mirror of
https://github.com/fergalmoran/turnstone.git
synced 2025-12-22 09:49:56 +00:00
Add ability to pass in an Item component
Create SplitMatch component Create first (WIP) example
This commit is contained in:
@@ -3,6 +3,7 @@ import Turnstone from '../../src/lib'
|
|||||||
import styles from './styles/App.module.css'
|
import styles from './styles/App.module.css'
|
||||||
import autocompleteStyles from './styles/autocomplete.module.css'
|
import autocompleteStyles from './styles/autocomplete.module.css'
|
||||||
import defaultListbox from '../_shared/defaultListbox'
|
import defaultListbox from '../_shared/defaultListbox'
|
||||||
|
import Item from './components/item/item'
|
||||||
|
|
||||||
const maxItems = 10
|
const maxItems = 10
|
||||||
const placeholder = 'Enter a city or airport'
|
const placeholder = 'Enter a city or airport'
|
||||||
@@ -33,29 +34,17 @@ const listbox = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const [selected, setSelected] = useState()
|
const [selected, setSelected] = useState()
|
||||||
|
|
||||||
const onChange = useCallback(
|
|
||||||
(text) => {
|
|
||||||
//console.log('Changed to:', text)
|
|
||||||
}, []
|
|
||||||
)
|
|
||||||
|
|
||||||
const onSelect = sel => setSelected(sel)
|
const onSelect = sel => setSelected(sel)
|
||||||
|
|
||||||
// const onSelect = useCallback(
|
|
||||||
// (selectedResult) => {
|
|
||||||
// console.log('Selected Result:', selectedResult)
|
|
||||||
// }, []
|
|
||||||
// )
|
|
||||||
|
|
||||||
const onEnter = useCallback(
|
const onEnter = useCallback(
|
||||||
(query, selectedResult) => {
|
(query, selectedResult) => {
|
||||||
//console.log('Enter Pressed. Selected Result:', selectedResult, query)
|
console.log('Enter Pressed. Selected Result:', selectedResult, query)
|
||||||
}, []
|
}, []
|
||||||
)
|
)
|
||||||
|
|
||||||
const onTab = useCallback(
|
const onTab = useCallback(
|
||||||
(query, selectedResult) => {
|
(query, selectedResult) => {
|
||||||
//console.log('Tab Pressed. Selected Result:', selectedResult, query)
|
console.log('Tab Pressed. Selected Result:', selectedResult, query)
|
||||||
}, []
|
}, []
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,12 +60,12 @@ const App = () => {
|
|||||||
defaultListbox={defaultListbox}
|
defaultListbox={defaultListbox}
|
||||||
defaultListboxIsImmutable={false}
|
defaultListboxIsImmutable={false}
|
||||||
id='autocomplete'
|
id='autocomplete'
|
||||||
|
itemComponent={Item}
|
||||||
listbox={listbox}
|
listbox={listbox}
|
||||||
listboxIsImmutable={true}
|
listboxIsImmutable={true}
|
||||||
maxItems={maxItems}
|
maxItems={maxItems}
|
||||||
minQueryLength={1}
|
minQueryLength={1}
|
||||||
noItemsMessage={noItemsMessage}
|
noItemsMessage={noItemsMessage}
|
||||||
onChange={onChange}
|
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onEnter={onEnter}
|
onEnter={onEnter}
|
||||||
onTab={onTab}
|
onTab={onTab}
|
||||||
|
|||||||
74
examples/geo/components/item/item.jsx
Normal file
74
examples/geo/components/item/item.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import SplitMatch from '../splitMatch/splitMatch'
|
||||||
|
import imgNewYork from'../../images/newyork.jpg'
|
||||||
|
import styles from './item.module.css'
|
||||||
|
|
||||||
|
const SplitComponent = (props) => {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
index
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const className = `split${index}`
|
||||||
|
|
||||||
|
return <span className={styles[className]}>{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchComponent = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <span className={styles.match}>{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Item(props) {
|
||||||
|
const {
|
||||||
|
appearsInDefaultListbox,
|
||||||
|
index,
|
||||||
|
item,
|
||||||
|
query,
|
||||||
|
searchType = 'startswith'
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const globalMatch = searchType === 'contains'
|
||||||
|
|
||||||
|
const matchedText = (includeSeparator) => {
|
||||||
|
return (
|
||||||
|
<SplitMatch
|
||||||
|
searchText={query}
|
||||||
|
globalMatch={globalMatch}
|
||||||
|
globalSplit={false}
|
||||||
|
caseSensitiveMatch={false}
|
||||||
|
caseSensitiveSplit={false}
|
||||||
|
separator=", "
|
||||||
|
includeSeparator={includeSeparator}
|
||||||
|
MatchComponent={MatchComponent}
|
||||||
|
SplitComponent={SplitComponent}>{item.name}</SplitMatch>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = () => {
|
||||||
|
return item.name === 'New York City, New York, United States'
|
||||||
|
? <div><img src={imgNewYork} alt={item.name} /></div>
|
||||||
|
: void 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItem = () => {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${styles.first}`}>
|
||||||
|
{img()}
|
||||||
|
<div>{matchedText(false)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const standardItem = () => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{matchedText(true)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return index === 0 && !appearsInDefaultListbox ? firstItem() : standardItem()
|
||||||
|
|
||||||
|
}
|
||||||
27
examples/geo/components/item/item.module.css
Normal file
27
examples/geo/components/item/item.module.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.first > div {
|
||||||
|
display: inline-block;
|
||||||
|
height: 50px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first img {
|
||||||
|
display: block;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first .split0 {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .split1 {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
99
examples/geo/components/splitMatch/splitMatch.jsx
Normal file
99
examples/geo/components/splitMatch/splitMatch.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import escapeStringRegExp from 'escape-string-regexp'
|
||||||
|
|
||||||
|
const getMatches = (str, searchString, global, caseSensitive) => {
|
||||||
|
const flags = (caseSensitive) ? 'g' : 'gi'
|
||||||
|
const regex = new RegExp(escapeStringRegExp(searchString), flags)
|
||||||
|
const matches = [...str.matchAll(regex)].map(a => [a.index, a.index + searchString.length])
|
||||||
|
|
||||||
|
return global
|
||||||
|
? matches
|
||||||
|
: (matches.length ? [matches[0]] : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDividers = (str, separator, global, caseSensitive) => {
|
||||||
|
const flags = (caseSensitive) ? 'g' : 'gi'
|
||||||
|
const regex = new RegExp(escapeStringRegExp(separator), flags)
|
||||||
|
const dividers = [...str.matchAll(regex)].map(a => a.index + separator.length)
|
||||||
|
|
||||||
|
return [...((global)
|
||||||
|
? dividers
|
||||||
|
: (dividers.length ? [dividers[0]] : [])
|
||||||
|
), str.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatchingChar = (index, matches) => {
|
||||||
|
return matches.reduce((prev, match) => {
|
||||||
|
return prev || index >= match[0] && index < match[1]
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SplitMatch(props) {
|
||||||
|
const {
|
||||||
|
caseSensitiveMatch = false,
|
||||||
|
caseSensitiveSplit = false,
|
||||||
|
globalMatch = true,
|
||||||
|
globalSplit = true,
|
||||||
|
includeSeparator = true,
|
||||||
|
searchText,
|
||||||
|
separator = ',',
|
||||||
|
children: text,
|
||||||
|
MatchComponent,
|
||||||
|
SplitComponent
|
||||||
|
} = props
|
||||||
|
|
||||||
|
if(!text) return null
|
||||||
|
|
||||||
|
const wrapMatch = (match, key) => {
|
||||||
|
if(MatchComponent) return <MatchComponent key={key}>{match}</MatchComponent>
|
||||||
|
return <strong key={key}>{match}</strong>
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapSplit = (children, key, index) => {
|
||||||
|
if(SplitComponent) return <SplitComponent key={key} index={index}>{children}</SplitComponent>
|
||||||
|
return <span key={key}>{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
let separatorRemoved = false
|
||||||
|
const dividers = getDividers(text, separator, globalSplit, caseSensitiveSplit)
|
||||||
|
const matches = getMatches(text, searchText, globalMatch, caseSensitiveMatch)
|
||||||
|
const parts = dividers.map((dividerIndex, i) => {
|
||||||
|
let tag = ''
|
||||||
|
let tagIsMatch = false
|
||||||
|
const parts = []
|
||||||
|
const prevIndex = dividers[i - 1] || 0
|
||||||
|
const chars = Array.from(text.substring(prevIndex, dividerIndex))
|
||||||
|
const addTag = (isMatch, finalTagInDivider) => {
|
||||||
|
const key = `part-${i}-${parts.length}`
|
||||||
|
if(tag.length && tagIsMatch !== isMatch) {
|
||||||
|
console.log({tag : `"${tag}"`})
|
||||||
|
|
||||||
|
if(!includeSeparator && finalTagInDivider && (globalSplit || !separatorRemoved)) {
|
||||||
|
tag = tag.replace(separator, '')
|
||||||
|
separatorRemoved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
tagIsMatch
|
||||||
|
? wrapMatch(tag, key)
|
||||||
|
: <React.Fragment key={key}>{tag}</React.Fragment>
|
||||||
|
)
|
||||||
|
tag = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chars.forEach((char, index) => {
|
||||||
|
const isMatch = isMatchingChar(prevIndex + index, matches)
|
||||||
|
addTag(isMatch)
|
||||||
|
tagIsMatch = isMatch
|
||||||
|
tag = `${tag}${char}`
|
||||||
|
})
|
||||||
|
addTag(!tagIsMatch, true)
|
||||||
|
|
||||||
|
return wrapSplit(parts, `part-${i}`, i)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log({parts})
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
BIN
examples/geo/images/newyork.jpg
Normal file
BIN
examples/geo/images/newyork.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -5,7 +5,7 @@
|
|||||||
.query,
|
.query,
|
||||||
.typeahead {
|
.typeahead {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 700px;
|
width: 500px;
|
||||||
height: 2.5em;
|
height: 2.5em;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
font-size: 1.4em;
|
font-size: 1.4em;
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
.listbox {
|
.listbox {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 700px;
|
width: 500px;
|
||||||
border-left: 1px solid #ccc;
|
border-left: 1px solid #ccc;
|
||||||
border-right: 1px solid #ccc;
|
border-right: 1px solid #ccc;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
@@ -69,10 +69,12 @@
|
|||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Get rid */
|
||||||
.topItem {
|
.topItem {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Get rid */
|
||||||
.split:not(:first-child) {
|
.split:not(:first-child) {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ export const fetcher = (query, listbox, defaultListbox, minQueryLength, maxItems
|
|||||||
return []
|
return []
|
||||||
else if (!defaultListbox && query.length < minQueryLength) return []
|
else if (!defaultListbox && query.length < minQueryLength) return []
|
||||||
|
|
||||||
const listboxProp =
|
const isDefaultListbox = (defaultListbox && !query.length)
|
||||||
defaultListbox && !query.length ? defaultListbox : listbox
|
|
||||||
|
const listboxProp = isDefaultListbox ? defaultListbox : listbox
|
||||||
|
|
||||||
const promises = listboxProp.map((group) => {
|
const promises = listboxProp.map((group) => {
|
||||||
if (typeof group.data === 'function') {
|
if (typeof group.data === 'function') {
|
||||||
@@ -100,7 +101,8 @@ export const fetcher = (query, listbox, defaultListbox, minQueryLength, maxItems
|
|||||||
text: itemText(item, listboxProp[groupIndex].displayField),
|
text: itemText(item, listboxProp[groupIndex].displayField),
|
||||||
groupIndex,
|
groupIndex,
|
||||||
groupName: listboxProp[groupIndex].name,
|
groupName: listboxProp[groupIndex].name,
|
||||||
dataSearchType: listboxProp[groupIndex].dataSearchType
|
dataSearchType: listboxProp[groupIndex].dataSearchType,
|
||||||
|
defaultListbox: isDefaultListbox
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function Item(props) {
|
|||||||
} = useContext(StateContext)
|
} = useContext(StateContext)
|
||||||
|
|
||||||
const { customStyles, highlighted, separator, query } = state
|
const { customStyles, highlighted, separator, query } = state
|
||||||
|
const CustomItem = state.props.itemComponent
|
||||||
|
|
||||||
const startsWith = item.dataSearchType !== 'contains'
|
const startsWith = item.dataSearchType !== 'contains'
|
||||||
|
|
||||||
@@ -48,11 +49,23 @@ export default function Item(props) {
|
|||||||
dispatch(setSelected(index))
|
dispatch(setSelected(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
const contents = splitText.map((part, index) => {
|
const matchedText = splitText.map((part, index) => {
|
||||||
const match = startsWith ? (splitQuery[index] || '') : query
|
const match = startsWith ? (splitQuery[index] || '') : query
|
||||||
return <MatchingText key={`split${index}`} text={part} match={match} startsWith={startsWith} />
|
return <MatchingText key={`split${index}`} text={part} match={match} startsWith={startsWith} />
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const itemContents = (CustomItem)
|
||||||
|
? <CustomItem
|
||||||
|
appearsInDefaultListbox={item.defaultListbox}
|
||||||
|
groupName={item.groupName}
|
||||||
|
index={index}
|
||||||
|
item={item.value}
|
||||||
|
query={query}
|
||||||
|
searchType={item.dataSearchType}
|
||||||
|
totalItems={state.items.length}
|
||||||
|
/>
|
||||||
|
: matchedText
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={divClassName()}
|
className={divClassName()}
|
||||||
@@ -62,7 +75,7 @@ export default function Item(props) {
|
|||||||
role='option'
|
role='option'
|
||||||
aria-selected={isHighlighted}
|
aria-selected={isHighlighted}
|
||||||
aria-label={item.text}>
|
aria-label={item.text}>
|
||||||
{contents}
|
{itemContents}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import reducer from '../reducers/reducer'
|
|||||||
import {setQuery} from '../actions/actions'
|
import {setQuery} from '../actions/actions'
|
||||||
import undef from '../utils/undef'
|
import undef from '../utils/undef'
|
||||||
|
|
||||||
const StateContext = createContext() //TODO: Rename GlobalStateContext
|
const StateContext = createContext()
|
||||||
|
|
||||||
const StateContextProvider = (props) => {
|
const StateContextProvider = (props) => {
|
||||||
const { separator, styles = {}, text = '', items = [] } = props
|
const { separator, styles = {}, text = '', items = [] } = props
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export default function Turnstone(props) {
|
|||||||
const newProps = {...propDefaults, ...props}
|
const newProps = {...propDefaults, ...props}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
<StateContextProvider {...newProps}>
|
<StateContextProvider {...newProps}>
|
||||||
<Container {...newProps} />
|
<Container {...newProps} />
|
||||||
</StateContextProvider>
|
</StateContextProvider>
|
||||||
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ Turnstone.propTypes = {
|
|||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
displayField: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
displayField: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
|
itemComponent: PropTypes.elementType,
|
||||||
listbox: listboxRules.isRequired,
|
listbox: listboxRules.isRequired,
|
||||||
listboxIsImmutable: PropTypes.bool,
|
listboxIsImmutable: PropTypes.bool,
|
||||||
minQueryLength: (props) => {
|
minQueryLength: (props) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user