Skip to content

Instantly share code, notes, and snippets.

@sladg
Last active September 9, 2024 07:58
Show Gist options
  • Save sladg/181d5222547d2ff0bdb4b7e0d9f7aa5f to your computer and use it in GitHub Desktop.
Save sladg/181d5222547d2ff0bdb4b7e0d9f7aa5f to your computer and use it in GitHub Desktop.
FloatingUI - codesandbox copy
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>
</>
);
}
import {
autoUpdate,
flip,
FloatingFocusManager,
FloatingList,
FloatingNode,
FloatingPortal,
FloatingTree,
offset,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useHover,
useInteractions,
useListItem,
useListNavigation,
useMergeRefs,
useRole,
useTypeahead
} from "@floating-ui/react";
import * as React from "react";
const MenuContext = React.createContext<{
getItemProps: (
userProps?: React.HTMLProps<HTMLElement>
) => Record<string, unknown>;
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
setHasFocusInside: React.Dispatch<React.SetStateAction<boolean>>;
isOpen: boolean;
}>({
getItemProps: () => ({}),
activeIndex: null,
setActiveIndex: () => {},
setHasFocusInside: () => {},
isOpen: false
});
interface MenuProps {
label: string;
nested?: boolean;
children?: React.ReactNode;
}
export const MenuComponent = React.forwardRef<
HTMLButtonElement,
MenuProps & React.HTMLProps<HTMLButtonElement>
>(({ children, label, ...props }, forwardedRef) => {
const [isOpen, setIsOpen] = React.useState(false);
const [hasFocusInside, setHasFocusInside] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
const elementsRef = React.useRef<Array<HTMLButtonElement | null>>([]);
const labelsRef = React.useRef<Array<string | null>>([]);
const parent = React.useContext(MenuContext);
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const parentId = useFloatingParentNodeId();
const item = useListItem();
const isNested = parentId != null;
const { floatingStyles, refs, context } = useFloating<HTMLButtonElement>({
nodeId,
open: isOpen,
onOpenChange: setIsOpen,
placement: isNested ? "right-start" : "bottom-start",
middleware: [
offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }),
flip(),
shift()
],
whileElementsMounted: autoUpdate
});
const hover = useHover(context, {
enabled: isNested,
delay: { open: 75 },
handleClose: safePolygon({ blockPointerEvents: true })
});
const click = useClick(context, {
event: "mousedown",
toggle: !isNested,
ignoreMouse: isNested
});
const role = useRole(context, { role: "menu" });
const dismiss = useDismiss(context, { bubbles: true });
const listNavigation = useListNavigation(context, {
listRef: elementsRef,
activeIndex,
nested: isNested,
onNavigate: setActiveIndex
});
const typeahead = useTypeahead(context, {
listRef: labelsRef,
onMatch: isOpen ? setActiveIndex : undefined,
activeIndex
});
const {
getReferenceProps,
getFloatingProps,
getItemProps
} = useInteractions([hover, click, role, dismiss, listNavigation, typeahead]);
// Event emitter allows you to communicate across tree components.
// This effect closes all menus when an item gets clicked anywhere
// in the tree.
React.useEffect(() => {
if (!tree) return;
function handleTreeClick() {
setIsOpen(false);
}
function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
if (event.nodeId !== nodeId && event.parentId === parentId) {
setIsOpen(false);
}
}
tree.events.on("click", handleTreeClick);
tree.events.on("menuopen", onSubMenuOpen);
return () => {
tree.events.off("click", handleTreeClick);
tree.events.off("menuopen", onSubMenuOpen);
};
}, [tree, nodeId, parentId]);
React.useEffect(() => {
if (isOpen && tree) {
tree.events.emit("menuopen", { parentId, nodeId });
}
}, [tree, isOpen, nodeId, parentId]);
return (
<FloatingNode id={nodeId}>
<button
ref={useMergeRefs([refs.setReference, item.ref, forwardedRef])}
tabIndex={
!isNested ? undefined : parent.activeIndex === item.index ? 0 : -1
}
role={isNested ? "menuitem" : undefined}
data-open={isOpen ? "" : undefined}
data-nested={isNested ? "" : undefined}
data-focus-inside={hasFocusInside ? "" : undefined}
className={isNested ? "MenuItem" : "RootMenu"}
{...getReferenceProps(
parent.getItemProps({
...props,
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
props.onFocus?.(event);
setHasFocusInside(false);
parent.setHasFocusInside(true);
}
})
)}
>
{label}
{isNested && (
<span aria-hidden style={{ marginLeft: 10, fontSize: 10 }}>
</span>
)}
</button>
<MenuContext.Provider
value={{
activeIndex,
setActiveIndex,
getItemProps,
setHasFocusInside,
isOpen
}}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{isOpen && (
<FloatingPortal>
<FloatingFocusManager
context={context}
modal={false}
initialFocus={isNested ? -1 : 0}
returnFocus={!isNested}
>
<div
ref={refs.setFloating}
className="Menu"
style={floatingStyles}
{...getFloatingProps()}
>
{children}
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</FloatingList>
</MenuContext.Provider>
</FloatingNode>
);
});
interface MenuItemProps {
label: string;
disabled?: boolean;
}
export const MenuItem = React.forwardRef<
HTMLButtonElement,
MenuItemProps & React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ label, disabled, ...props }, forwardedRef) => {
const menu = React.useContext(MenuContext);
const item = useListItem({ label: disabled ? null : label });
const tree = useFloatingTree();
const isActive = item.index === menu.activeIndex;
return (
<button
{...props}
ref={useMergeRefs([item.ref, forwardedRef])}
type="button"
role="menuitem"
className="MenuItem"
tabIndex={isActive ? 0 : -1}
disabled={disabled}
{...menu.getItemProps({
onClick(event: React.MouseEvent<HTMLButtonElement>) {
props.onClick?.(event);
tree?.events.emit("click");
},
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
props.onFocus?.(event);
menu.setHasFocusInside(true);
}
})}
>
{label}
</button>
);
});
export const Menu = React.forwardRef<
HTMLButtonElement,
MenuProps & React.HTMLProps<HTMLButtonElement>
>((props, ref) => {
const parentId = useFloatingParentNodeId();
if (parentId === null) {
return (
<FloatingTree>
<MenuComponent {...props} ref={ref} />
</FloatingTree>
);
}
return <MenuComponent {...props} ref={ref} />;
});
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>
)}
</>
);
}
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