From 0f4101bbf82e15afcc6f02ae1075a05dee7f261c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Fri, 14 Feb 2020 13:02:15 +0100 Subject: [PATCH] feat(core): support `onHighlight` on sources --- packages/autocomplete-core/completion.ts | 23 ++------- packages/autocomplete-core/onKeyDown.ts | 60 +++++++++++++---------- packages/autocomplete-core/propGetters.ts | 26 +++++++++- packages/autocomplete-core/types/api.ts | 16 ++++-- packages/autocomplete-core/utils.ts | 31 +++++++++++- stories/react.stories.tsx | 43 ++++++++++++++++ 6 files changed, 146 insertions(+), 53 deletions(-) diff --git a/packages/autocomplete-core/completion.ts b/packages/autocomplete-core/completion.ts index bf8e27a73..3173d085e 100644 --- a/packages/autocomplete-core/completion.ts +++ b/packages/autocomplete-core/completion.ts @@ -1,8 +1,5 @@ import { AutocompleteState, AutocompleteOptions } from './types'; -import { - getSuggestionFromHighlightedIndex, - getRelativeHighlightedIndex, -} from './utils'; +import { getHighlightedItem } from './utils'; interface GetCompletionProps { state: AutocompleteState; @@ -22,25 +19,13 @@ export function getCompletion({ return null; } - const suggestion = getSuggestionFromHighlightedIndex({ state }); - - if (!suggestion) { - return null; - } - - const item = - suggestion.items[getRelativeHighlightedIndex({ state, suggestion })]; - const inputValue = suggestion.source.getInputValue({ - suggestion: item, - state, - }); + const { itemValue } = getHighlightedItem({ state }); // The completion should appear only if the _first_ characters of the query // match with the suggestion. if ( state.query.length > 0 && - inputValue.toLocaleLowerCase().indexOf(state.query.toLocaleLowerCase()) === - 0 + itemValue.toLocaleLowerCase().indexOf(state.query.toLocaleLowerCase()) === 0 ) { // If the query typed has a different case than the suggestion, we want // to show the completion matching the case of the query. This makes both @@ -49,7 +34,7 @@ export function getCompletion({ // - query: 'Gui' // - suggestion: 'guitar' // => completion: 'Guitar' - const completion = state.query + inputValue.slice(state.query.length); + const completion = state.query + itemValue.slice(state.query.length); if (completion === state.query) { return null; diff --git a/packages/autocomplete-core/onKeyDown.ts b/packages/autocomplete-core/onKeyDown.ts index 8b75ca67e..fd12f153c 100644 --- a/packages/autocomplete-core/onKeyDown.ts +++ b/packages/autocomplete-core/onKeyDown.ts @@ -1,10 +1,7 @@ import { stateReducer } from './stateReducer'; import { onInput } from './onInput'; import { getCompletion } from './completion'; -import { - getSuggestionFromHighlightedIndex, - getRelativeHighlightedIndex, -} from './utils'; +import { getHighlightedItem } from './utils'; import { AutocompleteStore, @@ -50,6 +47,27 @@ export function onKeyDown({ `${props.id}-item-${store.getState().highlightedIndex}` ); nodeItem?.scrollIntoView(false); + + if (store.getState().highlightedIndex >= 0) { + const { item, itemValue, itemUrl, source } = getHighlightedItem({ + state: store.getState(), + }); + + source.onHighlight({ + suggestion: item, + suggestionValue: itemValue, + suggestionUrl: itemUrl, + source, + state: store.getState(), + setHighlightedIndex, + setQuery, + setSuggestions, + setIsOpen, + setStatus, + setContext, + event, + }); + } } else if ( (event.key === 'Tab' || // When the user hits the right arrow and is at the end of the input @@ -97,30 +115,17 @@ export function onKeyDown({ ); props.onStateChange({ state: store.getState() }); } else if (event.key === 'Enter') { + // No item is selected, so we let the browser handle the native `onSubmit` + // form event. if (store.getState().highlightedIndex < 0) { return; } - const suggestion = getSuggestionFromHighlightedIndex({ - state: store.getState(), - }); - - const item = - suggestion.items[ - getRelativeHighlightedIndex({ state: store.getState(), suggestion }) - ]; - - if (item) { - // This prevents the `onSubmit` event to be sent when an item is selected. - event.preventDefault(); - } + // This prevents the `onSubmit` event to be sent because an item is + // highlighted. + event.preventDefault(); - const itemUrl = suggestion.source.getSuggestionUrl({ - suggestion: item, - state: store.getState(), - }); - const inputValue = suggestion.source.getInputValue({ - suggestion: item, + const { item, itemValue, itemUrl, source } = getHighlightedItem({ state: store.getState(), }); @@ -144,7 +149,7 @@ export function onKeyDown({ // Keep native browser behavior } else { onInput({ - query: inputValue, + query: itemValue, store, props, setHighlightedIndex, @@ -157,11 +162,11 @@ export function onKeyDown({ isOpen: false, }, }).then(() => { - suggestion.source.onSelect({ + source.onSelect({ suggestion: item, - suggestionValue: inputValue, + suggestionValue: itemValue, suggestionUrl: itemUrl, - source: suggestion.source, + source, state: store.getState(), setHighlightedIndex, setQuery, @@ -169,6 +174,7 @@ export function onKeyDown({ setIsOpen, setStatus, setContext, + event, }); props.onStateChange({ state: store.getState() }); diff --git a/packages/autocomplete-core/propGetters.ts b/packages/autocomplete-core/propGetters.ts index bbc9c4ed7..10374c636 100644 --- a/packages/autocomplete-core/propGetters.ts +++ b/packages/autocomplete-core/propGetters.ts @@ -1,7 +1,7 @@ import { stateReducer } from './stateReducer'; import { onInput } from './onInput'; import { onKeyDown } from './onKeyDown'; -import { isSpecialClick } from './utils'; +import { isSpecialClick, getHighlightedItem } from './utils'; import { GetRootProps, @@ -211,7 +211,7 @@ export function getPropGetters({ role: 'option', 'aria-selected': store.getState().highlightedIndex === item.__autocomplete_id, - onMouseMove() { + onMouseMove(event) { if (item.__autocomplete_id === store.getState().highlightedIndex) { return; } @@ -227,6 +227,27 @@ export function getPropGetters({ ) ); props.onStateChange({ state: store.getState() }); + + if (store.getState().highlightedIndex >= 0) { + const { item, itemValue, itemUrl, source } = getHighlightedItem({ + state: store.getState(), + }); + + source.onHighlight({ + suggestion: item, + suggestionValue: itemValue, + suggestionUrl: itemUrl, + source, + state: store.getState(), + setHighlightedIndex, + setQuery, + setSuggestions, + setIsOpen, + setStatus, + setContext, + event, + }); + } }, onMouseDown(event: MouseEvent) { // Prevents the `activeElement` from being changed to the item so it @@ -273,6 +294,7 @@ export function getPropGetters({ setIsOpen, setStatus, setContext, + event, }); props.onStateChange({ state: store.getState() }); diff --git a/packages/autocomplete-core/types/api.ts b/packages/autocomplete-core/types/api.ts index 41ef4de25..cce9ed114 100644 --- a/packages/autocomplete-core/types/api.ts +++ b/packages/autocomplete-core/types/api.ts @@ -26,11 +26,14 @@ interface ItemParams { } interface OnSelectParams - extends AutocompleteSetters, - ItemParams { + extends ItemParams, + AutocompleteSetters { state: AutocompleteState; + event: Event; } +type OnHighlightParams = OnSelectParams; + interface OnSubmitParams extends AutocompleteSetters { state: AutocompleteState; event: Event; @@ -67,9 +70,16 @@ export interface PublicAutocompleteSource { | Array> | Promise>>; /** - * Called when an item is selected. + * Function called when an item is selected. */ onSelect?(params: OnSelectParams): void; + /** + * Function called when an item is highlighted. + * + * An item is highlighted either via keyboard navigation or via mouse over. + * You can trigger different behaviors based on the event `type`. + */ + onHighlight?(params: OnHighlightParams): void; } export type AutocompleteSource = { diff --git a/packages/autocomplete-core/utils.ts b/packages/autocomplete-core/utils.ts index 2056d5b44..4ff626a23 100644 --- a/packages/autocomplete-core/utils.ts +++ b/packages/autocomplete-core/utils.ts @@ -51,6 +51,7 @@ function normalizeSource( onSelect({ setIsOpen }) { setIsOpen(false); }, + onHighlight: noop, ...source, }; } @@ -98,7 +99,7 @@ export function getNextHighlightedIndex( // We don't have access to the autocomplete source when we call `onKeyDown` // or `onClick` because those are native browser events. // However, we can get the source from the suggestion index. -export function getSuggestionFromHighlightedIndex({ +function getSuggestionFromHighlightedIndex({ state, }: { state: AutocompleteState; @@ -139,7 +140,13 @@ export function getSuggestionFromHighlightedIndex({ * (absolute: 3, relative: 1) * @param param0 */ -export function getRelativeHighlightedIndex({ state, suggestion }): number { +function getRelativeHighlightedIndex({ + state, + suggestion, +}: { + state: AutocompleteState; + suggestion: AutocompleteSuggestion; +}): number { let isOffsetFound = false; let counter = 0; let previousItemsOffset = 0; @@ -159,3 +166,23 @@ export function getRelativeHighlightedIndex({ state, suggestion }): number { return state.highlightedIndex - previousItemsOffset; } + +export function getHighlightedItem({ + state, +}: { + state: AutocompleteState; +}) { + const suggestion = getSuggestionFromHighlightedIndex({ state }); + const item = + suggestion.items[getRelativeHighlightedIndex({ state, suggestion })]; + const source = suggestion.source; + const itemValue = source.getInputValue({ suggestion: item, state }); + const itemUrl = source.getSuggestionUrl({ suggestion: item, state }); + + return { + item, + itemValue, + itemUrl, + source, + }; +} diff --git a/stories/react.stories.tsx b/stories/react.stories.tsx index 94939769f..3b4516aa4 100644 --- a/stories/react.stories.tsx +++ b/stories/react.stories.tsx @@ -92,6 +92,49 @@ storiesOf('React', module) container ); + return container; + }) + ) + .add( + 'Replaces query onHighlight', + withPlayground(({ container, dropdownContainer }) => { + render( + { + return [ + { + getInputValue({ suggestion }) { + return suggestion.query; + }, + onHighlight({ suggestionValue, setQuery, event }) { + if (event.type === 'keydown') { + setQuery(suggestionValue); + } + }, + getSuggestions({ query }) { + return getAlgoliaHits({ + searchClient, + queries: [ + { + indexName: 'instant_search_demo_query_suggestions', + query, + params: { + hitsPerPage: 4, + }, + }, + ], + }); + }, + }, + ]; + }} + />, + container + ); + return container; }) );