-
-
Save jsteenkamp/2d539a756266de60dfb72c942482bd8c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Suspense, useState } from "react"; | |
import { unstable_createResource as createResource } from "react-cache"; | |
import { | |
Combobox, | |
ComboboxInput, | |
ComboboxList, | |
ComboboxOption | |
} from "./Combobox2.js"; | |
function App({ tabIndex, navigate }) { | |
let [searchTerm, setSearchTerm] = useState(null); | |
let [selection, setSelection] = useState(null); | |
return ( | |
<div style={{ maxWidth: 600, margin: "auto" }}> | |
<h1 style={{ textAlign: "center" }}>Combobox</h1> | |
<h2>Last Selection: {selection}</h2> | |
<form | |
onSubmit={event => { | |
event.preventDefault(); | |
setSearchTerm(null); | |
}} | |
> | |
<Combobox onSelect={setSelection}> | |
<ComboboxInput | |
selectOnClick | |
onChange={async event => { | |
let value = event.target.value; | |
await Promise.resolve(); | |
setSearchTerm(value); | |
}} | |
/> | |
<Suspense maxDuration={2000} fallback={<div>Loading...</div>}> | |
<AsyncList searchTerm={searchTerm} /> | |
</Suspense> | |
</Combobox> | |
</form> | |
<div style={{ height: 400 }} /> | |
</div> | |
); | |
} | |
function AsyncList({ searchTerm }) { | |
let options = SearchResource.read(searchTerm); | |
return options ? ( | |
<ComboboxList> | |
{options.map(option => ( | |
<ComboboxOption key={option} value={option}> | |
{option} | |
</ComboboxOption> | |
))} | |
</ComboboxList> | |
) : null; | |
} | |
let rando = () => | |
Math.random() | |
.toString(16) | |
.substr(2, 4); | |
let SearchResource = createResource(value => { | |
return new Promise(resolve => { | |
if (!value) { | |
resolve(null); | |
} | |
setTimeout(() => { | |
resolve([ | |
`${value}${rando()}`, | |
`${value}${rando()}`, | |
`${rando()} ${value} ${rando()}`, | |
`${value}${rando()}`, | |
`${rando()} ${value} ${rando()}`, | |
`${value}${rando()}`, | |
`${value}${rando()}`, | |
`${value}${rando()}`, | |
`${value}${rando()}`, | |
`${value}${rando()}` | |
]); | |
}, Math.random() * 500); | |
}); | |
}); | |
export default () => ( | |
<Suspense maxDuration={5000} fallback={<div>Loading...</div>}> | |
<App /> | |
</Suspense> | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable jsx-a11y/role-supports-aria-props */ | |
// TODO: remove flash after deleting | |
// | |
// TODO: default render of <Option/> is <OptionText/> | |
// should assume value from string children? | |
// require value if not string children? | |
// TODO: aria attributes | |
import React, { | |
Fragment, | |
useState, | |
useEffect, | |
useRef, | |
useContext, | |
useMemo, | |
useReducer | |
} from "react"; | |
import Portal from "@reach/portal"; | |
import useRect from "./useRect"; | |
import { findAll } from "highlight-words-core"; | |
let chart = { | |
initial: "idle", | |
idle: { | |
BLUR: "idle", | |
CHANGE: "suggesting", | |
NAVIGATE: "navigating" | |
}, | |
suggesting: { | |
CHANGE: "suggesting", | |
NAVIGATE: "navigating", | |
MOUSE_DOWN: "selectingWithClick", | |
BLUR: "idle", | |
ESCAPE: "idle", | |
EMPTY: "idle" | |
}, | |
navigating: { | |
CHANGE: "suggesting", | |
ARROW_UP_DOWN: "navigating", | |
MOUSE_DOWN: "selectingWithClick", | |
BLUR: "idle", | |
ESCAPE: "idle", | |
NAVIGATE: "navigating", | |
SELECT_WITH_KEYBOARD: "idle" | |
}, | |
selectingWithClick: { | |
SELECT_WITH_CLICK: "idle" | |
} | |
}; | |
function reducer(data, action) { | |
switch (action.type) { | |
case "EMPTY": | |
case "CHANGE": | |
return { | |
...data, | |
value: action.value | |
}; | |
case "NAVIGATE": | |
return { | |
...data, | |
navigationValue: action.value | |
}; | |
case "ESCAPE": | |
case "BLUR": | |
return { | |
...data, | |
navigationValue: null | |
}; | |
case "SELECT_WITH_CLICK": | |
return { | |
...data, | |
value: action.value, | |
navigationValue: null | |
}; | |
case "SELECT_WITH_KEYBOARD": | |
return { | |
...data, | |
value: data.navigationValue, | |
navigationValue: null | |
}; | |
default: | |
return data; | |
} | |
} | |
let visibleStates = ["suggesting", "navigating", "selectingWithClick"]; | |
let selectingWithClickNode = null; | |
let Context = React.createContext(); | |
export function Combobox({ children, onSelect = k }) { | |
let optionsRef = useRef([]); | |
let inputRef = useRef(null); | |
let defaultData = { | |
value: "", | |
navigationValue: null | |
}; | |
let [state, data, transition] = useSimpleMachineLogger( | |
chart, | |
reducer, | |
defaultData | |
); | |
let rect = useRect(inputRef, visibleStates.includes(state)); | |
function registerOption(value) { | |
let { current: options } = optionsRef; | |
// TODO: validate unique values | |
options.push(value); | |
return () => options.splice(options.indexOf(value), 1); | |
} | |
let context = useMemo( | |
() => ({ | |
state, | |
transition, | |
data, | |
registerOption, | |
options: optionsRef.current, | |
inputRef, | |
onSelect, | |
rect | |
}), | |
// onSelect will need to be memoized, or useCallback? | |
[state, data, rect, onSelect] | |
); | |
return <Context.Provider value={context}>{children}</Context.Provider>; | |
} | |
export function ComboboxInput({ | |
selectOnClick = false, | |
onClick, | |
onChange, | |
onKeyDown, | |
onBlur, | |
onFocus, | |
...props | |
}) { | |
let { | |
transition, | |
options, | |
state, | |
onSelect, | |
inputRef, | |
data: { navigationValue, value } | |
} = useContext(Context); | |
let selectOnClickRef = useRef(false); | |
function handleBlur(event) { | |
if (state !== "selectingWithClick") { | |
transition("BLUR"); | |
} | |
} | |
function handleChange(event) { | |
let value = event.target.value; | |
// if (value.trim() === "") { | |
// transition("EMPTY", { value }); | |
// } else { | |
transition("CHANGE", { value }); | |
// } | |
} | |
function handleKeyDown(event) { | |
switch (event.key) { | |
case "ArrowDown": { | |
event.preventDefault(); | |
let index = options.indexOf(navigationValue); | |
let nextValue = options[(index + 1) % options.length]; | |
transition("NAVIGATE", { value: nextValue }); | |
break; | |
} | |
case "ArrowUp": { | |
event.preventDefault(); | |
let index = options.indexOf(navigationValue); | |
if (index === 0) { | |
// go back to their original value | |
transition("NAVIGATE", { value: null }); | |
} else if (index === -1) { | |
// if navigating a closed list we don't have any options yet, | |
// this is desired behavior so the list opens but nothing is selected | |
let value = options.length ? options[options.length - 1] : undefined; | |
transition("NAVIGATE", { value }); | |
} else { | |
let nextValue = | |
options[(index - 1 + options.length) % options.length]; | |
transition("NAVIGATE", { value: nextValue }); | |
} | |
break; | |
} | |
case "Escape": { | |
if (state !== "idle") { | |
transition("ESCAPE"); | |
} | |
break; | |
} | |
case "Enter": { | |
if (state === "navigating" && navigationValue !== null) { | |
// don't want to submit forms | |
event.preventDefault(); | |
transition("SELECT_WITH_KEYBOARD"); | |
onSelect(navigationValue); | |
onChange && onChange(event); | |
} | |
break; | |
} | |
default: { | |
} | |
} | |
} | |
function handleFocus() { | |
if (selectOnClick) { | |
selectOnClickRef.current = true; | |
} | |
} | |
function handleClick() { | |
if (selectOnClickRef.current) { | |
selectOnClickRef.current = false; | |
inputRef.current.select(); | |
} | |
} | |
let inputValue = | |
state === "navigating" | |
? // we don't always have a `navigationValue` while navigating | |
// (like first arrow down on idle) | |
navigationValue || value | |
: value; | |
return ( | |
<input | |
ref={inputRef} | |
{...props} | |
value={inputValue} | |
onClick={wrapEvent(onClick, handleClick)} | |
onBlur={wrapEvent(onBlur, handleBlur)} | |
onFocus={wrapEvent(onFocus, handleFocus)} | |
onChange={wrapEvent(onChange, handleChange)} | |
onKeyDown={wrapEvent(onKeyDown, handleKeyDown)} | |
/> | |
); | |
} | |
export function ComboboxList({ children, style, ...props }) { | |
let { state, transition, value, rect } = useContext(Context); | |
useEffect(() => { | |
let handler = event => { | |
if (selectingWithClickNode && selectingWithClickNode !== event.target) { | |
selectingWithClickNode = null; | |
transition("BLUR"); | |
} | |
}; | |
document.addEventListener("mouseup", handler); | |
return () => document.removeEventListener("mouseup", handler); | |
}, []); | |
let el = | |
visibleStates.includes(state) && rect ? ( | |
<ul | |
style={{ | |
...style, | |
position: "fixed", | |
top: rect.bottom, | |
left: rect.left, | |
width: rect.width | |
}} | |
{...props} | |
data-reach-combobox-list | |
children={children} | |
/> | |
) : null; | |
return <Portal>{rect ? el : null}</Portal>; | |
} | |
export function ComboboxOption({ children, value, ...props }) { | |
let { | |
transition, | |
registerOption, | |
onSelect, | |
data: { navigationValue, value: contextValue } | |
} = useContext(Context); | |
let isActive = navigationValue === value; | |
useEffect(() => registerOption(value), []); | |
function handleMouseDown(event) { | |
selectingWithClickNode = event.target; | |
transition("MOUSE_DOWN"); | |
} | |
function handleClick() { | |
transition("SELECT_WITH_CLICK", { value }); | |
onSelect(value); | |
} | |
if (typeof children === "string") { | |
let searchWords = contextValue.split(/\s+/); | |
let textToHighlight = value; | |
let results = findAll({ searchWords, textToHighlight }); | |
if (results.length) { | |
children = ( | |
<Fragment> | |
{results.map((result, index) => { | |
let str = value.slice(result.start, result.end); | |
return ( | |
<span | |
key={index} | |
data-user-value={result.highlight ? true : undefined} | |
data-suggested-value={result.highlight ? undefined : true} | |
> | |
{str} | |
</span> | |
); | |
})} | |
</Fragment> | |
); | |
} | |
} | |
return ( | |
<li | |
{...props} | |
tabIndex="-1" | |
data-reach-combobox-option | |
aria-selected={isActive} | |
onClick={handleClick} | |
onMouseDown={handleMouseDown} | |
children={children} | |
/> | |
); | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
function k() {} | |
function wrapEvent(userFn, fn) { | |
return event => { | |
if (userFn) userFn(event); | |
if (event.defaultPrevented) return; | |
fn(event); | |
}; | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
function useSimpleMachine(chart, reducer, initialData) { | |
let [state, setState] = useState(chart.initial); | |
let [data, dispatch] = useReducer(reducer, initialData); | |
let transition = (action, payload = {}) => { | |
let nextState = chart[state][action]; | |
dispatch({ type: action, state, nextState: state, ...payload }); | |
setState(nextState); | |
}; | |
return [state, data, transition]; | |
} | |
function useSimpleMachineLogger(chart, reducer, initialData) { | |
let [state, data, transition] = useSimpleMachine(chart, reducer, initialData); | |
function loggedTransition(action, payload = {}) { | |
let nextState = chart[state][action]; | |
console.group("useSimpleMachine"); | |
console.log({ action }, payload); | |
console.log({ state, nextState }); | |
console.log("data", data); | |
console.log( | |
"nextData", | |
reducer(data, { type: action, state, nextState: state, ...payload }) | |
); | |
console.groupEnd(); | |
transition(action, payload); | |
} | |
return [state, data, loggedTransition]; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable jsx-a11y/role-supports-aria-props */ | |
// TODO: remove flash after deleting | |
// | |
// TODO: default render of <Option/> is <OptionText/> | |
// should assume value from string children? | |
// require value if not string children? | |
// TODO: aria attributes | |
import React, { | |
Fragment, | |
useState, | |
useEffect, | |
useRef, | |
useContext, | |
useMemo, | |
useReducer | |
} from "react"; | |
import Portal from "@reach/portal"; | |
import useRect from "./useRect"; | |
import { findAll } from "highlight-words-core"; | |
let chart = { | |
initial: "idle", | |
idle: { | |
BLUR: "idle", | |
CHANGE: "suggesting", | |
NAVIGATE: "navigating" | |
}, | |
suggesting: { | |
CHANGE: "suggesting", | |
NAVIGATE: "navigating", | |
MOUSE_DOWN: "selectingWithClick", | |
BLUR: "idle", | |
ESCAPE: "idle", | |
EMPTY: "idle" | |
}, | |
navigating: { | |
CHANGE: "suggesting", | |
ARROW_UP_DOWN: "navigating", | |
MOUSE_DOWN: "selectingWithClick", | |
BLUR: "idle", | |
ESCAPE: "idle", | |
NAVIGATE: "navigating", | |
SELECT_WITH_KEYBOARD: "idle" | |
}, | |
selectingWithClick: { | |
SELECT_WITH_CLICK: "idle" | |
} | |
}; | |
function reducer(data, action) { | |
switch (action.type) { | |
case "EMPTY": | |
case "CHANGE": | |
return { | |
...data, | |
value: action.value | |
}; | |
case "NAVIGATE": | |
return { | |
...data, | |
navigationValue: action.value | |
}; | |
case "ESCAPE": | |
case "BLUR": | |
return { | |
...data, | |
navigationValue: null | |
}; | |
case "SELECT_WITH_CLICK": | |
return { | |
...data, | |
value: action.value, | |
navigationValue: null | |
}; | |
case "SELECT_WITH_KEYBOARD": | |
return { | |
...data, | |
value: data.navigationValue, | |
navigationValue: null | |
}; | |
default: | |
return data; | |
} | |
} | |
let visibleStates = ["suggesting", "navigating", "selectingWithClick"]; | |
let selectingWithClickNode = null; | |
let Context = React.createContext(); | |
export function Combobox({ children, onSelect = k }) { | |
let optionsRef = useRef([]); | |
let inputRef = useRef(null); | |
let defaultData = { | |
value: "", | |
navigationValue: null | |
}; | |
let [state, data, transition] = useSimpleMachineLogger( | |
chart, | |
reducer, | |
defaultData | |
); | |
let rect = useRect(inputRef, visibleStates.includes(state)); | |
function registerOption(value) { | |
let { current: options } = optionsRef; | |
// TODO: validate unique values | |
options.push(value); | |
return () => options.splice(options.indexOf(value), 1); | |
} | |
let context = useMemo( | |
() => ({ | |
state, | |
transition, | |
data, | |
registerOption, | |
options: optionsRef.current, | |
inputRef, | |
onSelect, | |
rect | |
}), | |
// onSelect will need to be memoized, or useCallback? | |
[state, data, rect, onSelect] | |
); | |
return <Context.Provider value={context}>{children}</Context.Provider>; | |
} | |
export function ComboboxInput({ | |
selectOnClick = false, | |
onClick, | |
onChange, | |
onKeyDown, | |
onBlur, | |
onFocus, | |
...props | |
}) { | |
let { | |
transition, | |
options, | |
state, | |
onSelect, | |
inputRef, | |
data: { navigationValue, value } | |
} = useContext(Context); | |
let selectOnClickRef = useRef(false); | |
function handleBlur(event) { | |
if (state !== "selectingWithClick") { | |
transition("BLUR"); | |
} | |
} | |
function handleChange(event) { | |
let value = event.target.value; | |
// if (value.trim() === "") { | |
// transition("EMPTY", { value }); | |
// } else { | |
transition("CHANGE", { value }); | |
// } | |
} | |
function handleKeyDown(event) { | |
switch (event.key) { | |
case "ArrowDown": { | |
event.preventDefault(); | |
let index = options.indexOf(navigationValue); | |
let nextValue = options[(index + 1) % options.length]; | |
transition("NAVIGATE", { value: nextValue }); | |
break; | |
} | |
case "ArrowUp": { | |
event.preventDefault(); | |
let index = options.indexOf(navigationValue); | |
if (index === 0) { | |
// go back to their original value | |
transition("NAVIGATE", { value: null }); | |
} else if (index === -1) { | |
// if navigating a closed list we don't have any options yet, | |
// this is desired behavior so the list opens but nothing is selected | |
let value = options.length ? options[options.length - 1] : undefined; | |
transition("NAVIGATE", { value }); | |
} else { | |
let nextValue = | |
options[(index - 1 + options.length) % options.length]; | |
transition("NAVIGATE", { value: nextValue }); | |
} | |
break; | |
} | |
case "Escape": { | |
if (state !== "idle") { | |
transition("ESCAPE"); | |
} | |
break; | |
} | |
case "Enter": { | |
if (state === "navigating" && navigationValue !== null) { | |
// don't want to submit forms | |
event.preventDefault(); | |
transition("SELECT_WITH_KEYBOARD"); | |
onSelect(navigationValue); | |
onChange && onChange(event); | |
} | |
break; | |
} | |
default: { | |
} | |
} | |
} | |
function handleFocus() { | |
if (selectOnClick) { | |
selectOnClickRef.current = true; | |
} | |
} | |
function handleClick() { | |
if (selectOnClickRef.current) { | |
selectOnClickRef.current = false; | |
inputRef.current.select(); | |
} | |
} | |
let inputValue = | |
state === "navigating" | |
? // we don't always have a `navigationValue` while navigating | |
// (like first arrow down on idle) | |
navigationValue || value | |
: value; | |
return ( | |
<input | |
ref={inputRef} | |
{...props} | |
value={inputValue} | |
onClick={wrapEvent(onClick, handleClick)} | |
onBlur={wrapEvent(onBlur, handleBlur)} | |
onFocus={wrapEvent(onFocus, handleFocus)} | |
onChange={wrapEvent(onChange, handleChange)} | |
onKeyDown={wrapEvent(onKeyDown, handleKeyDown)} | |
/> | |
); | |
} | |
export function ComboboxList({ children, style, ...props }) { | |
let { state, transition, value, rect } = useContext(Context); | |
useEffect(() => { | |
let handler = event => { | |
if (selectingWithClickNode && selectingWithClickNode !== event.target) { | |
selectingWithClickNode = null; | |
transition("BLUR"); | |
} | |
}; | |
document.addEventListener("mouseup", handler); | |
return () => document.removeEventListener("mouseup", handler); | |
}, []); | |
let el = | |
visibleStates.includes(state) && rect ? ( | |
<ul | |
style={{ | |
...style, | |
position: "fixed", | |
top: rect.bottom, | |
left: rect.left, | |
width: rect.width | |
}} | |
{...props} | |
data-reach-combobox-list | |
children={children} | |
/> | |
) : null; | |
return <Portal>{rect ? el : null}</Portal>; | |
} | |
export function ComboboxOption({ children, value, ...props }) { | |
let { | |
transition, | |
registerOption, | |
onSelect, | |
data: { navigationValue, value: contextValue } | |
} = useContext(Context); | |
let isActive = navigationValue === value; | |
useEffect(() => registerOption(value), []); | |
function handleMouseDown(event) { | |
selectingWithClickNode = event.target; | |
transition("MOUSE_DOWN"); | |
} | |
function handleClick() { | |
transition("SELECT_WITH_CLICK", { value }); | |
onSelect(value); | |
} | |
if (typeof children === "string") { | |
let searchWords = contextValue.split(/\s+/); | |
let textToHighlight = value; | |
let results = findAll({ searchWords, textToHighlight }); | |
if (results.length) { | |
children = ( | |
<Fragment> | |
{results.map((result, index) => { | |
let str = value.slice(result.start, result.end); | |
return ( | |
<span | |
key={index} | |
data-user-value={result.highlight ? true : undefined} | |
data-suggested-value={result.highlight ? undefined : true} | |
> | |
{str} | |
</span> | |
); | |
})} | |
</Fragment> | |
); | |
} | |
} | |
return ( | |
<li | |
{...props} | |
tabIndex="-1" | |
data-reach-combobox-option | |
aria-selected={isActive} | |
onClick={handleClick} | |
onMouseDown={handleMouseDown} | |
children={children} | |
/> | |
); | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
function k() {} | |
function wrapEvent(userFn, fn) { | |
return event => { | |
if (userFn) userFn(event); | |
if (event.defaultPrevented) return; | |
fn(event); | |
}; | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
function useSimpleMachine(chart, reducer, initialData) { | |
let [state, setState] = useState(chart.initial); | |
let [data, dispatch] = useReducer(reducer, initialData); | |
let transition = (action, payload = {}) => { | |
let nextState = chart[state][action]; | |
dispatch({ type: action, state, nextState: state, ...payload }); | |
setState(nextState); | |
}; | |
return [state, data, transition]; | |
} | |
function useSimpleMachineLogger(chart, reducer, initialData) { | |
let [state, data, transition] = useSimpleMachine(chart, reducer, initialData); | |
function loggedTransition(action, payload = {}) { | |
let nextState = chart[state][action]; | |
console.group("useSimpleMachine"); | |
console.log({ action }, payload); | |
console.log({ state, nextState }); | |
console.log("data", data); | |
console.log( | |
"nextData", | |
reducer(data, { type: action, state, nextState: state, ...payload }) | |
); | |
console.groupEnd(); | |
transition(action, payload); | |
} | |
return [state, data, loggedTransition]; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment