Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active January 29, 2019 21:25
Show Gist options
  • Star 26 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save ryanflorence/3b5cc8da206c717b9da9191e3b444b6e to your computer and use it in GitHub Desktop.
Save ryanflorence/3b5cc8da206c717b9da9191e3b444b6e to your computer and use it in GitHub Desktop.
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>
);
/* 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];
}
/* 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];
}
@fatso83
Copy link

fatso83 commented Nov 12, 2018

Required reading for React Devs with heads stuck in React 15 land (me):

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment