flowbite-react icon indicating copy to clipboard operation
flowbite-react copied to clipboard

Dropdown: Event callbacks and and floating props please

Open lancegliser opened this issue 1 year ago • 2 comments

  • [X] I have searched the Issues to see if this bug has already been reported
  • [X] I have tested the latest version

Summary

Describe how it should work, and provide examples of the solution, which might include screenshots or code snippets.

<Dropdown /> should have exposed just a few properties and I'd have not ended up double bag it.

I think I'd need:

  • On close callback
  • Option to use <FloatingPortal />
  • useBaseFloating, useFloatingInteractions
  • Base button

Context

What are you trying to accomplish? How is your use case affected by not having this feature?

I've needed to implement a context menu (triple dot) as the last column in a table's row. The last row's <Dropdown /> can have it's elements cut off by the table (or any container's) overflow, or worse cause scrolling if it's overflow-x-auto. I need a way to remove it from dom, portaling, floating etc to achieve the below:

image

It should have been so much easier.

Here's a quick hack I'm using to get around the current implementation:

import { FloatingPortal, useFloating } from "@floating-ui/react";
import { Dropdown, type DropdownProps } from "flowbite-react";
import { useEffect, useId, useState, type FC, type ReactNode } from "react";
import { useBaseFloating } from "../../../hooks";

export type DropdownPortalProps = Omit<DropdownProps, "label" | "trigger"> & {
  /** Elements to act as trigger. You must supply your own <button>, unlike standard <Dropdown label /> */
  label: ReactNode;
  "data-testid"?: string;
};
export const DropdownPortal: FC<DropdownPortalProps> = ({
  label,
  ...props
}) => {
  const { refs, floatingStyles } = useBaseFloating({
    placement: "bottom",
  });
  const id = useId();

  const [isOpen, setIsOpen] = useState(false);
  const handleOpen = () => {
    setIsOpen(true);
    setDropdownState(true);
  };
  const handleClose = () => {
    setIsOpen(false);
    setDropdownState(false);
  };
  const setDropdownState = (isOpen: boolean) => {
    const dropdownTrigger = refs.floating.current?.querySelector("button");
    if (!dropdownTrigger) return;

    const isExpanded = dropdownTrigger.getAttribute("aria-expanded") === "true";
    if (isExpanded !== isOpen) {
      dropdownTrigger.click();
    }
  };

  // Create a click outside listener sync between the trigger's state and our isOpen.
  // Click outside events can cause the dropdown to fire, without us being made aware.
  // <Dropdown /> offers no event hooks to broadcast it's state handling this.
  useEffect(() => {
    if (!isOpen) return;

    const onDocumentClick = (event: MouseEvent) => {
      const reference = refs.reference.current;
      const floating = refs.floating.current;
      if (!reference || !floating) return;

      if (
        // @ts-expect-error Meh
        !reference.contains(event.target) &&
        // @ts-expect-error Meh
        !floating.contains(event.target)
      ) {
        setIsOpen(false);
      }
    };
    document.addEventListener("click", onDocumentClick);
    return () => {
      document.removeEventListener("click", onDocumentClick);
    };
  }, [isOpen, refs.floating, refs.reference]);

  return (
    <>
      <div
        aria-controls={id}
        aria-haspopup="menu"
        aria-expanded={isOpen}
        ref={refs.setReference}
        onClick={isOpen ? handleClose : handleOpen}
        data-testid="flowbite-dropdown-portal"
      >
        {label}
      </div>
      <FloatingPortal>
        <div
          aria-expanded={isOpen}
          data-testid="flowbite-dropdown-portal"
          id={id}
          ref={refs.setFloating}
          style={floatingStyles}
        >
          <Dropdown
            label={null}
            placement="bottom"
            inline
            arrowIcon={false}
            {...props}
          />
        </div>
      </FloatingPortal>
    </>
  );
};

lancegliser avatar Dec 03 '24 21:12 lancegliser

If it helps, here's the story file for reproducing:

import type { Meta, StoryObj } from "@storybook/react";
import { Dropdown } from "flowbite-react";
import { PrimaryButton } from "../../Buttons";
import { ViewIcon } from "../../Icons";
import { DropdownPortal } from "./DropdownPortal";

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
const meta: Meta<typeof DropdownPortal> = {
  component: DropdownPortal,
  // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/7.0/react/writing-docs/docs-page
  tags: ["autodocs"],
  parameters: {
    // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
    layout: "centered",
  },
  // More on argTypes: https://storybook.js.org/docs/react/api/argtypes
  argTypes: {},
  args: {
    children: (
      <>
        <Dropdown.Item icon={ViewIcon}>A</Dropdown.Item>
        <Dropdown.Item icon={ViewIcon}>B</Dropdown.Item>
        <Dropdown.Item icon={ViewIcon}>C</Dropdown.Item>
        <Dropdown.Item icon={ViewIcon}>D</Dropdown.Item>
        <Dropdown.Item icon={ViewIcon}>F</Dropdown.Item>
      </>
    ),
    label: <PrimaryButton>Dropdown</PrimaryButton>,
  },
};

export default meta;
type Story = StoryObj<typeof DropdownPortal>;

// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
export const Default: Story = {
  // More on args: https://storybook.js.org/docs/react/writing-stories/args
  args: {},
};

export const OverflowXAuto: Story = {
  args: {},
  render: (args) => (
    <div className="h-8 w-40 overflow-x-auto bg-orange-500">
      <DropdownPortal {...args} />
    </div>
  ),
};

lancegliser avatar Dec 03 '24 21:12 lancegliser

Any updates to this or workarounds. Facing this issue in table action button.

using flowbite-react table and dropdown component.

Image

QBT-VinayAdiga avatar Oct 24 '25 05:10 QBT-VinayAdiga