diff --git a/examples/_shared/defaultItemGroups.js b/examples/_shared/defaultListbox.js similarity index 93% rename from examples/_shared/defaultItemGroups.js rename to examples/_shared/defaultListbox.js index 990a747..017968c 100644 --- a/examples/_shared/defaultItemGroups.js +++ b/examples/_shared/defaultListbox.js @@ -1,4 +1,4 @@ -const defaultItemGroups = [ +const defaultListbox = [ { name: 'Recent Searches', displayField: 'name', data: [ { name: 'New Orleans, Louisiana, United States', coords: '29.95465,-90.07507' }, { name: 'Chicago, Illinois, United States', coords: '41.85003,-87.65005' }, @@ -17,4 +17,4 @@ const defaultItemGroups = [ } ] -export default defaultItemGroups \ No newline at end of file +export default defaultListbox \ No newline at end of file diff --git a/examples/geo/App.jsx b/examples/geo/App.jsx index 5ffac55..a145fbc 100644 --- a/examples/geo/App.jsx +++ b/examples/geo/App.jsx @@ -2,14 +2,14 @@ import React, { useCallback, useState } from 'react' import Turnstone from '../../src/lib' import styles from './styles/App.module.css' import autocompleteStyles from './styles/autocomplete.module.css' -import defaultItemGroups from '../_shared/defaultItemGroups' +import defaultListbox from '../_shared/defaultListbox' const maxItems = 10 const placeholder = 'Enter a city or airport' const splitChar = ',' const noItemsMessage = 'We found no places that match your search' -const itemGroups = [ +const listbox = [ { name: 'Cities', ratio: 8, @@ -66,11 +66,11 @@ const App = () => { autoFocus={false} clearButton={true} debounceWait={250} - defaultItemGroups={defaultItemGroups} - defaultItemGroupsAreImmutable={false} + defaultListbox={defaultListbox} + defaultListboxIsImmutable={false} id='autocomplete' - itemGroups={itemGroups} - itemGroupsAreImmutable={true} + listbox={listbox} + listboxIsImmutable={true} maxItems={maxItems} minQueryLength={1} noItemsMessage={noItemsMessage} diff --git a/src/App.jsx b/src/App.jsx index aa87652..d60b7df 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,11 @@ const styles = { highlightedItem: 'highlightedItem' } +const listbox = { + data: fruits, + dataSearchType: 'startswith' +} + const App = () => { return ( <> @@ -13,10 +18,9 @@ const App = () => { { const placeholder = 'test' render() @@ -31,8 +33,10 @@ describe('Integration tests', () => { render() @@ -46,8 +50,10 @@ describe('Integration tests', () => { const text = 'pe' render() @@ -58,9 +64,11 @@ describe('Integration tests', () => { const placeholder = 'test' render() @@ -74,8 +82,10 @@ describe('Integration tests', () => { const placeholder = 'test' render() diff --git a/src/lib/components/container.jsx b/src/lib/components/container.jsx index 84c224f..a96e6db 100644 --- a/src/lib/components/container.jsx +++ b/src/lib/components/container.jsx @@ -29,14 +29,11 @@ export default function Container(props) { clearButtonAriaLabel, clearButtonText, debounceWait, - defaultItemGroups, - defaultItemGroupsAreImmutable, + defaultListbox, + defaultListboxIsImmutable, disabled, - displayField, - data, - dataSearchType, id, - itemGroupsAreImmutable, + listboxIsImmutable, maxItems, minQueryLength, name, @@ -49,18 +46,10 @@ export default function Container(props) { tabIndex } = props - // Destructure itemGroups prop - const { - itemGroups = [ - { - name: '', - data, - dataSearchType, - displayField, - ratio: maxItems - } - ] - } = props + // Destructure listbox prop + const listbox = Array.isArray(props.listbox) + ? props.listbox + : [{ ...props.listbox, ...{ name: '', ratio: maxItems } }] const listboxId = `${id}-listbox` @@ -78,10 +67,10 @@ export default function Container(props) { // Checks whether or not SWR data is to be treated as immutable const isImmutable = (() => { - return itemGroupsAreImmutable && + return listboxIsImmutable && !( - defaultItemGroups && - !defaultItemGroupsAreImmutable && + defaultListbox && + !defaultListboxIsImmutable && debouncedQuery.length === 0 ) })() @@ -90,8 +79,8 @@ export default function Container(props) { const swrData = useData( debouncedQuery.toLowerCase(), isImmutable, - itemGroups, - defaultItemGroups, + listbox, + defaultListbox, minQueryLength, maxItems, dispatch diff --git a/src/lib/components/container.test.jsx b/src/lib/components/container.test.jsx index f54bdcd..ab18e7b 100644 --- a/src/lib/components/container.test.jsx +++ b/src/lib/components/container.test.jsx @@ -6,7 +6,7 @@ import { StateContextProvider } from '../context/state' import Container from './container' import { fruits } from '../../data' -vi.mock('./listbox', () => ({ default: () => 'Items' })) +vi.mock('./listbox', () => ({ default: () => 'Listbox' })) vi.mock('./hooks/containerEffects', () => ({ useItemsState: vi.fn(), useAutoFocus: vi.fn(), diff --git a/src/lib/components/hooks/useData.js b/src/lib/components/hooks/useData.js index d5de0b2..5ba204f 100644 --- a/src/lib/components/hooks/useData.js +++ b/src/lib/components/hooks/useData.js @@ -75,13 +75,13 @@ const swrOptions = (isImmutable) => { : swrBaseOptions } -export const fetcher = (query, itemGroups, defaultItemGroups, minQueryLength, maxItems) => { - if (defaultItemGroups && query.length > 0 && query.length < minQueryLength) +export const fetcher = (query, listbox, defaultListbox, minQueryLength, maxItems) => { + if (defaultListbox && query.length > 0 && query.length < minQueryLength) return [] - else if (!defaultItemGroups && query.length < minQueryLength) return [] + else if (!defaultListbox && query.length < minQueryLength) return [] const groupsProp = - defaultItemGroups && !query.length ? defaultItemGroups : itemGroups + defaultListbox && !query.length ? defaultListbox : listbox const promises = groupsProp.map((group) => { if (typeof group.data === 'function') { @@ -110,7 +110,7 @@ export const fetcher = (query, itemGroups, defaultItemGroups, minQueryLength, ma }) } -const useData = (query, isImmutable, itemGroups, defaultItemGroups, minQueryLength, maxItems) => { +const useData = (query, isImmutable, listbox, defaultListbox, minQueryLength, maxItems) => { // See: https://github.com/vercel/swr/discussions/1810 const dummyArgToEnsureCachingOfZeroLengthStrings = 'X' @@ -120,7 +120,7 @@ const useData = (query, isImmutable, itemGroups, defaultItemGroups, minQueryLeng query.toLowerCase(), dummyArgToEnsureCachingOfZeroLengthStrings ], - (query) => fetcher(query, itemGroups, defaultItemGroups, minQueryLength, maxItems), + (query) => fetcher(query, listbox, defaultListbox, minQueryLength, maxItems), swrOptions(isImmutable) ) diff --git a/src/lib/components/hooks/useData.test.js b/src/lib/components/hooks/useData.test.js index 5412331..00283c5 100644 --- a/src/lib/components/hooks/useData.test.js +++ b/src/lib/components/hooks/useData.test.js @@ -27,7 +27,7 @@ const server = setupServer( }) ) -const apiItemGroups = [ +const apiListbox = [ { name: 'Books', ratio: 4, @@ -55,7 +55,7 @@ describe('Fetching API data', () => { }) test('Returns expected results', () => { - return fetcher('L', apiItemGroups, undef, 1, 6).then(items => { + return fetcher('L', apiListbox, undef, 1, 6).then(items => { expect(items).toEqual([ { value: { title: 'Last Argument of Kings', author: 'Joe Abercrombie' }, @@ -97,13 +97,13 @@ describe('Fetching API data', () => { // // Test Static data // // /////////////////////////////////////////// -const singleItemGroup = [{ +const singleGroupListbox = [{ name: '', data: fruits, dataSearchType: 'startswith' }] -const multiItemGroup = [ +const multiGroupListbox = [ { name: 'Fruits', data: fruits, @@ -118,14 +118,14 @@ const multiItemGroup = [ describe('Fetching static data', () => { afterEach(() => { - delete multiItemGroup[0].ratio - delete multiItemGroup[1].ratio - multiItemGroup[0].dataSearchType = 'startswith' - multiItemGroup[1].dataSearchType = 'startswith' + delete multiGroupListbox[0].ratio + delete multiGroupListbox[1].ratio + multiGroupListbox[0].dataSearchType = 'startswith' + multiGroupListbox[1].dataSearchType = 'startswith' }) test('Returns expected results for a single group', () => { - return fetcher('Pe', singleItemGroup, undef, 1, 10).then(items => { + return fetcher('Pe', singleGroupListbox, undef, 1, 10).then(items => { expect(items).toEqual([ { value: 'Peach', text: 'Peach', groupIndex: 0, groupName: '' }, { value: 'Pear', text: 'Pear', groupIndex: 0, groupName: '' } @@ -134,7 +134,7 @@ describe('Fetching static data', () => { }) test('Returns expected results for multiple groups with equal ratios', () => { - return fetcher('P', multiItemGroup, undef, undef, 10).then(items => { + return fetcher('P', multiGroupListbox, undef, undef, 10).then(items => { expect(items).toEqual([ { value: 'Peach', text: 'Peach', groupIndex: 0, groupName: 'Fruits' }, { value: 'Pear', text: 'Pear', groupIndex: 0, groupName: 'Fruits' }, @@ -151,7 +151,7 @@ describe('Fetching static data', () => { }) test('Returns expected results for multiple groups limited to 6 results', () => { - return fetcher('P', multiItemGroup, undef, 1, 6).then(items => { + return fetcher('P', multiGroupListbox, undef, 1, 6).then(items => { expect(items).toEqual([ { value: 'Peach', text: 'Peach', groupIndex: 0, groupName: 'Fruits' }, { value: 'Pear', text: 'Pear', groupIndex: 0, groupName: 'Fruits' }, @@ -164,10 +164,10 @@ describe('Fetching static data', () => { }) test('Returns expected ratios for multiple groups', () => { - multiItemGroup[0].ratio = 4 - multiItemGroup[0].ratio = 2 + multiGroupListbox[0].ratio = 4 + multiGroupListbox[0].ratio = 2 - return fetcher('P', multiItemGroup, undef, 1, 6).then(items => { + return fetcher('P', multiGroupListbox, undef, 1, 6).then(items => { expect(items).toEqual([ { value: 'Peach', text: 'Peach', groupIndex: 0, groupName: 'Fruits' }, { value: 'Pear', text: 'Pear', groupIndex: 0, groupName: 'Fruits' }, @@ -180,10 +180,10 @@ describe('Fetching static data', () => { }) test('Returns results containing query', () => { - multiItemGroup[0].dataSearchType = 'contains' - multiItemGroup[1].dataSearchType = 'contains' + multiGroupListbox[0].dataSearchType = 'contains' + multiGroupListbox[1].dataSearchType = 'contains' - return fetcher('Pe', multiItemGroup, undef, 1, 6).then(items => { + return fetcher('Pe', multiGroupListbox, undef, 1, 6).then(items => { expect(items).toEqual([ { value: 'Bartlett pear', text: 'Bartlett pear', groupIndex: 0, groupName: 'Fruits' }, { value: 'Cantaloupe', text: 'Cantaloupe', groupIndex: 0, groupName: 'Fruits' }, diff --git a/src/lib/components/listBox.test.jsx b/src/lib/components/listBox.test.jsx index 66a4dde..7ceb3e8 100644 --- a/src/lib/components/listBox.test.jsx +++ b/src/lib/components/listBox.test.jsx @@ -3,7 +3,7 @@ import renderer from 'react-test-renderer' import { vi, describe, expect, test } from 'vitest' import { StateContextProvider } from '../context/state' -import Listbox from './listBox' +import Listbox from './listbox' vi.mock('./item', () => ({ default: () => 'Item' })) vi.mock('./item', () => ({ default: () => 'ItemFirst' })) diff --git a/src/lib/index.jsx b/src/lib/index.jsx index e807190..d1f3550 100644 --- a/src/lib/index.jsx +++ b/src/lib/index.jsx @@ -3,7 +3,6 @@ import React from 'react' import PropTypes from 'prop-types' import { StateContextProvider } from './context/state' -import isUndefined from './utils/isUndefined' import Container from './components/container' const randomId = () => `turnstone-${(0|Math.random()*6.04e7).toString(36)}` @@ -15,10 +14,10 @@ const propDefaults = { clearButtonAriaLabel: 'Clear contents', clearButtonText: '\u00d7', debounceWait: 250, - defaultItemGroupsAreImmutable: true, + defaultListboxIsImmutable: true, disabled: false, id: randomId(), - itemGroupsAreImmutable: true, + listboxIsImmutable: true, maxItems: 10, minQueryLength: 1, placeholder: '' @@ -38,51 +37,42 @@ export default function Turnstone(props) { // Prop validation // ////////////////////////////////////////////////////// -const msgBothRequired = `Either a "data" prop or an "itemGroups" prop must be provided. Both are missing.` -const msgOneOnly = `Both a "data" prop and an "itemGroups" prop were provided. Provide one only.` -const requiredPropsAreMissing = (props) => isUndefined(props.data) && isUndefined(props.itemGroups) +const dataSearchTypes = ['startswith', 'contains'] + +const listboxRules = PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.exact({ + data: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.array + ]).isRequired, + dataSearchType: PropTypes.oneOf(dataSearchTypes), + displayField: PropTypes.string, + name: PropTypes.string.isRequired, + ratio: PropTypes.number + })), + PropTypes.exact({ + data: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.array + ]).isRequired, + dataSearchType: PropTypes.oneOf(dataSearchTypes), + displayField: PropTypes.string + }) +]) + Turnstone.propTypes = { autoFocus: PropTypes.bool, clearButton: PropTypes.bool, clearButtonAriaLabel: PropTypes.string, clearButtonText: PropTypes.string, - data: (props) => { - if(requiredPropsAreMissing(props)) return new Error(msgBothRequired) - if(!isUndefined(props.data) && !isUndefined(props.itemGroups)) return new Error(msgOneOnly) - PropTypes.checkPropTypes( - {data: PropTypes.oneOfType([PropTypes.array, PropTypes.func])}, - {data: props.data}, - 'prop', 'Turnstone' - ) - }, - dataSearchType: PropTypes.oneOf(['startswith', 'contains']), debounceWait: PropTypes.number, - defaultItemGroups: PropTypes.array, - defaultItemGroupsAreImmutable: PropTypes.bool, + defaultListbox: listboxRules, + defaultListboxIsImmutable: PropTypes.bool, disabled: PropTypes.bool, displayField: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), id: PropTypes.string, - itemGroups: (props) => { - if(requiredPropsAreMissing(props)) return new Error(msgBothRequired) - if(!isUndefined(props.data) && !isUndefined(props.itemGroups)) return new Error(msgOneOnly) - PropTypes.checkPropTypes( - { - itemGroups: PropTypes.arrayOf(PropTypes.exact({ - name: PropTypes.string.isRequired, - data: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.array - ]).isRequired, - dataSearchType: PropTypes.oneOf(['startswith', 'contains']), - displayField: PropTypes.string, - ratio: PropTypes.number - })) - }, - {itemGroups: props.itemGroups}, - 'prop', 'Turnstone' - ) - }, - itemGroupsAreImmutable: PropTypes.bool, + listbox: listboxRules.isRequired, + listboxIsImmutable: PropTypes.bool, minQueryLength: (props) => { PropTypes.checkPropTypes( {minQueryLength: PropTypes.number}, diff --git a/src/lib/index.test.jsx b/src/lib/index.test.jsx index 2a73d69..c14b9e1 100644 --- a/src/lib/index.test.jsx +++ b/src/lib/index.test.jsx @@ -35,9 +35,9 @@ describe('Turnstone', () => { clearButtonAriaLabel: 'Clear contents', clearButtonText: '×', debounceWait: 250, - defaultItemGroupsAreImmutable: true, + defaultListboxIsImmutable: true, disabled: false, - itemGroupsAreImmutable: true, + listboxIsImmutable: true, maxItems: 10, minQueryLength: 1, placeholder: '', diff --git a/src/lib/reducers/reducer.js b/src/lib/reducers/reducer.js index 204f73b..62af317 100644 --- a/src/lib/reducers/reducer.js +++ b/src/lib/reducers/reducer.js @@ -21,7 +21,7 @@ const reducer = (state, action) => { newState.itemsLoaded = false // Allow listbox if there is no query and we have default items to show - if(action.query.length === 0 && state.props.defaultItemGroups) + if(action.query.length === 0 && state.props.defaultListbox) newState.itemsLoaded = true return newState diff --git a/src/lib/reducers/reducer.test.js b/src/lib/reducers/reducer.test.js index c4e8a90..aa8c95c 100644 --- a/src/lib/reducers/reducer.test.js +++ b/src/lib/reducers/reducer.test.js @@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest' import reducer from './reducer' import * as actions from '../actions/actions' import undef from '../utils/undef' -import defaultItemGroups from '../../../examples/_shared/defaultItemGroups' +import defaultListbox from '../../../examples/_shared/defaultListbox' describe('SET_QUERY action', () => { test('produces expected new state', () => { @@ -69,7 +69,7 @@ describe('SET_QUERY action', () => { const state = { props: { minQueryLength: 1, - defaultItemGroups + defaultListbox } } @@ -81,7 +81,7 @@ describe('SET_QUERY action', () => { itemsLoaded: true, props: { minQueryLength: 1, - defaultItemGroups + defaultListbox } }) })