Floating UI Practical Guide - Dialog

发表于 2023-06-16 15:11 1453 字 8 min read

cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

本文分享了如何使用 @floating-ui/react 库创建一个功能完整的可访问对话框(Dialog)组件,支持模态、背景遮罩、ESC键和外部点击关闭、焦点管理以及无障碍(ARIA)属性。组件通过 useFloating、useClick、useDismiss、useRole 等 Hook 实现交互逻辑,并结合 FloatingPortal、FloatingOverlay 和 FloatingFocusManager 实现居中显示、背景变暗、焦点捕获和可访问性控制。

This article has been machine-translated from Chinese. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.

Previous article: Floating UI Practical Guide - Popover

In this article, I’ll share how to use Floating UI to create another common floating UI component — Dialog. A Dialog is a floating element that displays information requiring immediate attention. It appears over the page content and blocks interaction with the page until it is dismissed.

It has similar interactions to a popover, but with two key differences:

  • It is modal and renders a backdrop behind the dialog, dimming the background content and making the rest of the page inaccessible.
  • It is centered in the viewport and not anchored to any specific reference element.

An accessible dialog component has the following key points:

  • Dismissal: It closes when the user presses the esc key or clicks outside the open dialog.
  • Role: The element is given relevant roles and ARIA attributes so screen readers can access it.
  • Focus management: Focus is completely trapped within the dialog and must be dismissed by the user.

Target Component

Goal: Implement a Dialog Demo like this:

Pasted image 20230616145507

Next, we need to create a React component called Dialog that uses the @floating-ui/react library to create an interactive floating dialog. Here’s the design for the component:

Component Props

The Dialog component needs to accept the following props:

  • rootId: The root element for the floating element, optional.
  • open: A boolean controlling whether the dialog is open.
  • initialOpen: Whether the dialog is initially open, defaults to false.
  • onOpenChange: A callback function called when the dialog’s open state changes, accepting a boolean parameter.
  • render: A function that accepts an object parameter containing a close method for closing the dialog. This function returns the React node to render inside the dialog.
  • className: CSS class name applied to the dialog.
  • overlayClass: CSS class name applied to the floating overlay.
  • containerClass: CSS class name applied to the dialog container.
  • isDismiss: A boolean determining whether clicking outside the dialog closes it, defaults to true.
  • children: React child elements, can be a button that opens the dialog when clicked.
  • showCloseButton: A boolean determining whether to show a close button, defaults to true.

Component Functionality

The main functionality of the Dialog component is to create an interactive floating dialog that can be closed by clicking the close button or clicking outside the dialog area. The dialog’s open and close state can be controlled via the open and onOpenChange props (controlled mode), or managed automatically via internal state (uncontrolled mode).

The Dialog component uses several hooks from the @floating-ui/react library:

  • useFloating: Used to manage the dialog’s open and close state.
  • useClick, useRole, and useDismiss: Used to handle dialog interactions such as clicks and role management.
  • useInteractions: Used to get and set interaction properties.

Additionally, the Dialog component uses FloatingPortal, FloatingOverlay, and FloatingFocusManager components to create the floating dialog UI.

Complete Code

Combining all of the above, we can write a fairly complete Dialog example that supports custom overlay styling, inner element styling, control over whether clicking the overlay closes the dialog, and can even be combined with Framer Motion for dialog animations (I’ll write an article about that if I get the chance).

import {
  FloatingFocusManager,
  FloatingOverlay,
  FloatingPortal,
  useClick,
  useDismiss,
  useFloating,
  useInteractions,
  useRole,
} from '@floating-ui/react';
import clsx from 'clsx';
import React, { cloneElement, useState } from 'react';
import { CgClose } from 'react-icons![](file:///C:\Users\34504\AppData\Roaming\Tencent\QQTempSys\3)6GH))S[9A2G57O0%MM45V.gif)';

type DialogProps = {
  rootId?: string;
  open?: boolean;
  initialOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children?: JSX.Element;
  render: (props: { close: () => void }) => React.ReactNode;
  className?: string;
  overlayClass?: string;
  containerClass?: string;
  isDismiss?: boolean;
  showCloseButton?: boolean;
};

export default function Dialog({
  initialOpen = false,
  open: controlledOpen,
  onOpenChange: setControlledOpen,
  children,
  className,
  render,
  rootId: customRootId,
  overlayClass,
  containerClass,
  showCloseButton = true,
  isDismiss = true,
}: DialogProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen);
  const open = controlledOpen ?? uncontrolledOpen;
  const setOpen = setControlledOpen ?? setUncontrolledOpen;

  const { reference, floating, context } = useFloating({
    open,
    onOpenChange: setOpen,
  });

  const click = useClick(context);
  const role = useRole(context);
  const dismiss = useDismiss(context, { enabled: isDismiss, outsidePressEvent: 'mousedown' });

  const { getReferenceProps, getFloatingProps } = useInteractions([click, role, dismiss]);

  const onClose = () => setOpen(false);

  return (
    <>
      {children && cloneElement(children, getReferenceProps({ ref: reference, ...children.props }))}
      <FloatingPortal id={customRootId}>
        {open && (
          <FloatingOverlay
            className={clsx('absolute inset-0 z-10 flex h-full w-full items-center', overlayClass ?? 'bg-black/60')}
            lockScroll
          >
            <div className={clsx('m-auto grid place-items-center', containerClass)}>
              <FloatingFocusManager context={context}>
                <div
                  className={clsx('relative overflow-hidden rounded-md bg-white', className ?? 'mx-24')}
                  ref={floating}
                  {...getFloatingProps()}
                >
                  {showCloseButton && <CgClose className="absolute right-2 top-2 h-6 w-6 cursor-pointer" onClick={onClose} />}
                  {render({ close: onClose })}
                </div>
              </FloatingFocusManager>
            </div>
          </FloatingOverlay>
        )}
      </FloatingPortal>
    </>
  );
}

Basic Dialog Hooks

Official example: CodeSandbox demo

This example demonstrates how to create a dialog for a single instance to familiarize yourself with the basics.

Let’s look at this example:

Open state

import { useState } from 'react';

function Dialog() {
  const [isOpen, setIsOpen] = useState(false);
}

isOpen determines whether the dialog is currently open on the screen. It is used for conditional rendering.

useFloating hook

useFloating hook

The useFloating() hook provides context for our dialog. We need to pass some information:

  • open: The open state from our useState() hook above.
  • onOpenChange: A callback function called when the dialog opens or closes. We’ll use it to update our isOpen state.
import { useFloating } from '@floating-ui/react';

function Dialog() {
  const [isOpen, setIsOpen] = useState(false);

  const { refs, context } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
  });
}

Interaction hooks

Interaction hooks

  • useClick() adds the ability to open or close the dialog when clicking the reference element. However, a dialog may not be attached to a reference element, so this is optional. (Typically dialogs are portaled independently, meaning the context is the body.)
  • useDismiss() adds the ability to close the dialog when the user presses esc or clicks outside the dialog. Its outsidePressEvent option can be set to 'mousedown' so that touch events become lazy and don’t pass through the backdrop, since the default behavior is eager.
  • useRole() adds the correct ARIA attributes for dialog to the dialog and reference elements.

Finally, useInteractions() merges all their props into prop getters, which can then be used for rendering.

import {
  // ...
  useClick,
  useDismiss,
  useRole,
  useInteractions,
  useId,
} from '@floating-ui/react';

function Dialog() {
  const [isOpen, setIsOpen] = useState(false);

  const { refs, context } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
  });

  const click = useClick(context);
  const dismiss = useDismiss(context, {
    outsidePressEvent: 'mousedown',
  });
  const role = useRole(context);

  // Merge all the interactions into prop getters
  const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);

  // Set up label and description ids
  const labelId = useId();
  const descriptionId = useId();
}

Rendering

Rendering

Now that we’ve set up all our variables and hooks, we can render our elements.

function Dialog() {
  // ...
  return (
    <>
      <button ref={refs.setReference} {...getReferenceProps()}>
        Reference element
      </button>
      {isOpen && (
        <FloatingOverlay lockScroll style={{ background: 'rgba(0, 0, 0, 0.8)' }}>
          <FloatingFocusManager context={context}>
            <div ref={refs.setFloating} aria-labelledby={labelId} aria-describedby={descriptionId} {...getFloatingProps()}>
              <h2 id={labelId}>Heading element</h2>
              <p id={descriptionId}>Description element</p>
              <button onClick={() => setIsOpen(false)}>Close</button>
            </div>
          </FloatingFocusManager>
        </FloatingOverlay>
      )}
    </>
  );
}
  • / {...getFloatingProps()} As mentioned in the previous article, these spread props from the interaction hooks onto the relevant elements. They include props such as onClick, aria-expanded, etc.

FloatingPortal & FloatingOverlay & FloatingFocusManager

  • <FloatingOverlay /> is a component that renders a backdrop overlay element behind the floating element, with the ability to lock body scrolling. FloatingOverlay docs
    • Provides a fixed base style that dims the background content and blocks pointer events behind the floating element.
    • It’s a regular <div/>, so it can be styled with any CSS solution.
  • <FloatingFocusManager />
    • FloatingPortal docs A component that manages and controls focus for floating elements on the page.
    • Automatically detects focus changes, adjusts the position and state of floating elements on the page, ensuring accessibility and usability of all elements.
    • By default, it typically traps focus inside.
    • It should directly wrap the floating element and only be rendered when the dialog is also rendered. FloatingFocusManager docs
      • <FloatingPortal /> teleports the floating element into a given container element — by default, outside the application root and into the body.
      • The root can be customized, meaning you can select a node with an id, or create one and append it to the specified root (body).
import { FloatingPortal } from '@floating-ui/react';

function Tooltip() {
  return (
    isOpen && (
      <FloatingPortal>
        <div>Floating element</div>
      </FloatingPortal>
    )
  );
}

喜欢的话,留下你的评论吧~

© 2020 - 2026 cos @cosine
Powered by theme astro-koharu · Inspired by Shoka