Skip to content

Commit

Permalink
Automatically reposition InlineAutocomplete suggestions depending o…
Browse files Browse the repository at this point in the history
…n available space (#3614)

* Automatically reposition suggestions depending on available space

* Use isomorphic layout effect

* Create old-cherries-smile.md

* Add story

* Handle `NaN` case

* Remove unused import

* Add `suggestionsPlacement` prop

* fix typo belo -> below

Co-authored-by: Josh Black <joshblack@github.com>

---------

Co-authored-by: Josh Black <joshblack@github.com>
  • Loading branch information
iansan5653 and joshblack authored Aug 14, 2023
1 parent dbcd407 commit 709024f
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 12 deletions.
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
}

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'

0 comments on commit 709024f

Please sign in to comment.