Last active
September 9, 2024 07:58
-
-
Save sladg/181d5222547d2ff0bdb4b7e0d9f7aa5f to your computer and use it in GitHub Desktop.
FloatingUI - codesandbox copy
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 "./styles.css"; | |
import { forwardRef, useRef, useState } from "react"; | |
import { | |
autoUpdate, | |
size, | |
flip, | |
useId, | |
useDismiss, | |
useFloating, | |
useInteractions, | |
useListNavigation, | |
useRole, | |
FloatingFocusManager, | |
FloatingPortal | |
} from "@floating-ui/react"; | |
export const data = [ | |
"Alfalfa Sprouts", | |
"Apple", | |
"Apricot", | |
"Artichoke", | |
"Asian Pear", | |
"Asparagus", | |
"Atemoya", | |
"Avocado", | |
"Bamboo Shoots", | |
"Banana", | |
"Bean Sprouts", | |
"Beans", | |
"Beets", | |
"Belgian Endive", | |
"Bell Peppers", | |
"Bitter Melon", | |
"Blackberries", | |
"Blueberries", | |
"Bok Choy", | |
"Boniato", | |
"Boysenberries", | |
"Broccoflower", | |
"Broccoli", | |
"Brussels Sprouts", | |
"Cabbage", | |
"Cactus Pear", | |
"Cantaloupe", | |
"Carambola", | |
"Carrots", | |
"Casaba Melon", | |
"Cauliflower", | |
"Celery", | |
"Chayote", | |
"Cherimoya", | |
"Cherries", | |
"Coconuts", | |
"Collard Greens", | |
"Corn", | |
"Cranberries", | |
"Cucumber", | |
"Dates", | |
"Dried Plums", | |
"Eggplant", | |
"Endive", | |
"Escarole", | |
"Feijoa", | |
"Fennel", | |
"Figs", | |
"Garlic", | |
"Gooseberries", | |
"Grapefruit", | |
"Grapes", | |
"Green Beans", | |
"Green Onions", | |
"Greens", | |
"Guava", | |
"Hominy", | |
"Honeydew Melon", | |
"Horned Melon", | |
"Iceberg Lettuce", | |
"Jerusalem Artichoke", | |
"Jicama", | |
"Kale", | |
"Kiwifruit", | |
"Kohlrabi", | |
"Kumquat", | |
"Leeks", | |
"Lemons", | |
"Lettuce", | |
"Lima Beans", | |
"Limes", | |
"Longan", | |
"Loquat", | |
"Lychee", | |
"Madarins", | |
"Malanga", | |
"Mandarin Oranges", | |
"Mangos", | |
"Mulberries", | |
"Mushrooms", | |
"Napa", | |
"Nectarines", | |
"Okra", | |
"Onion", | |
"Oranges", | |
"Papayas", | |
"Parsnip", | |
"Passion Fruit", | |
"Peaches", | |
"Pears", | |
"Peas", | |
"Peppers", | |
"Persimmons", | |
"Pineapple", | |
"Plantains", | |
"Plums", | |
"Pomegranate", | |
"Potatoes", | |
"Prickly Pear", | |
"Prunes", | |
"Pummelo", | |
"Pumpkin", | |
"Quince", | |
"Radicchio", | |
"Radishes", | |
"Raisins", | |
"Raspberries", | |
"Red Cabbage", | |
"Rhubarb", | |
"Romaine Lettuce", | |
"Rutabaga", | |
"Shallots", | |
"Snow Peas", | |
"Spinach", | |
"Sprouts", | |
"Squash", | |
"Strawberries", | |
"String Beans", | |
"Sweet Potato", | |
"Tangelo", | |
"Tangerines", | |
"Tomatillo", | |
"Tomato", | |
"Turnip", | |
"Ugli Fruit", | |
"Water Chestnuts", | |
"Watercress", | |
"Watermelon", | |
"Waxed Beans", | |
"Yams", | |
"Yellow Squash", | |
"Yuca/Cassava", | |
"Zucchini Squash" | |
]; | |
interface ItemProps { | |
children: React.ReactNode; | |
active: boolean; | |
} | |
const Item = forwardRef< | |
HTMLDivElement, | |
ItemProps & React.HTMLProps<HTMLDivElement> | |
>(({ children, active, ...rest }, ref) => { | |
const id = useId(); | |
return ( | |
<div | |
ref={ref} | |
role="option" | |
id={id} | |
aria-selected={active} | |
{...rest} | |
style={{ | |
background: active ? "lightblue" : "none", | |
padding: 4, | |
cursor: "default", | |
...rest.style | |
}} | |
> | |
{children} | |
</div> | |
); | |
}); | |
function AutoComplete() { | |
const [open, setOpen] = useState(false); | |
const [inputValue, setInputValue] = useState(""); | |
const [activeIndex, setActiveIndex] = useState<number | null>(null); | |
const listRef = useRef<Array<HTMLElement | null>>([]); | |
const { refs, floatingStyles, context } = useFloating<HTMLInputElement>({ | |
whileElementsMounted: autoUpdate, | |
open, | |
onOpenChange: setOpen, | |
middleware: [ | |
flip({ padding: 10 }), | |
size({ | |
apply({ rects, availableHeight, elements }) { | |
Object.assign(elements.floating.style, { | |
width: `${rects.reference.width}px`, | |
maxHeight: `${availableHeight}px` | |
}); | |
}, | |
padding: 10 | |
}) | |
] | |
}); | |
const role = useRole(context, { role: "listbox" }); | |
const dismiss = useDismiss(context); | |
const listNav = useListNavigation(context, { | |
listRef, | |
activeIndex, | |
onNavigate: setActiveIndex, | |
virtual: true, | |
loop: true | |
}); | |
const { | |
getReferenceProps, | |
getFloatingProps, | |
getItemProps | |
} = useInteractions([role, dismiss, listNav]); | |
function onChange(event: React.ChangeEvent<HTMLInputElement>) { | |
const value = event.target.value; | |
setInputValue(value); | |
if (value) { | |
setOpen(true); | |
setActiveIndex(0); | |
} else { | |
setOpen(false); | |
} | |
} | |
const items = data.filter((item) => | |
item.toLowerCase().startsWith(inputValue.toLowerCase()) | |
); | |
return ( | |
<> | |
<input | |
{...getReferenceProps({ | |
ref: refs.setReference, | |
onChange, | |
value: inputValue, | |
placeholder: "Enter fruit", | |
"aria-autocomplete": "list", | |
onKeyDown(event) { | |
if ( | |
event.key === "Enter" && | |
activeIndex != null && | |
items[activeIndex] | |
) { | |
setInputValue(items[activeIndex]); | |
setActiveIndex(null); | |
setOpen(false); | |
} | |
} | |
})} | |
/> | |
<FloatingPortal> | |
{open && ( | |
<FloatingFocusManager | |
context={context} | |
initialFocus={-1} | |
visuallyHiddenDismiss | |
> | |
<div | |
{...getFloatingProps({ | |
ref: refs.setFloating, | |
style: { | |
...floatingStyles, | |
background: "#eee", | |
color: "black", | |
overflowY: "auto" | |
} | |
})} | |
> | |
{items.map((item, index) => ( | |
<Item | |
{...getItemProps({ | |
key: item, | |
ref(node) { | |
listRef.current[index] = node; | |
}, | |
onClick() { | |
setInputValue(item); | |
setOpen(false); | |
refs.domReference.current?.focus(); | |
} | |
})} | |
active={activeIndex === index} | |
> | |
{item} | |
</Item> | |
))} | |
</div> | |
</FloatingFocusManager> | |
)} | |
</FloatingPortal> | |
</> | |
); | |
} | |
export default function App() { | |
return ( | |
<> | |
<h1>Floating UI List Autocomplete with Automatic Selection</h1> | |
<p> | |
<a href="https://www.w3.org/TR/wai-aria-practices-1.1/examples/combobox/aria1.1pattern/listbox-combo.html"> | |
Combobox - WAI-ARIA Authoring Guide | |
</a> | |
</p> | |
<AutoComplete /> | |
<h3>Others</h3> | |
<ul> | |
<li> | |
<a | |
href="https://codesandbox.io/s/confident-waterfall-sxcmgs?file=/src/App.tsx" | |
target="_blank" | |
rel="noreferrer" | |
> | |
Manual selection | |
</a> | |
</li> | |
</ul> | |
</> | |
); | |
} |
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 "./styles.css"; | |
import * as React from "react"; | |
import { | |
useFloating, | |
useClick, | |
useDismiss, | |
useRole, | |
useListNavigation, | |
useInteractions, | |
FloatingFocusManager, | |
useTypeahead, | |
offset, | |
flip, | |
size, | |
autoUpdate, | |
FloatingPortal, | |
} from "@floating-ui/react"; | |
const options = [ | |
"Red", | |
"Orange", | |
"Yellow", | |
"Green", | |
"Cyan", | |
"Blue", | |
"Purple", | |
"Pink", | |
"Maroon", | |
"Black", | |
"White", | |
]; | |
export default function App() { | |
const [isOpen, setIsOpen] = React.useState(false); | |
const [activeIndex, setActiveIndex] = React.useState<number | null>(null); | |
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null); | |
const { refs, floatingStyles, context } = useFloating<HTMLElement>({ | |
placement: "bottom-start", | |
open: isOpen, | |
onOpenChange: setIsOpen, | |
whileElementsMounted: autoUpdate, | |
middleware: [ | |
offset(5), | |
flip({ padding: 10 }), | |
size({ | |
apply({ rects, elements, availableHeight }) { | |
Object.assign(elements.floating.style, { | |
maxHeight: `${availableHeight}px`, | |
minWidth: `${rects.reference.width}px`, | |
}); | |
}, | |
padding: 10, | |
}), | |
], | |
}); | |
const listRef = React.useRef<Array<HTMLElement | null>>([]); | |
const listContentRef = React.useRef(options); | |
const isTypingRef = React.useRef(false); | |
const click = useClick(context, { event: "mousedown" }); | |
const dismiss = useDismiss(context); | |
const role = useRole(context, { role: "listbox" }); | |
const listNav = useListNavigation(context, { | |
listRef, | |
activeIndex, | |
selectedIndex, | |
onNavigate: setActiveIndex, | |
// This is a large list, allow looping. | |
loop: true, | |
}); | |
const typeahead = useTypeahead(context, { | |
listRef: listContentRef, | |
activeIndex, | |
selectedIndex, | |
onMatch: isOpen ? setActiveIndex : setSelectedIndex, | |
onTypingChange(isTyping) { | |
isTypingRef.current = isTyping; | |
}, | |
}); | |
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( | |
[dismiss, role, listNav, typeahead, click] | |
); | |
const handleSelect = (index: number) => { | |
setSelectedIndex(index); | |
setIsOpen(false); | |
}; | |
const selectedItemLabel = | |
selectedIndex !== null ? options[selectedIndex] : undefined; | |
return ( | |
<> | |
<h1>Floating UI — Select</h1> | |
<label | |
id="select-label" | |
onClick={() => refs.domReference.current?.focus()} | |
> | |
Select balloon color | |
</label> | |
<div | |
tabIndex={0} | |
ref={refs.setReference} | |
aria-labelledby="select-label" | |
aria-autocomplete="none" | |
style={{ width: 150, lineHeight: 2, margin: "auto" }} | |
{...getReferenceProps()} | |
> | |
{selectedItemLabel || "Select..."} | |
</div> | |
{isOpen && ( | |
<FloatingPortal> | |
<FloatingFocusManager context={context} modal={false}> | |
<div | |
ref={refs.setFloating} | |
style={{ | |
...floatingStyles, | |
overflowY: "auto", | |
background: "#eee", | |
minWidth: 100, | |
borderRadius: 8, | |
outline: 0, | |
}} | |
{...getFloatingProps()} | |
> | |
{options.map((value, i) => ( | |
<div | |
key={value} | |
ref={(node) => { | |
listRef.current[i] = node; | |
}} | |
role="option" | |
tabIndex={i === activeIndex ? 0 : -1} | |
aria-selected={i === selectedIndex && i === activeIndex} | |
style={{ | |
padding: 10, | |
cursor: "default", | |
background: i === activeIndex ? "cyan" : "", | |
}} | |
{...getItemProps({ | |
// Handle pointer select. | |
onClick() { | |
handleSelect(i); | |
}, | |
// Handle keyboard select. | |
onKeyDown(event) { | |
if (event.key === "Enter") { | |
event.preventDefault(); | |
handleSelect(i); | |
} | |
if (event.key === " " && !isTypingRef.current) { | |
event.preventDefault(); | |
handleSelect(i); | |
} | |
}, | |
})} | |
> | |
{value} | |
<span | |
aria-hidden | |
style={{ | |
position: "absolute", | |
right: 10, | |
}} | |
> | |
{i === selectedIndex ? " ✓" : ""} | |
</span> | |
</div> | |
))} | |
</div> | |
</FloatingFocusManager> | |
</FloatingPortal> | |
)} | |
</> | |
); | |
} |
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
body { | |
font-family: sans-serif; | |
text-align: center; | |
} | |
* { | |
box-sizing: border-box; | |
} | |
label { | |
display: inline-block; | |
} | |
label > span { | |
display: block; | |
padding-bottom: 10px; | |
} | |
[role="option"]:focus { | |
outline: 0; | |
} | |
[role="combobox"] { | |
background: #ddd; | |
user-select: none; | |
} | |
:root { | |
--highlighted: royalblue; | |
--active-unfocused: #d7dce5; | |
} | |
html, | |
body { | |
height: 100%; | |
font-family: sans-serif; | |
padding: 0 15px; | |
} | |
.RootMenu { | |
padding: 6px 14px; | |
border: none; | |
font-size: 16px; | |
background: none; | |
border-radius: 6px; | |
border: 1px solid var(--active-unfocused); | |
} | |
.RootMenu[data-open], | |
.RootMenu:hover { | |
background: var(--active-unfocused); | |
} | |
.Menu { | |
background: rgba(255, 255, 255, 0.8); | |
-webkit-backdrop-filter: blur(10px); | |
backdrop-filter: blur(10px); | |
padding: 4px; | |
border-radius: 6px; | |
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.1); | |
outline: 0; | |
} | |
.MenuItem { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
background: none; | |
width: 100%; | |
border: none; | |
border-radius: 4px; | |
font-size: 16px; | |
text-align: left; | |
line-height: 1.8; | |
min-width: 110px; | |
margin: 0; | |
outline: 0; | |
} | |
.MenuItem:focus { | |
background: var(--highlighted); | |
color: white; | |
} | |
.MenuItem[data-nested][data-open]:not([data-focus-inside]) { | |
background: var(--highlighted); | |
color: white; | |
} | |
.MenuItem[data-focus-inside][data-open] { | |
background: var(--active-unfocused); | |
} | |
body { | |
font-family: sans-serif; | |
} | |
[role="listbox"] ul { | |
list-style-type: none; | |
padding: 0; | |
margin: 0; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment