From 61a804f593efa24b22ae7c74f63025499007b428 Mon Sep 17 00:00:00 2001 From: Tom Southall Date: Thu, 17 Feb 2022 01:36:53 +0000 Subject: [PATCH] Begin to refactor towards a global state context --- src/App.jsx | 5 + src/lib/actions/actionTypes.js | 7 + src/lib/actions/actions.js | 55 ++++ src/lib/components/container.jsx | 248 +++++------------- .../components/effects/containerEffects.js | 27 -- src/lib/components/hooks/containerEffects.js | 27 ++ .../containerEffects.test.js | 26 +- src/lib/components/hooks/useData.js | 133 ++++++++++ src/lib/components/item.jsx | 11 +- src/lib/components/matchingText.jsx | 31 +-- src/lib/context/turnstone.jsx | 25 +- src/lib/index.test.jsx | 1 + src/lib/reducers/reducer.js | 38 +++ src/lib/utils/itemText.js | 13 + 14 files changed, 388 insertions(+), 259 deletions(-) create mode 100644 src/lib/actions/actionTypes.js create mode 100644 src/lib/actions/actions.js delete mode 100644 src/lib/components/effects/containerEffects.js create mode 100644 src/lib/components/hooks/containerEffects.js rename src/lib/components/{effects => hooks}/containerEffects.test.js (68%) create mode 100644 src/lib/components/hooks/useData.js create mode 100644 src/lib/reducers/reducer.js create mode 100644 src/lib/utils/itemText.js diff --git a/src/App.jsx b/src/App.jsx index fd1131d..779594c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,10 @@ import React from 'react' import Turnstone from './lib' import fruits from './data' +const styles = { + highlightedItem: 'highlightedItem' +} + const App = () => { return ( { maxItems={10} noItemsMessage={'No matching fruit found'} placeholder={'Type something fruity'} + styles={styles} /> ) } diff --git a/src/lib/actions/actionTypes.js b/src/lib/actions/actionTypes.js new file mode 100644 index 0000000..79387d0 --- /dev/null +++ b/src/lib/actions/actionTypes.js @@ -0,0 +1,7 @@ +export const SET_QUERY = 'SET_QUERY' +export const SET_ITEMS = 'SET_ITEMS' +export const SET_HIGHLIGHTED = 'SET_HIGHLIGHTED' +export const CLEAR_HIGHLIGHTED = 'CLEAR_HIGHLIGHTED' +export const NEXT_HIGHLIGHTED = 'NEXT_HIGHLIGHTED' +export const PREV_HIGHLIGHTED = 'PREV_HIGHLIGHTED' +export const SET_SELECTED = 'SET_SELECTED' \ No newline at end of file diff --git a/src/lib/actions/actions.js b/src/lib/actions/actions.js new file mode 100644 index 0000000..938e3a9 --- /dev/null +++ b/src/lib/actions/actions.js @@ -0,0 +1,55 @@ +import * as types from './actionTypes' +import undef from '../utils/undef' + +export const setQuery = (query) => { + const payload = { + query, + selected: undef + } + + return { + type: types.SET_QUERY, + payload + } +} + +export const setItems = (items) => { + const highlighted = items && items.length + ? { index: 0, text: items[0].text } + : undef + + const payload = { + items, + highlighted + } + + return { + type: types.SET_ITEMS, + payload + } +} + +export const setHighlighted = (index) => { + return { + type: types.SET_HIGHLIGHTED, + index + } +} + +export const clearHighlighted = () => { + return { + type: types.CLEAR_HIGHLIGHTED + } +} + +export const highlightPrev = () => { + return { + type: types.PREV_HIGHLIGHTED + } +} + +export const highlightNext = () => { + return { + type: types.NEXT_HIGHLIGHTED + } +} \ No newline at end of file diff --git a/src/lib/components/container.jsx b/src/lib/components/container.jsx index cef5b9e..72940dd 100644 --- a/src/lib/components/container.jsx +++ b/src/lib/components/container.jsx @@ -1,17 +1,22 @@ import React, { useState, useEffect, useRef, useContext, useMemo } from 'react' -import useSWR from 'swr' import setify from 'setify' // Sets input value without changing cursor position -import swrLaggyMiddleware from '../utils/swrLaggyMiddleware' import { TurnstoneContext } from '../context/turnstone' import Items from './items' import { useDebounce } from 'use-debounce' -import { useAutoFocus, useQueryChange } from './effects/containerEffects' -import firstOfType from 'first-of-type' +import { useAutoFocus, useQueryChange } from './hooks/containerEffects' +import useData from './hooks/useData' import undef from '../utils/undef' import isUndefined from '../utils/isUndefined' import startsWithCaseInsensitive from '../utils/startsWithCaseInsensitive' import defaultStyles from './styles/input.styles.js' -// import fetch from 'unfetch' // TODO: may need this if not using Next.js +import { + setQuery, + setItems, + setHighlighted, + clearHighlighted, + highlightPrev, + highlightNext +} from '../actions/actions' export default function Container(props) { // Destructure props @@ -51,17 +56,15 @@ export default function Container(props) { // Global state from context const { + state, + dispatch, customStyles, - queryState, - setQueryState, - highlightedState, - setHighlightedState, selectedState, setSelectedState } = useContext(TurnstoneContext) // Component state - const [debouncedQueryState] = useDebounce(queryState, debounceWait) + const [debouncedQuery] = useDebounce(state.query, debounceWait) const [hasFocus, setHasFocus] = useState(false) const [isLoaded, setIsLoaded] = useState(false) @@ -69,181 +72,77 @@ export default function Container(props) { const queryInput = useRef(null) const typeaheadInput = useRef(null) - const itemText = (item, displayField) => { - const itemType = typeof item - const text = - itemType === 'string' && isUndefined(displayField) - ? item - : item[displayField] - return isUndefined(text) ? firstOfType(item, 'string') || '' : text + const isImmutable = () => { + return itemGroupsAreImmutable && + !( + defaultItemGroups && + !defaultItemGroupsAreImmutable && + debouncedQuery.length === 0 + ) } - const filterSuppliedData = (group, query) => { - const { data, displayField, dataSearchType } = group - const searchType = dataSearchType - ? dataSearchType.toLowerCase() - : dataSearchType - - switch (searchType) { - case 'startswith': - return data.filter((item) => - itemText(item, displayField) - .toLowerCase() - .startsWith(query.toLowerCase()) - ) - case 'contains': - return data.filter((item) => - itemText(item, displayField) - .toLowerCase() - .includes(query.toLowerCase()) - ) - default: - return data - } - } - - const limitResults = (groups, groupsProp) => { - // TODO: Place into a util/callback function - const ratios = groupsProp.map((group) => group.ratio || 1) - const ratioTotal = ratios.reduce((total, ratio) => total + ratio, 0) - const ratioMultiplier = maxItems / ratioTotal - const resultTotal = groups.flat().length - const groupCounts = [] - let unassignedSlots = resultTotal < maxItems ? resultTotal : maxItems - - while (unassignedSlots > 0) { - groups = groups.map((group, i) => { - if (!groupCounts[i]) { - groupCounts[i] = Math.round(ratios[i] * ratioMultiplier) - if (groupCounts[i] > group.length) groupCounts[i] = group.length - unassignedSlots = unassignedSlots - groupCounts[i] - } else if (groupCounts[i] < group.length) { - unassignedSlots -= ++groupCounts[i] - } - return group - }) - } - - return groups.map((group, index) => group.slice(0, groupCounts[index])) - } - - const fetcher = (q) => { - if (defaultItemGroups && q.length > 0 && q.length < minQueryLength) - return [] - else if (!defaultItemGroups && q.length < minQueryLength) return [] - - const groupsProp = - defaultItemGroups && !q.length ? defaultItemGroups : itemGroups - - const promises = groupsProp.map((g) => { - if (typeof g.data === 'function') { - return g.data(q) - } else { - return Promise.resolve({ data: filterSuppliedData(g, q) }) - } - }) - - return Promise.all(promises).then((groups) => { - groups = groups.reduce((prevGroups, group, groupIndex) => { - return [ - ...prevGroups, - group.data.map((item) => ({ - value: item, - text: itemText(item, groupsProp[groupIndex].displayField), - groupIndex, - groupName: groupsProp[groupIndex].name - })) - ] - }, []) - - if (groups.length) groups = limitResults(groups, groupsProp) - - return groups.flat() - }) - } - - const swrBaseOptions = { - use: [swrLaggyMiddleware] - } - const swrOptions = - itemGroupsAreImmutable && - !( - defaultItemGroups && - !defaultItemGroupsAreImmutable && - debouncedQueryState.length === 0 - ) - ? { - ...swrBaseOptions, - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false - } - : swrBaseOptions - - // See: https://github.com/vercel/swr/discussions/1810 - const dummyArgToEnsureCachingOfZeroLengthStrings = 'X' - - const swrData = useSWR( - [ - debouncedQueryState.toLowerCase(), - dummyArgToEnsureCachingOfZeroLengthStrings - ], - fetcher, - swrOptions + const swrData = useData( + debouncedQuery.toLowerCase(), + isImmutable(), + itemGroups, + defaultItemGroups, + minQueryLength, + maxItems, + dispatch ).data - const items = useMemo(() => { - // console.log('swrData', swrData) - return swrData || [] + // Store retrieved data in global state + useEffect(() => { + dispatch(setItems(swrData || [])) }, [swrData]) // Autofocus on render if prop is true useAutoFocus(queryInput, autoFocus) - // As soon as the queryState changes (ignoring debounce) update the - // typeahead value and the query value - useQueryChange(queryInput, typeaheadInput, queryState, onChange) + // As soon as the query state changes (ignoring debounce) update the + // typeahead value and the query value and fire onChnage + useQueryChange(state.query, queryInput, typeaheadInput, onChange) - // Whenever the dropdown items change, set the highlighted item - // to either the first or nothing if there are no items - useEffect(() => { - setHighlightedState( - items && items.length ? { index: 0, text: items[0].text } : undef - ) - }, [items, setHighlightedState]) + // // Whenever the dropdown items change, set the highlighted item + // // to either the first or nothing if there are no items + // useEffect(() => { + // setHighlightedState( + // state.items && state.items.length ? { index: 0, text: state.items[0].text } : undef + // ) + // }, [state.items, setHighlightedState]) // Figure out whether we are able to display a loading state //TODO: useReducer instead of useeffect? useEffect(() => { - if (items && items.length) setIsLoaded(true) - else if (queryState.length <= minQueryLength) setIsLoaded(false) - }, [items, queryState, isLoaded, minQueryLength, setIsLoaded]) + if (state.items && state.items.length) setIsLoaded(true) + else if (state.query.length <= minQueryLength) setIsLoaded(false) + }, [state.items, state.query, isLoaded, minQueryLength, setIsLoaded]) // When the highlighted item changes, make sure the typeahead matches and format // the query text to match the case of the typeahead text useEffect(() => { const typeAheadValue = - highlightedState && + state.highlighted && hasFocus && queryInput.current.value.length > 0 && - startsWithCaseInsensitive(highlightedState.text, queryInput.current.value) - ? highlightedState.text + startsWithCaseInsensitive(state.highlighted.text, queryInput.current.value) + ? state.highlighted.text : '' const queryValue = formatQuery(queryInput.current.value, typeAheadValue) typeaheadInput.current.value = typeAheadValue setify(queryInput.current, queryValue) - }, [highlightedState, hasFocus, setQueryState]) + }, [state.highlighted, hasFocus]) // When an item is selected alter the query to match and fire applicable events useEffect(() => { if (!isUndefined(selectedState)) { typeaheadInput.current.value = '' - setQueryState(selectedState.text) //TODO: Put in a reducer? + dispatch(setQuery(selectedState.text)) queryInput.current.blur() if (typeof onSelect === 'function') onSelect(selectedState.value) } - }, [selectedState, setQueryState, onSelect]) + }, [selectedState, onSelect]) const formatQuery = (query, typeahead) => { const formattedQuery = typeahead.substring(0, query.length) @@ -254,29 +153,25 @@ export default function Container(props) { : query } - const setHighlightedIndex = (i) => { - setHighlightedState({ index: i, text: items[i].text }) - } - const onTabOrEnter = (keyPressed) => { // keyPressed must be 'enter' or 'tab' - const highlightedIndex = highlightedState && highlightedState.index + const highlightedIndex = state.highlighted && state.highlighted.index const highlightedItem = !isUndefined(highlightedIndex) - ? items[highlightedIndex] + ? state.items[highlightedIndex] : undef const f = keyPressed.toLowerCase() === 'enter' ? onEnter : onTab - setSelectedState(items[highlightedIndex]) + setSelectedState(state.items[highlightedIndex]) if (typeof f === 'function') f(queryInput.current.value, highlightedItem) } const isX = () => { - return !!queryState + return !!state.query } const isDropdown = () => { - if (hasFocus && !queryState && defaultItemGroups) return true - if (queryState.length < minQueryLength) return false - return hasFocus && queryState + if (hasFocus && !state.query && defaultItemGroups) return true + if (state.query.length < minQueryLength) return false + return hasFocus && state.query } // Handle different keypresses and call the appropriate action creators @@ -284,13 +179,11 @@ export default function Container(props) { switch (evt.keyCode) { case 40: // Down arrow evt.preventDefault() - if (highlightedState.index < items.length - 1) - setHighlightedIndex(highlightedState.index + 1) + dispatch(highlightNext()) break case 38: // Up arrow evt.preventDefault() - if (highlightedState.index > 0) - setHighlightedIndex(highlightedState.index - 1) + dispatch(highlightPrev()) break case 13: // Enter evt.preventDefault() @@ -308,8 +201,7 @@ export default function Container(props) { } const handleInput = () => { - setSelectedState() - setQueryState(queryInput.current.value) + dispatch(setQuery(queryInput.current.value)) } const handleX = (evt) => { @@ -318,24 +210,24 @@ export default function Container(props) { } const clearState = () => { - setSelectedState() - setQueryState('') - setTimeout(() => queryInput.current.focus(), debounceWait) + dispatch(setQuery(queryInput.current.value)) + setTimeout(() => queryInput.current.focus(), debounceWait) // TODO: Put in useEffect } const handleFocus = () => { - setHasFocus(true) + setHasFocus(true) //TODO: make hasFocus part of global state? - if (items && items.length > 0) { - setHighlightedIndex(0) - } else { - setHighlightedState() + if (state.items && state.items.length > 0) { + dispatch(setHighlighted({ index: 0, text: state.items[0].text })) + } + else { + dispatch(clearHighlighted()) } } const handleBlur = () => { setHasFocus(false) - setHighlightedState() + dispatch(clearHighlighted()) } return ( @@ -380,7 +272,7 @@ export default function Container(props) { {isDropdown() && ( diff --git a/src/lib/components/effects/containerEffects.js b/src/lib/components/effects/containerEffects.js deleted file mode 100644 index a36a9d8..0000000 --- a/src/lib/components/effects/containerEffects.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect } from 'react' -import setify from 'setify' // Sets input value without changing cursor position -//import startsWithCaseInsensitive from '../../utils/startsWithCaseInsensitive' - -export const useAutoFocus = (queryInput, autoFocus) => { // TODO: might be able to use autofus property of input for this - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autofocus - useEffect(() => { - if (autoFocus) queryInput.current.focus() - }, [autoFocus]) -} - -export const useQueryChange = (queryInput, typeaheadInput, queryState, onChange) => { - useEffect(() => { - // console.log('queryState changed', { queryState }) - const value = (() => { - const currentValue = typeaheadInput.current.value - if (!queryState) return '' - //if (!queryMatchesTypeahead(queryState, currentValue, true)) return '' - if (!currentValue.startsWith(queryState)) return '' - return currentValue - })() - - typeaheadInput.current.value = value - - setify(queryInput.current, queryState) - if (typeof onChange === 'function') onChange(queryState) - }, [queryState, onChange]) -} \ No newline at end of file diff --git a/src/lib/components/hooks/containerEffects.js b/src/lib/components/hooks/containerEffects.js new file mode 100644 index 0000000..75a7401 --- /dev/null +++ b/src/lib/components/hooks/containerEffects.js @@ -0,0 +1,27 @@ +import { useEffect } from 'react' +import setify from 'setify' // Sets input value without changing cursor position +//import startsWithCaseInsensitive from '../../utils/startsWithCaseInsensitive' + +export const useAutoFocus = (queryInput, autoFocus) => { // TODO: might be able to use autofocus property of input for this - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autofocus + useEffect(() => { + if (autoFocus) queryInput.current.focus() + }, [autoFocus]) +} + +export const useQueryChange = (query, queryInput, typeaheadInput, onChange) => { + useEffect(() => { + // console.log('query changed', { query }) + const value = (() => { + const currentValue = typeaheadInput.current.value + if (!query) return '' + //if (!queryMatchesTypeahead(query, currentValue, true)) return '' + if (!currentValue.startsWith(query)) return '' + return currentValue + })() + + typeaheadInput.current.value = value + + setify(queryInput.current, query) + if (typeof onChange === 'function') onChange(query) + }, [query, onChange]) +} \ No newline at end of file diff --git a/src/lib/components/effects/containerEffects.test.js b/src/lib/components/hooks/containerEffects.test.js similarity index 68% rename from src/lib/components/effects/containerEffects.test.js rename to src/lib/components/hooks/containerEffects.test.js index 9f8b627..0376370 100644 --- a/src/lib/components/effects/containerEffects.test.js +++ b/src/lib/components/hooks/containerEffects.test.js @@ -34,43 +34,43 @@ describe('useQueryChange', () => { const typeaheadValue = 'Chicago, Illinois, United States' const queryValue = 'Chi' - test('Empty queryState causes both query and typeahead inputs to be emptied', () => { + test('Empty query causes both query and typeahead inputs to be emptied', () => { const queryRef = inputRef(queryValue) const typeaheadRef = inputRef(typeaheadValue) - renderHook(() => useQueryChange(queryRef, typeaheadRef, '')) + renderHook(() => useQueryChange('', queryRef, typeaheadRef)) expect(queryRef.current.value).toBe('') expect(typeaheadRef.current.value).toBe('') }) - test('Empty query input value is set to queryState', () => { + test('Empty query input value is set to query', () => { const queryRef = inputRef() const typeaheadRef = inputRef() - renderHook(() => useQueryChange(queryRef, typeaheadRef, queryValue)) + renderHook(() => useQueryChange(queryValue, queryRef, typeaheadRef)) expect(queryRef.current.value).toBe(queryValue) }) - test('If queryState matches typeahead, both values remain unchanged', () => { + test('If query matches typeahead, both values remain unchanged', () => { const queryRef = inputRef() const typeaheadRef = inputRef(typeaheadValue) - renderHook(() => useQueryChange(queryRef, typeaheadRef, queryValue)) + renderHook(() => useQueryChange(queryValue, queryRef, typeaheadRef)) expect(queryRef.current.value).toBe(queryValue) expect(typeaheadRef.current.value).toBe(typeaheadValue) }) - test('If queryState does not match typeahead, typeahead is emptied', () => { - const queryState = 'Chil' - const queryRef = inputRef(queryState) + test('If query does not match typeahead, typeahead is emptied', () => { + const query = 'Chil' + const queryRef = inputRef(query) const typeaheadRef = inputRef(typeaheadValue) - renderHook(() => useQueryChange(queryRef, typeaheadRef, queryState)) - expect(queryRef.current.value).toBe(queryState) + renderHook(() => useQueryChange(query, queryRef, typeaheadRef)) + expect(queryRef.current.value).toBe(query) expect(typeaheadRef.current.value).toBe('') }) - test('If onChange is present, it is called and passed the queryState', () => { + test('If onChange is present, it is called and passed the query', () => { const queryRef = inputRef() const typeaheadRef = inputRef() const onChange = vi.fn() - renderHook(() => useQueryChange(queryRef, typeaheadRef, queryValue, onChange)) + renderHook(() => useQueryChange(queryValue, queryRef, typeaheadRef, onChange)) expect(onChange).toHaveBeenCalledWith(queryValue) }) }) \ No newline at end of file diff --git a/src/lib/components/hooks/useData.js b/src/lib/components/hooks/useData.js new file mode 100644 index 0000000..895376d --- /dev/null +++ b/src/lib/components/hooks/useData.js @@ -0,0 +1,133 @@ +import useSWR from 'swr' +import firstOfType from 'first-of-type' +import swrLaggyMiddleware from '../../utils/swrLaggyMiddleware' +import isUndefined from '../../utils/isUndefined' + +const filterSuppliedData = (group, query) => { + const { data, displayField, dataSearchType } = group + const searchType = dataSearchType + ? dataSearchType.toLowerCase() + : dataSearchType + + if(!query.length) return [] + + switch (searchType) { + case 'startswith': + return data.filter((item) => + itemText(item, displayField) + .toLowerCase() + .startsWith(query.toLowerCase()) + ) + case 'contains': + return data.filter((item) => + itemText(item, displayField) + .toLowerCase() + .includes(query.toLowerCase()) + ) + default: + return data + } +} + +const limitResults = (groups, groupsProp, maxItems) => { + const ratios = groupsProp.map((group) => group.ratio || 1) + const ratioTotal = ratios.reduce((total, ratio) => total + ratio, 0) + const ratioMultiplier = maxItems / ratioTotal + const resultTotal = groups.flat().length + const groupCounts = [] + let unassignedSlots = resultTotal < maxItems ? resultTotal : maxItems + + while (unassignedSlots > 0) { // TODO: Use something better than a while loop + groups = groups.map((group, i) => { + if (!groupCounts[i]) { + groupCounts[i] = Math.round(ratios[i] * ratioMultiplier) + if (groupCounts[i] > group.length) groupCounts[i] = group.length + unassignedSlots = unassignedSlots - groupCounts[i] + } else if (groupCounts[i] < group.length) { + unassignedSlots -= ++groupCounts[i] + } + return group + }) + } + + return groups.map((group, index) => group.slice(0, groupCounts[index])) +} + +const itemText = (item, displayField) => { + const itemType = typeof item + const text = + itemType === 'string' && isUndefined(displayField) + ? item + : item[displayField] + return isUndefined(text) ? firstOfType(item, 'string') || '' : text +} + +const swrOptions = (isImmutable) => { + const swrBaseOptions = { + use: [swrLaggyMiddleware] + } + + return isImmutable + ? { + ...swrBaseOptions, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false + } + : swrBaseOptions +} + +const useData = (query, isImmutable, itemGroups, defaultItemGroups, minQueryLength, maxItems) => { + console.log({query}) + + // See: https://github.com/vercel/swr/discussions/1810 + const dummyArgToEnsureCachingOfZeroLengthStrings = 'X' + + const fetcher = (q) => { + if (defaultItemGroups && q.length > 0 && q.length < minQueryLength) + return [] + else if (!defaultItemGroups && q.length < minQueryLength) return [] + + const groupsProp = + defaultItemGroups && !q.length ? defaultItemGroups : itemGroups + + const promises = groupsProp.map((g) => { + if (typeof g.data === 'function') { + return g.data(q) + } else { + return Promise.resolve({ data: filterSuppliedData(g, q) }) + } + }) + + return Promise.all(promises).then((groups) => { + groups = groups.reduce((prevGroups, group, groupIndex) => { + return [ + ...prevGroups, + group.data.map((item) => ({ + value: item, + text: itemText(item, groupsProp[groupIndex].displayField), + groupIndex, + groupName: groupsProp[groupIndex].name + })) + ] + }, []) + + if (groups.length) groups = limitResults(groups, groupsProp, maxItems) + + return groups.flat() + }) + } + + const swrObj = useSWR( + [ + query.toLowerCase(), + dummyArgToEnsureCachingOfZeroLengthStrings + ], + fetcher, + swrOptions(isImmutable) + ) + + return swrObj +} + +export default useData \ No newline at end of file diff --git a/src/lib/components/item.jsx b/src/lib/components/item.jsx index 977f811..b446118 100644 --- a/src/lib/components/item.jsx +++ b/src/lib/components/item.jsx @@ -4,14 +4,15 @@ import MatchingText from './matchingText' import { TurnstoneContext } from '../context/turnstone' import isUndefined from '../utils/isUndefined' import escapeStringRegExp from 'escape-string-regexp' +import { setHighlighted } from '../actions/actions' export default function Item(props) { const { index, item } = props const { + state, + dispatch, customStyles, - highlightedState, - setHighlightedState, setSelectedState, splitCharState } = useContext(TurnstoneContext) @@ -24,7 +25,7 @@ export default function Item(props) { const divClassName = useMemo(() => { let itemStyle = customStyles[ - (highlightedState && index === highlightedState.index) + (state.highlighted && index === state.highlighted.index) ? 'highlightedItem' : 'item' ] @@ -32,10 +33,10 @@ export default function Item(props) { return (index === 0 && customStyles.topItem) ? `${itemStyle} ${customStyles.topItem}` : itemStyle - }, [customStyles, highlightedState, index]) + }, [customStyles, state.highlighted, index]) const handleMouseEnter = () => { - setHighlightedState({ index, text: item.text }) + dispatch(setHighlighted(index)) } const handleClick = () => { diff --git a/src/lib/components/matchingText.jsx b/src/lib/components/matchingText.jsx index 31eaf4b..0b3fae8 100644 --- a/src/lib/components/matchingText.jsx +++ b/src/lib/components/matchingText.jsx @@ -1,37 +1,14 @@ -// import React, { useContext } from 'react' -// import { TurnstoneContext } from '../context/turnstone' -// import escapeStringRegExp from 'escape-string-regexp' - -// export default function ResultMatch(props) { -// const { text } = props -// const { customStyles, queryState } = useContext(TurnstoneContext) -// const regex = new RegExp('(' + escapeStringRegExp(queryState) + ')', 'i') -// const parts = text.split(regex) -// const index = parts.findIndex( -// (part) => part.toLowerCase() === queryState.toLowerCase() -// ) - -// parts[index] = ( -// -// {parts[index]} -// -// ) - -// return {parts} -// } - - import React, { useContext } from 'react' import { TurnstoneContext } from '../context/turnstone' import escapeStringRegExp from 'escape-string-regexp' export default function MatchingText(props) { const { text } = props - const { customStyles, queryState } = useContext(TurnstoneContext) - const regex = new RegExp('(' + escapeStringRegExp(queryState) + ')', 'i') - const parts = (queryState) ? text.split(regex) : [text] + const { customStyles, state } = useContext(TurnstoneContext) + const regex = new RegExp('(' + escapeStringRegExp(state.query) + ')', 'i') + const parts = (state.query) ? text.split(regex) : [text] const matchingText = parts.map((part, index) => { - const style = (part.toLowerCase() === queryState.toLowerCase()) ? 'match' : 'nonMatch' + const style = (part.toLowerCase() === state.query.toLowerCase()) ? 'match' : 'nonMatch' if(part.length) return ({parts[index]}) }) diff --git a/src/lib/context/turnstone.jsx b/src/lib/context/turnstone.jsx index fe776f7..4f6b1ea 100644 --- a/src/lib/context/turnstone.jsx +++ b/src/lib/context/turnstone.jsx @@ -1,24 +1,31 @@ -import React, { createContext, useState, useEffect } from 'react' +import React, { createContext, useReducer, useState, useEffect } from 'react' +import reducer from '../reducers/reducer' +import {setQuery} from '../actions/actions' +import undef from '../utils/undef' -const TurnstoneContext = createContext() +const TurnstoneContext = createContext() //TODO: Rename GlobalStateContext const TurnstoneContextProvider = (props) => { const { children, splitChar, styles = {}, text = '' } = props - const [queryState, setQueryState] = useState(text) - const [highlightedState, setHighlightedState] = useState() + const [state, dispatch] = useReducer(reducer, { + query: text, + items: [], + highlighted: undef, + selected: undef, + customStyles: styles, + splitChar + }) const [selectedState, setSelectedState] = useState() const [customStyles] = useState(styles) const [splitCharState] = useState(splitChar) - useEffect(() => setQueryState(text), [text]) + useEffect(() => dispatch(setQuery(text)), [text]) // TODO: Is this needed? return ( ) diff --git a/src/lib/reducers/reducer.js b/src/lib/reducers/reducer.js new file mode 100644 index 0000000..7e86927 --- /dev/null +++ b/src/lib/reducers/reducer.js @@ -0,0 +1,38 @@ +import * as types from '../actions/actionTypes' +import undef from '../utils/undef' + +const highlightedItem = (i, state) => { + return { index: i, text: state.items[i].text } +} + +const reducer = (state, action) => { + const newState = () => { + switch (action.type) { + case types.SET_QUERY: + return action.payload + case types.SET_ITEMS: + return action.payload + case types.SET_HIGHLIGHTED: + console.log('setting', action.index) + return { highlighted: highlightedItem(action.index, state) } + case types.CLEAR_HIGHLIGHTED: + return { highlighted: undef } + case types.PREV_HIGHLIGHTED: + return (state.highlighted.index > 0) + ? { highlighted: highlightedItem(state.highlighted.index - 1, state) } + : {} + case types.NEXT_HIGHLIGHTED: + return (state.highlighted.index < state.items.length - 1) + ? { highlighted: highlightedItem(state.highlighted.index + 1, state) } + : {} + case types.SET_SELECTED: + return action.payload + default: + throw new Error('Invalid action type passed to reducer') + } + } + + return {...state, ...newState()} +} + +export default reducer \ No newline at end of file diff --git a/src/lib/utils/itemText.js b/src/lib/utils/itemText.js new file mode 100644 index 0000000..6945586 --- /dev/null +++ b/src/lib/utils/itemText.js @@ -0,0 +1,13 @@ +import firstOfType from 'first-of-type' +import isUndefined from './isUndefined' + +const itemText = (item, displayField) => { + const itemType = typeof item + const text = + itemType === 'string' && isUndefined(displayField) + ? item + : item[displayField] + return isUndefined(text) ? firstOfType(item, 'string') || '' : text +} + +export default itemText \ No newline at end of file