diff --git a/website/lib/components/Button.js b/website/lib/components/Button.js new file mode 100644 index 0000000000..9be7adf72f --- /dev/null +++ b/website/lib/components/Button.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import {twMerge} from 'tailwind-merge'; + +export const Button = React.forwardRef(function Button( + props, + ref +) { + return ( + + ); + } +); + +export const DialogContent = React.forwardRef( + function DialogContent(props, propRef) { + const {context: floatingContext, ...context} = + useDialogContext(); + const ref = useMergeRefs([ + context.refs.setFloating, + propRef, + ]); + + const {isMounted, styles} = useTransitionStyles( + floatingContext, + { + duration: {open: 400}, + } + ); + + return ( + + {isMounted && ( + + +
+ {props.children} +
+
+
+ )} +
+ ); + } +); + +export const DialogHeading = React.forwardRef( + function DialogHeading({children, ...props}, ref) { + const {setLabelId} = useDialogContext(); + const id = useId(); + + // Only sets `aria-labelledby` on the Dialog root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + return ( +

+ {children} +

+ ); + } +); + +export const DialogDescription = React.forwardRef( + function DialogDescription({children, ...props}, ref) { + const {setDescriptionId} = useDialogContext(); + const id = useId(); + + // Only sets `aria-describedby` on the Dialog root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + return ( +

+ {children} +

+ ); + } +); + +export const DialogClose = React.forwardRef(function DialogClose( + {children, ...props}, + ref +) { + const {setOpen} = useDialogContext(); + return ( + + ); +}); diff --git a/website/lib/components/Layout.js b/website/lib/components/Layout.js index 4456caf8d4..3ba36f669e 100644 --- a/website/lib/components/Layout.js +++ b/website/lib/components/Layout.js @@ -136,6 +136,13 @@ const interactions = [ depth: 1, mono: true, }, + { + url: '/docs/useClientPoint', + title: 'useClientPoint', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAU1QTFRFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsY4lIQAAAG90Uk5TAAQpQ1FSJwtOlMrp9v3oyw0DVtT/21cCKs3+xgFN3FvySDj3PRvJ2o417eNnJQcGbB2b9IMVj2GromjvPIy+v6mCqJGQi8Jq8CRpOrSub26w/CYFa0EMEsjhRrst5ln40tAWPuDrT1TnRzbTLLoJRC0RjAAAAcdJREFUeJzVles7AkEUxie5bMolhsgipUS1IZVbCBXKJeTexTWX9P9/tE/NrN3OzFafPN6P7/m9zzx75pxZhP6HDB3Gzi5jt6E1ukcw9ZotfX2W/gGTMNgUtw4NY5VGRm36/Ng41so+MaaDi5MjGGhKELmBaQfkMXbM8Hini8Vj7Jpl824Pm8d4zs0MeHk8xl4WP7+gdMbnD0jBwKLPTp2FJUZgmVZDK+G6E14JUS8CeatyQFS5K1tUOcIKW0Rrq2p3jbpOEFgnlY1NtRvYILYfBGKksqW5V3Gb2DEQoIc3fF6E2GsgsEMqca0dJ/Yu94Q9rb3PPSFBKklNA8UksRMgkCKVA82kHdIuHYFAmnY8o3aPqZsGAUnZhRNl+Q2n1HNIIIAytMiapTPIo2iWVuVpPZcuApe/05rNMQJX3P3B2BNkBFDKzg2kWDy6vuHxt9fMAMrxAndsHlnv2XwGbg/RQ57F59lvRk2FIuSLBT6PpAQMxBiXrEqUGvlHXV4ez4ZE6UmfR+j5Rc2/Pjfj5fdG9eFFzjOslWCmvPmtFR6J5fc6/17m/0q0ibvaHoQ+WuRlfcq9+tL7uQFVvicq7fAIVavt8X+pH2AHXX0SoLBwAAAAAElFTkSuQmCC', + depth: 1, + mono: true, + }, { url: '/docs/useMergeRefs', title: 'useMergeRefs', diff --git a/website/lib/components/Logos.js b/website/lib/components/Logos.js index cba20e597f..1b8cbaa127 100644 --- a/website/lib/components/Logos.js +++ b/website/lib/components/Logos.js @@ -8,14 +8,14 @@ import { export function Logos({items}) { return ( -
+
{items.map((item) => ( ({ + opacity: 0, + transform: { + top: 'translateY(-0.5rem)', + right: 'translateX(0.5rem)', + bottom: 'translateY(0.5rem)', + left: 'translateX(-0.5rem)', + }[side], + }), + close: () => ({ + opacity: 0, + transform: 'scale(0.97)', + }), + }); + + return React.useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + ...transition, + modal, + labelId, + descriptionId, + setLabelId, + setDescriptionId, + arrowRef, + }), + [ + open, + setOpen, + interactions, + data, + modal, + labelId, + descriptionId, + transition, + ] + ); +} + +const PopoverContext = React.createContext(null); + +export const usePopoverContext = () => { + const context = React.useContext(PopoverContext); + + if (context == null) { + throw new Error( + 'Popover components must be wrapped in ' + ); + } + + return context; +}; + +export function Popover({ + children, + modal = false, + ...restOptions +}) { + // This can accept any props as options, e.g. `placement`, + // or other positioning options. + const popover = usePopover({modal, ...restOptions}); + return ( + + {children} + + ); +} + +export const PopoverTrigger = React.forwardRef( + function PopoverTrigger( + {children, asChild = false, ...props}, + propRef + ) { + const context = usePopoverContext(); + const childrenRef = children.ref; + const ref = useMergeRefs([ + context.refs.setReference, + propRef, + childrenRef, + ]); + + // `asChild` allows the user to pass any element as the anchor + if (asChild && React.isValidElement(children)) { + return React.cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...children.props, + 'data-state': context.open ? 'open' : 'closed', + }) + ); + } + + return ( + + ); + } +); + +export const PopoverContent = React.forwardRef( + function PopoverContent(props, propRef) { + const {context: floatingContext, ...context} = + usePopoverContext(); + const ref = useMergeRefs([ + context.refs.setFloating, + propRef, + ]); + + return ( + + {context.isMounted && ( + +
+ {props.children} + +
+
+ )} +
+ ); + } +); + +export const PopoverHeading = React.forwardRef( + function PopoverHeading({children, ...props}, ref) { + const {setLabelId} = usePopoverContext(); + const id = useId(); + + // Only sets `aria-labelledby` on the Popover root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + return ( +

+ {children} +

+ ); + } +); + +export const PopoverDescription = React.forwardRef( + function PopoverDescription({children, ...props}, ref) { + const {setDescriptionId} = usePopoverContext(); + const id = useId(); + + // Only sets `aria-describedby` on the Popover root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + return ( +

+ {children} +

+ ); + } +); + +export const PopoverClose = React.forwardRef( + function PopoverClose({children, ...props}, ref) { + const {setOpen} = usePopoverContext(); + return ( + + ); + } +); diff --git a/website/lib/components/Tooltip.js b/website/lib/components/Tooltip.js index 1500c1cce8..e986760b85 100644 --- a/website/lib/components/Tooltip.js +++ b/website/lib/components/Tooltip.js @@ -1,6 +1,8 @@ import { + arrow, autoUpdate, flip, + FloatingArrow, FloatingPortal, inline, offset, @@ -16,6 +18,7 @@ import { useRole, useTransitionStyles, } from '@floating-ui/react'; +import classNames from 'classnames'; import * as React from 'react'; import {useId} from 'react'; @@ -29,6 +32,8 @@ export function useTooltip({ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + const arrowRef = React.useRef(null); + const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; @@ -45,6 +50,10 @@ export function useTooltip({ crossAxis: placement.includes('-'), }), shift({padding: 5}), + arrow({ + element: arrowRef, + padding: 4, + }), ], }); @@ -53,7 +62,7 @@ export function useTooltip({ const hover = useHover(context, { move: false, enabled: controlledOpen == null, - restMs: isInstantPhase ? 0 : 50, + restMs: isInstantPhase ? 0 : 150, delay, }); const focus = useFocus(context, { @@ -75,6 +84,7 @@ export function useTooltip({ setOpen, ...interactions, ...data, + arrowRef, }), [open, setOpen, interactions, data] ); @@ -165,8 +175,8 @@ export const TooltipContent = React.forwardRef( floatingContext, { duration: isInstantPhase - ? {open: 100, close: id === currentId ? 500 : 100} - : {open: 500, close: 150}, + ? {open: 100, close: id === currentId ? 150 : 50} + : {open: 300, close: 150}, initial: { opacity: 0, transform: 'scale(0.95)', @@ -179,21 +189,29 @@ export const TooltipContent = React.forwardRef( {isMounted && (
+ > + {props.children} + +
)} ); diff --git a/website/package-lock.json b/website/package-lock.json index 0349f8746e..62aa752bfc 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@docsearch/css": "^3.1.0", "@docsearch/react": "^3.1.0", - "@floating-ui/react": "^0.20.1", + "@floating-ui/react": "^0.21.0", "@mdx-js/loader": "^2.1.1", "@mdx-js/react": "^2.1.1", "@next/mdx": "^12.1.5", @@ -27,6 +27,7 @@ "rehype-pretty-code": "^0.9.4", "remark-smartypants": "^2.0.0", "shiki": "^0.12.1", + "tailwind-merge": "^1.10.0", "unist-util-visit": "^2.0.3", "use-isomorphic-layout-effect": "^1.1.1" }, @@ -1892,9 +1893,9 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.20.1.tgz", - "integrity": "sha512-JHTHJ+/YsIxNFH8uJDFa5OyI6dSUZcle6wAFe0zRTjgWD+rkACfBBoJtx2itTtn7C4a7xAz4jgxdEQcMel194g==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.21.0.tgz", + "integrity": "sha512-4Zut7tjeDVEKHaR6N3uG4m1dl114UkLuK4SNAeHlAb4pKu5KEkMkI34Y8NmCc4ARfXIu25UGUhYBUzShDhbofA==", "dependencies": { "@floating-ui/react-dom": "^1.3.0", "aria-hidden": "^1.1.3", @@ -7379,6 +7380,11 @@ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.1.1.tgz", "integrity": "sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg==" }, + "node_modules/tailwind-merge": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.10.0.tgz", + "integrity": "sha512-WFnDXSS4kFTZwjKg5/oZSGzBRU/l+qcbv5NVTzLUQvJ9yovDAP05h0F2+ZFW0Lw9EcgRoc2AfURUdZvnEFrXKg==" + }, "node_modules/tailwindcss": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz", @@ -9437,9 +9443,9 @@ } }, "@floating-ui/react": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.20.1.tgz", - "integrity": "sha512-JHTHJ+/YsIxNFH8uJDFa5OyI6dSUZcle6wAFe0zRTjgWD+rkACfBBoJtx2itTtn7C4a7xAz4jgxdEQcMel194g==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.21.0.tgz", + "integrity": "sha512-4Zut7tjeDVEKHaR6N3uG4m1dl114UkLuK4SNAeHlAb4pKu5KEkMkI34Y8NmCc4ARfXIu25UGUhYBUzShDhbofA==", "requires": { "@floating-ui/react-dom": "^1.3.0", "aria-hidden": "^1.1.3", @@ -13253,6 +13259,11 @@ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.1.1.tgz", "integrity": "sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg==" }, + "tailwind-merge": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.10.0.tgz", + "integrity": "sha512-WFnDXSS4kFTZwjKg5/oZSGzBRU/l+qcbv5NVTzLUQvJ9yovDAP05h0F2+ZFW0Lw9EcgRoc2AfURUdZvnEFrXKg==" + }, "tailwindcss": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz", diff --git a/website/package.json b/website/package.json index 7285a08333..aea5a9daeb 100644 --- a/website/package.json +++ b/website/package.json @@ -30,7 +30,7 @@ "dependencies": { "@docsearch/css": "^3.1.0", "@docsearch/react": "^3.1.0", - "@floating-ui/react": "^0.20.1", + "@floating-ui/react": "^0.21.0", "@mdx-js/loader": "^2.1.1", "@mdx-js/react": "^2.1.1", "@next/mdx": "^12.1.5", @@ -45,6 +45,7 @@ "rehype-pretty-code": "^0.9.4", "remark-smartypants": "^2.0.0", "shiki": "^0.12.1", + "tailwind-merge": "^1.10.0", "unist-util-visit": "^2.0.3", "use-isomorphic-layout-effect": "^1.1.1" } diff --git a/website/pages/docs/FloatingArrow.mdx b/website/pages/docs/FloatingArrow.mdx index 82da88b8d5..20feae400e 100644 --- a/website/pages/docs/FloatingArrow.mdx +++ b/website/pages/docs/FloatingArrow.mdx @@ -196,7 +196,8 @@ The color of the arrow. default: `"none"{:js}` -The stroke (border) color of the arrow. +The stroke (border) color of the arrow. This must match (or be +less than) the floating element's border width. ```js diff --git a/website/pages/docs/react.mdx b/website/pages/docs/react.mdx index 94cd93dafa..3ac70a1b35 100644 --- a/website/pages/docs/react.mdx +++ b/website/pages/docs/react.mdx @@ -8,24 +8,17 @@ This allows you to create components such as tooltips, popovers, dropdown menus, hover cards, modal dialogs, select menus, comboboxes, and more. -## Library goals - -The goal of this library is to provide **building blocks** to -create your own floating UI components, handling difficult parts -like accessibility and positioning for you, but not offering -pre-built components. In theory, this allows you to build any -type of component you desire, since you can add your own custom -logic on top. - -If you're looking for flexibility and are comfortable building -your own components, this library is hopefully what you're -looking for. If instead, you're looking for something simpler and -ready-made, you will likely find other libraries better suited -for your use case. React component libraries that use Floating UI -behind the scenes for positioning include -[Radix UI](https://www.radix-ui.com/), -[Mantine](https://mantine.dev/), [Ariakit](https://ariakit.org/), -among others. +## Goals + +- **Provide building blocks to create your own floating UI + components**: pre-built components aren't exported, rather the + primitives necessary to create them. +- **Accessibility-first mindset**: the building blocks ensure + accessible user experiences and work with assistive technology. +- **Low-level, extremely configurable and flexible**: At the cost + of more code to setup, you get high control and flexibility, so + you can create "bespoke" and complex floating elements you + won't find in most component libraries. ## Install @@ -43,13 +36,6 @@ and uses this one as a dependency. npm install @floating-ui/react-dom ``` -This documentation refers to the latest version of the library. -If you find a feature is missing locally, make sure you have -upgraded to the latest version: - -- [Latest `@floating-ui/react` versions](https://www.npmjs.com/package/@floating-ui/react?activeTab=versions) -- [Latest `@floating-ui/react-dom` versions](https://www.npmjs.com/package/@floating-ui/react-dom?activeTab=versions) - ## Usage There are two main parts to creating floating elements: diff --git a/website/pages/docs/useClientPoint.mdx b/website/pages/docs/useClientPoint.mdx new file mode 100644 index 0000000000..763c9b81fe --- /dev/null +++ b/website/pages/docs/useClientPoint.mdx @@ -0,0 +1,135 @@ +# useClientPoint + +Positions the floating element at a given client point `(x, y)`, +usually generated by a mouse event. By default, the client's +mouse position is automatically tracked. + +```js +import {useClientPoint} from '@floating-ui/react'; +``` + +## Usage + +This hook is an interaction hook that returns event handler +props. + +To use it, pass it the `context{:.const}` object returned from +`useFloating(){:js}`, and then feed its result into the +`useInteractions(){:js}` array. The returned prop getters are +then spread onto the elements for rendering. + +```js {9-13} /context/ +function App() { + const [isOpen, setIsOpen] = useState(false); + + const {x, y, strategy, refs, context} = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const clientPoint = useClientPoint(context); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + clientPoint, + ]); + + return ( + <> +
+ Reference element +
+ {isOpen && ( +
+ Floating element +
+ )} + + ); +} +``` + +The default behavior is to follow the mouse cursor `clientX` and +`clientY` coordinates. + +## Pointer events + +If the floating element is not interactive, disable pointer +events: + +```css +.floating { + pointer-events: none; +} +``` + +This will ensure that the floating element does not block point +updates. + +## Props + +```ts +interface Props { + enabled?: boolean; + axis?: 'both' | 'x' | 'y'; + x?: number | null; + y?: number | null; +} +``` + +### enabled + +default: `true{:js}` + +Conditionally enable/disable the hook. + +```js +useClientPoint(context, { + enabled: false, +}); +``` + +### axis + +default: `'both'{:js}` + +Whether to restrict the client point to an axis and use the +reference element (if it exists) as the other axis. This can be +useful if the floating element is also interactive. + +```js +useClientPoint(context, { + axis: 'x', +}); +``` + +### x + +default: `null{:js}` + +An explicitly defined `x` client coordinate. + +```js +useClientPoint(context, { + x: 100, +}); +``` + +### y + +default: `null{:js}` + +An explicitly defined `y` client coordinate. + +```js +useClientPoint(context, { + y: 100, +}); +``` diff --git a/website/pages/docs/useDismiss.mdx b/website/pages/docs/useDismiss.mdx index d10bf3e1b4..b2a0f2b577 100644 --- a/website/pages/docs/useDismiss.mdx +++ b/website/pages/docs/useDismiss.mdx @@ -217,7 +217,7 @@ useDismiss(context, { ### bubbles -default: `true{:js}` +default: `undefined{:js}` When dealing with nested floating elements, this determines whether the dismissal bubbles through the entire @@ -244,9 +244,8 @@ Any omitted events will use the default value. ```js useDismiss(context, { bubbles: { - escapeKey: false, - // can be omitted, same as default - outsidePress: true, + escapeKey: true, // false by default + outsidePress: false, // true by default }, }); ``` diff --git a/website/pages/docs/useTypeahead.mdx b/website/pages/docs/useTypeahead.mdx index a237d98fe3..9da5f80373 100644 --- a/website/pages/docs/useTypeahead.mdx +++ b/website/pages/docs/useTypeahead.mdx @@ -89,6 +89,7 @@ interface Props { resetMs?: number; ignoreKeys?: Array; selectedIndex?: number | null; + onTypingChange?: (isTyping) => void; } ``` @@ -193,7 +194,7 @@ useTypeahead(context, { ### resetMs -default: `1000{:js}` +default: `750{:js}` Debounce timeout which will reset the transient string as the user types. @@ -230,11 +231,16 @@ useTypeahead(context, { }); ``` -## Data +### onTypingChange -### typing +default: no-op + +Callback invoked with the typing state as the user types. -This hook sets `context.dataRef.current.typing = boolean{:js}` to -determine if the user is currently typing. If a select option -needs to be selected with `Space`, but the typeahead also allows -spaces, this can be used to conditionally run the handler. +```js +useTypeahead(context, { + onTypingChange(isTyping) { + // ... + }, +}); +``` diff --git a/website/pages/index.js b/website/pages/index.js index cc1931303e..55ef0cdedd 100644 --- a/website/pages/index.js +++ b/website/pages/index.js @@ -1,11 +1,40 @@ import { + autoUpdate, + flip, + FloatingDelayGroup, + FloatingFocusManager, + FloatingNode, + FloatingPortal, + FloatingTree, getOverflowAncestors, + offset, + safePolygon, shift, + size, + useClick, + useDismiss, useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useFloatingTree, + useHover, + useInteractions, + useListNavigation, + useMergeRefs, + useRole, + useTransitionStyles, + useTypeahead, } from '@floating-ui/react'; +import classNames from 'classnames'; import cn from 'classnames'; import Head from 'next/head'; import Link from 'next/link'; +import { + Children, + cloneElement, + isValidElement, + useId, +} from 'react'; import { forwardRef, useCallback, @@ -13,16 +42,40 @@ import { useRef, useState, } from 'react'; -import {ArrowRight, GitHub} from 'react-feather'; +import { + ArrowRight, + BarChart, + Check, + ChevronRight, + Edit, + GitHub, + Heart, + Share, +} from 'react-feather'; import useIsomorphicLayoutEffect from 'use-isomorphic-layout-effect'; import Logo from '../assets/logo.svg'; import Text from '../assets/text.svg'; import {MINI_SPONSORS, SPONSORS} from '../data'; +import {Button} from '../lib/components/Button'; import {Cards} from '../lib/components/Cards'; import {Chrome} from '../lib/components/Chrome'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeading, + DialogTrigger, +} from '../lib/components/Dialog'; import {Floating} from '../lib/components/Floating'; import {Logos} from '../lib/components/Logos'; +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from '../lib/components/Popover'; import { Tooltip, TooltipContent, @@ -509,6 +562,831 @@ function Virtual() { ); } +function PopoverDemo() { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState('Balloon name'); + const [editName, setEditName] = useState(name); + + return ( +
+ + + + + +

Edit name

+
+ + setEditName(event.target.value) + } + maxLength={20} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setName(editName); + setIsOpen(false); + } + }} + /> + { + setName(editName); + }} + > + + +
+
+
+
+ ); +} + +const options = [ + 'Red', + 'Orange', + 'Yellow', + 'Green', + 'Cyan', + 'Blue', + 'Purple', + 'Pink', + 'Maroon', + 'Black', + 'White', +]; + +function ColorSwatch({color}) { + return ( +
+ ); +} + +function SelectDemo() { + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(null); + + const {x, y, strategy, refs, context} = useFloating({ + placement: 'bottom-start', + open: isOpen, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(5), + size({ + apply({rects, elements, availableHeight}) { + Object.assign(elements.floating.style, { + maxHeight: `${Math.max(200, availableHeight)}px`, + width: `${rects.reference.width}px`, + }); + }, + padding: 50, + }), + flip({ + padding: 50, + fallbackStrategy: 'initialPlacement', + }), + ], + }); + + const listRef = useRef([]); + const listContentRef = useRef(options); + + 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, + }); + + const {getReferenceProps, getFloatingProps, getItemProps} = + useInteractions([click, dismiss, role, listNav, typeahead]); + + const handleSelect = (index) => { + setSelectedIndex(index); + setIsOpen(false); + }; + + const selectedItemLabel = + selectedIndex !== null ? options[selectedIndex] : undefined; + + return ( +
+ + + {isOpen && ( + +
+ {options.map((value, i) => ( +
{ + listRef.current[i] = node; + }} + role="option" + tabIndex={i === activeIndex ? 0 : -1} + aria-selected={ + i === selectedIndex && i === activeIndex + } + className={classNames( + 'flex gap-2 items-center p-2 rounded outline-none cursor-default scroll-my-1', + { + 'bg-cyan-200 dark:bg-blue-500 dark:text-white': + i === activeIndex, + } + )} + {...getItemProps({ + // Handle pointer select. + onClick() { + handleSelect(i); + }, + // Handle keyboard select. + onKeyDown(event) { + if (event.key === 'Enter') { + event.preventDefault(); + handleSelect(i); + } + + // Only if not using typeahead. + if ( + event.key === ' ' && + !context.dataRef.current.typing + ) { + event.preventDefault(); + handleSelect(i); + } + }, + })} + > + + {value} + + {i === selectedIndex ? ( + + ) : ( + '' + )} + +
+ ))} +
+
+ )} +
+ ); +} + +const fruits = [ + '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', +]; + +const Item = forwardRef(({children, active, ...rest}, ref) => { + const id = useId(); + return ( +
+ {children} +
+ ); +}); + +function ComboboxDemo() { + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [activeIndex, setActiveIndex] = useState(null); + + const listRef = useRef([]); + + const {x, y, strategy, context, refs} = useFloating({ + whileElementsMounted: autoUpdate, + open, + onOpenChange: setOpen, + middleware: [ + offset(5), + size({ + apply({rects, elements, availableHeight}) { + Object.assign(elements.floating.style, { + maxHeight: `${Math.max(200, availableHeight)}px`, + width: `${rects.reference.width}px`, + }); + }, + padding: 50, + }), + flip({ + padding: 50, + fallbackStrategy: 'initialPlacement', + }), + ], + }); + + const {getReferenceProps, getFloatingProps, getItemProps} = + useInteractions([ + useRole(context, {role: 'listbox'}), + useDismiss(context), + useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true, + allowEscape: true, + }), + ]); + + function onChange(event) { + const value = event.target.value; + setInputValue(value); + + if (value) { + setOpen(true); + } else { + setOpen(false); + } + } + + const items = fruits.filter((item) => + item.toLowerCase().startsWith(inputValue.toLowerCase()) + ); + + return ( + <> + + {open && ( + +
+ {items.length === 0 && ( +

+ No fruits found. +

+ )} + {items.map((item, index) => ( + + {item} + + ))} +
+
+ )} + + ); +} + +export const MenuItem = forwardRef( + ({label, disabled, ...props}, ref) => { + return ( + + ); + } +); + +export const MenuComponent = forwardRef( + ({children, label, ...props}, forwardedRef) => { + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const [allowHover, setAllowHover] = useState(false); + const [hasFocusInside, setHasFocusInside] = useState(false); + + const listItemsRef = useRef([]); + const listContentRef = useRef([]); + + useEffect(() => { + const strings = []; + Children.forEach(children, (child) => { + if (isValidElement(child)) { + strings.push( + child.props.label && !child.props.disabled + ? child.props.label + : null + ); + } + }); + listContentRef.current = strings; + }); + + const tree = useFloatingTree(); + const nodeId = useFloatingNodeId(); + const parentId = useFloatingParentNodeId(); + const isNested = parentId != null; + + const {x, y, strategy, refs, context} = useFloating({ + 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 && allowHover, + delay: {open: 75}, + handleClose: safePolygon({ + restMs: 25, + blockPointerEvents: true, + }), + }); + const click = useClick(context, { + event: 'mousedown', + toggle: !isNested || !allowHover, + ignoreMouse: isNested, + }); + const role = useRole(context, {role: 'menu'}); + const dismiss = useDismiss(context, {bubbles: true}); + const listNavigation = useListNavigation(context, { + listRef: listItemsRef, + activeIndex, + nested: isNested, + onNavigate: setActiveIndex, + }); + const typeahead = useTypeahead(context, { + listRef: listContentRef, + 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. + useEffect(() => { + if (!tree) return; + + function handleTreeClick() { + setIsOpen(false); + } + + function onSubMenuOpen(event) { + 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]); + + useEffect(() => { + if (isOpen && tree) { + tree.events.emit('menuopen', {parentId, nodeId}); + } + }, [tree, isOpen, nodeId, parentId]); + + // Determine if "hover" logic can run based on the modality of input. This + // prevents unwanted focus synchronization as menus open and close with + // keyboard navigation and the cursor is resting on the menu. + useEffect(() => { + function onPointerMove({pointerType}) { + if (pointerType !== 'touch') { + setAllowHover(true); + } + } + + function onKeyDown() { + setAllowHover(false); + } + + window.addEventListener('pointermove', onPointerMove, { + once: true, + capture: true, + }); + window.addEventListener('keydown', onKeyDown, true); + return () => { + window.removeEventListener( + 'pointermove', + onPointerMove, + { + capture: true, + } + ); + window.removeEventListener('keydown', onKeyDown, true); + }; + }, [allowHover]); + + const {isMounted, styles} = useTransitionStyles(context, { + duration: 100, + }); + + const referenceRef = useMergeRefs([ + refs.setReference, + forwardedRef, + ]); + const referenceProps = getReferenceProps({ + ...props, + onFocus(event) { + props.onFocus?.(event); + setHasFocusInside(false); + }, + onClick(event) { + event.stopPropagation(); + }, + ...(isNested && { + // Indicates this is a nested
acting as a . + role: 'menuitem', + }), + }); + + return ( + + + + {isMounted && ( + +
+ {Children.map( + children, + (child, index) => + isValidElement(child) && + cloneElement( + child, + getItemProps({ + tabIndex: activeIndex === index ? 0 : -1, + ref(node) { + listItemsRef.current[index] = node; + }, + onClick(event) { + child.props.onClick?.(event); + tree?.events.emit('click'); + }, + onFocus(event) { + child.props.onFocus?.(event); + setHasFocusInside(true); + }, + // Allow focus synchronization if the cursor did not move. + onMouseEnter() { + if (allowHover && isOpen) { + setActiveIndex(index); + } + }, + }) + ) + )} +
+
+ )} +
+
+ ); + } +); + +export const Menu = forwardRef((props, ref) => { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +}); + function HomePage() { const bannerRef = useRef(); const [hideBanner, setHideBanner] = useState(true); @@ -582,20 +1460,19 @@ function HomePage() { GitHub
-
- -
-
-

- A JavaScript library for{' '} - anchor positioning — anchor a{' '} +

+ A JavaScript library to{' '} + position and + create{' '} + interactions{' '} + for{' '} - floating element + floating elements @@ -613,14 +1490,22 @@ function HomePage() { flow of content, like this one!

- {' '} - next to another element while making sure it stays in - view optimally. This lets you position tooltips, - popovers, or dropdowns to efficiently float on top of - the UI! + +

+
+ +
+
+

+ Advanced anchor positioning. +

+

+ Anchor a floating element next to another element + while making sure it stays in view by{' '} + avoiding collisions. This lets you + position tooltips, popovers, or dropdowns optimally.

-
@@ -630,9 +1515,209 @@ function HomePage() {
+
+

+ Interactions for React. +

+

+ Build your own floating UI components with React. + From simple tooltips to select menus, you have full + control while ensuring{' '} + fully accessible UI experiences. +

+
+ +
+
+ +
+

+ Tooltips +

+

+ Floating elements that display information + related to an anchor element on hover or focus. +

+
+
+ + + + + Like + + + + + + Share + + + + + + Stats + + + + + + Edit + +
+
+
+ +
+ +
+

+ Popovers +

+

+ Floating elements that display an anchored + interactive dialog on click. +

+
+
+ +
+
+
+ +
+ +
+

+ Select Menus +

+

+ Floating elements that display a list of + options to choose from on click. +

+
+
+ +
+
+
+ +
+ +
+

+ Comboboxes +

+

+ Floating elements that combine an input and a + list of searchable options to choose from. +

+
+
+ +
+
+
+ +
+ +
+

+ Dropdown Menus +

+

+ Floating elements that display a list of + buttons that perform an action. +

+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+

+ Dialogs +

+

+ A floating modal window overlaid on the UI, + rendering the content underneath inert. +

+
+
+ + + + + + + Delete balloon + + + Are you sure you want to delete? + +
+ + Confirm + + + Cancel + +
+
+
+
+
+
+
+ +
+ + Use Floating UI with React{' '} + + +
+
-

- Light as a feather. +

+ Modern, tree-shakeable modules.

This positioning toolkit has a platform-agnostic 0.6 @@ -704,31 +1789,6 @@ function HomePage() {

-
-

- Interactions for React. -

-

- In addition to positioning, there are also - interaction primitives to build floating UI - components with React. This includes event hooks for - hover, focus or click, modal and non-modal focus - management, keyboard list navigation, typeahead, - portals, backdrop overlays, screen reader support, - and more. -

- - Use Floating UI with React{' '} - - -
-

Support Floating UI!