Skip to content

Automatically reposition InlineAutocomplete suggestions depending on available space #3614

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
merged 9 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/old-cherries-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@primer/react": patch
---

Automatically reposition `InlineAutocomplete` suggestions depending on available space

<!-- Changed components: InlineAutocomplete -->
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,64 @@ export const CustomRendering = ({loading, tabInserts}: ArgProps) => {
</FormControl>
)
}

export const AbovePositioning = () => {
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)

const onShowSuggestions = (event: ShowSuggestionsEvent) => {
setSuggestions(
filteredUsers(event.query).map(user => ({
value: user.login,
render: props => <UserSuggestion user={user} {...props} />,
})),
)
}

const onHideSuggestions = () => setSuggestions(null)

return (
<FormControl sx={{position: 'absolute', bottom: '15px'}}>
<FormControl.Label>Inline Autocomplete Demo</FormControl.Label>
<FormControl.Caption>Try typing &apos;@&apos; to show user suggestions.</FormControl.Caption>
<InlineAutocomplete
triggers={[{triggerChar: '@'}]}
suggestions={suggestions}
onShowSuggestions={onShowSuggestions}
onHideSuggestions={onHideSuggestions}
suggestionsPlacement="above"
>
<Textarea sx={{height: '70px'}} />
</InlineAutocomplete>
</FormControl>
)
}

export const AutoPositioning = () => {
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)

const onShowSuggestions = (event: ShowSuggestionsEvent) => {
setSuggestions(
filteredUsers(event.query).map(user => ({
value: user.login,
render: props => <UserSuggestion user={user} {...props} />,
})),
)
}

const onHideSuggestions = () => setSuggestions(null)

return (
<FormControl sx={{position: 'absolute', bottom: '15px'}}>
<FormControl.Label>Inline Autocomplete Demo</FormControl.Label>
<FormControl.Caption>Try typing &apos;@&apos; to show user suggestions.</FormControl.Caption>
<InlineAutocomplete
triggers={[{triggerChar: '@'}]}
suggestions={suggestions}
onShowSuggestions={onShowSuggestions}
onHideSuggestions={onHideSuggestions}
>
<Textarea sx={{height: '70px'}} />
</InlineAutocomplete>
</FormControl>
)
}
27 changes: 23 additions & 4 deletions src/drafts/InlineAutocomplete/InlineAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import {BetterSystemStyleObject} from '../../sx'
import {useSyntheticChange} from '../hooks/useSyntheticChange'
import {getAbsoluteCharacterCoordinates} from '../utils/character-coordinates'

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

import {useRefObjectAsForwardedRef} from '../../hooks'
Expand Down Expand Up @@ -52,6 +59,18 @@ export type InlineAutocompleteProps = {
* thrown.
*/
children: TextInputCompatibleChild
/**
* Control which side of the insertion point the suggestions list appears on by default. This
* should almost always be `"below"` because it typically provides a better user experience
* (the most-relevant suggestions will appear closest to the text). However, if the input
* is always near the bottom of the screen (ie, a chat composition form), it may be better to
* display the suggestions above the input.
*
* In either case, if there is not enough room to display the suggestions in the default direction,
* the suggestions will appear in the other direction.
* @default "below"
*/
suggestionsPlacement?: SuggestionsPlacement
Copy link
Member

@siddharthkp siddharthkp Aug 14, 2023

Choose a reason for hiding this comment

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

(Not blocking suggestion) Should we lock to the suggestion if it's given? For example, If we start at "above", stick to above even when the list becomes smaller and there is now enough space below. Layout shift when filtering could be a bad user experience.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 IMHO it should be sticky

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 think that works for if the list becomes shorter while it's open, but not if the list becomes larger. If we try to distinguish between those cases, the logic becomes pretty challenging and I don't know if it's worth it considering how uncommon that will actually be in practice.

I tested in VSCode just now and the suggestions list can flip as you type. I've personally never noticed that before, so I think that this is probably not a very common case.

}

const getSelectionStart = (element: TextInputElement) => {
Expand Down Expand Up @@ -79,6 +98,7 @@ const InlineAutocomplete = ({
sx,
children,
tabInsertsSuggestions = false,
suggestionsPlacement = 'below',
// Forward accessibility props so it works with FormControl
...forwardProps
}: InlineAutocompleteProps & React.ComponentProps<'textarea' | 'input'>) => {
Expand Down Expand Up @@ -108,7 +128,6 @@ const InlineAutocomplete = ({
(getSelectionStart(inputRef.current) ?? 0) - showEventRef.current.query.length,
)
: {top: 0, left: 0, height: 0}
const suggestionsOffset = {top: triggerCharCoords.top + triggerCharCoords.height, left: triggerCharCoords.left}

// User can blur while suggestions are visible with shift+tab
const onBlur: React.FocusEventHandler<TextInputElement> = () => {
Expand Down Expand Up @@ -198,10 +217,10 @@ const InlineAutocomplete = ({
inputRef={inputRef}
onCommit={onCommit}
onClose={onHideSuggestions}
top={suggestionsOffset.top || 0}
left={suggestionsOffset.left || 0}
triggerCharCoords={triggerCharCoords}
visible={suggestionsVisible}
tabInsertsSuggestions={tabInsertsSuggestions}
defaultPlacement={suggestionsPlacement}
/>

<Portal>
Expand Down
47 changes: 39 additions & 8 deletions src/drafts/InlineAutocomplete/_AutocompleteSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import React, {useCallback, useState} from 'react'
import React, {useCallback, useRef, useState} from 'react'
import Spinner from '../../Spinner'
import {ActionList, ActionListItemProps} from '../../ActionList'
import Box from '../../Box'
import {ComboboxCommitEvent, useCombobox} from '../hooks/useCombobox'
import Overlay from '../../Overlay'

import {Suggestion, Suggestions, TextInputElement} from './types'
import {Suggestion, Suggestions, SuggestionsPlacement, TextInputElement} from './types'
import {getSuggestionKey, getSuggestionValue} from './utils'
import {CharacterCoordinates} from '../utils/character-coordinates'
import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'

type AutoCompleteSuggestionsProps = {
suggestions: Suggestions | null
portalName?: string
// make top/left primitives instead of a Coordinates object to avoid extra re-renders
top: number
left: number
triggerCharCoords: CharacterCoordinates
onClose: () => void
onCommit: (suggestion: string) => void
inputRef: React.RefObject<TextInputElement>
visible: boolean
tabInsertsSuggestions: boolean
defaultPlacement: SuggestionsPlacement
}

const LoadingIndicator = () => (
Expand Down Expand Up @@ -54,14 +55,16 @@ const SuggestionListItem = ({suggestion}: {suggestion: Suggestion}) => {
const AutocompleteSuggestions = ({
suggestions,
portalName,
top,
left,
triggerCharCoords,
onClose,
onCommit: externalOnCommit,
inputRef,
visible,
tabInsertsSuggestions,
defaultPlacement,
}: AutoCompleteSuggestionsProps) => {
const overlayRef = useRef<HTMLDivElement | null>(null)

// It seems wierd to use state instead of a ref here, but because the list is inside an
// AnchoredOverlay it is not always mounted - so we want to reinitialize the Combobox when it mounts
const [list, setList] = useState<HTMLUListElement | null>(null)
Expand All @@ -85,6 +88,32 @@ const AutocompleteSuggestions = ({
defaultFirstOption: true,
})

const [top, setTop] = useState(0)
useIsomorphicLayoutEffect(
function recalculateTop() {
const overlayHeight = overlayRef.current?.offsetHeight ?? 0

const belowOffset = triggerCharCoords.top + triggerCharCoords.height
const wouldOverflowBelow = belowOffset + overlayHeight > window.innerHeight

const aboveOffset = triggerCharCoords.top - overlayHeight
const wouldOverflowAbove = aboveOffset < 0

// Only override the default if it would overflow in the default direction and it would not overflow in the override direction
const result = {
below: wouldOverflowBelow && !wouldOverflowAbove ? aboveOffset : belowOffset,
above: wouldOverflowAbove && !wouldOverflowBelow ? belowOffset : aboveOffset,
}[defaultPlacement]

// Sometimes the value can be NaN if layout is not available (ie, SSR or JSDOM)
const resultNotNaN = Number.isNaN(result) ? 0 : result

setTop(resultNotNaN)
},
// this is a cheap effect and we want it to run when pretty much anything that could affect position changes
[triggerCharCoords.top, triggerCharCoords.height, suggestions, visible, defaultPlacement],
)

// Conditional rendering appears wrong at first - it means that we are reconstructing the
// Combobox instance every time the suggestions appear. But this is what we want - otherwise
// the textarea would always have the `combobox` role, which is incorrect (a textarea should
Expand All @@ -98,7 +127,9 @@ const AutocompleteSuggestions = ({
preventFocusOnOpen
portalContainerName={portalName}
sx={{position: 'fixed'}}
{...{top, left}}
top={top}
left={triggerCharCoords.left}
ref={overlayRef}
>
<ActionList ref={setList}>
{suggestions === 'loading' ? (
Expand Down
2 changes: 2 additions & 0 deletions src/drafts/InlineAutocomplete/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ export type TextInputCompatibleChild = React.ReactElement<
JSX.IntrinsicElements['textarea'] | JSX.IntrinsicElements['input']
> &
React.RefAttributes<HTMLInputElement & HTMLTextAreaElement>

export type SuggestionsPlacement = 'above' | 'below'