Jerome Marshall
Jerome Marshall

Reputation: 353

Group Disclosures (Accordian) from Headless UI

I've just started using Headless UI. I'm trying to use the Disclosure component from Headless UI to render my job experiences. Basically, I need "n" number of Disclosures which will be dynamically rendered and whenever one Disclosure is opened the others should close.

I am able to render the Disclosures dynamically, and they all have their individual states. (opening/closing a disclosure doesn't affect the other Disclosure). All I want to do is to have only one disclosure open at a time. Opening another Disclosure should close all the remaining Disclosures. I have gone through their docs but couldn't find a way to manage multiple Disclosure states together.

Here is my code:

    import React, { useContext } from "react";
    import { GlobalContext } from "../data/GlobalContext";
    import { Tab, Disclosure } from "@headlessui/react";
    import ReactMarkdown from "react-markdown";

    const Experience = () => {
      const { data } = useContext(GlobalContext);
      const expData = data.pageContent.find(
        (content) => content.__component === "page-content.experience-page-content"
      );

      return (
        <div className="container h-screen">
          <div className="flex h-full flex-col items-center justify-center">
            <h3 className="">{expData.pageTitle}</h3>
            <div className="flex min-h-[600px] flex-col">

              {expData.jobs.map((job, i) => (
                <Disclosure key={job.companyName} defaultOpen={i === 0}>
                  <Disclosure.Button
                    key={job.companyName + "_tab"}
                    className="px-4 py-3 dark:text-dark-primary"
                  >
                    {job.companyName}
                  </Disclosure.Button>
                  <Disclosure.Panel key={job.companyName + "_panel"}>
                    <p className="">
                      <span className="">{job.designation}</span>
                      <span className="">{" @ "}</span>
                      <span className="">{job.companyName}</span>
                    </p>
                    <p className="">{job.range}</p>
                    <ReactMarkdown className="">
                      {job.workDescription}
                    </ReactMarkdown>
                  </Disclosure.Panel>
                </Disclosure>
              ))}

            </div>
          </div>
        </div>
      );
    };

    export default Experience;

It would be really helpful if someone could help me with this. Thanks.

Upvotes: 10

Views: 10811

Answers (7)

Passing By
Passing By

Reputation: 1

If anyone is still looking for a quick hack for vuejs

  1. add a "@click=closeOpenTabs" to the DisclosureButton
  2. declare function:
const closeOpenTabs = () => document
    .querySelectorAll('button[data-headlessui-state="open"]')
    .forEach((b) => b.click());

Upvotes: 0

muzafako
muzafako

Reputation: 321

If you have multiple disclosures in different components here a tricky solution without using ref or useState

https://github.com/tailwindlabs/headlessui/discussions/475#discussioncomment-7638849

Upvotes: 0

Minuth Prom
Minuth Prom

Reputation: 1

I solved it by using this simple approach.

  1. Create a function to handle the disclosure button click

const handleDisclosureButtonClick = (event) => {
  const disclosureButtons =
    document.getElementsByClassName("disclosure-button");
  for (let i = 0; i < disclosureButtons.length; i++) {
    const disclosureButton = disclosureButtons.item(i);
    if (
      disclosureButton !== event.target &&
      disclosureButton.getAttribute("data-headlessui-state") === "open"
    ) {
      disclosureButton.click();
    }
  }
};

  1. Add a class name disclosure-button for query and event handler to the disclosure button

<Disclosure.Button className="disclosure-button" onClick={handleDisclosureButtonClick}>
  button
</Disclosure.Button>

Upvotes: 0

Juliana Gil
Juliana Gil

Reputation: 228

I had the same issue and solved by using the Disclosure internal render props and some external state. The code below makes each disclosure to toggle itself and close others when opended (if any).

export default function MyAccordion({ data }) {

  const [activeDisclosurePanel, setActiveDisclosurePanel] = useState(null);

  function togglePanels(newPanel) {

    if (activeDisclosurePanel) {
      if (activeDisclosurePanel.key !== newPanel.key && activeDisclosurePanel.open) {
        activeDisclosurePanel.close();
      }
    }

    setActiveDisclosurePanel({
      ...newPanel, 
      open: !newPanel.open
    });
  }

  return (
    <ul>
      {
        data.map((item, index) => (
          <Disclosure as="li" key={ index }>
            {
              (panel) => {
                const { open, close } = panel
                return (<>
                  <Disclosure.Button onClick={ () => {                        
                    if (!open) {
                      // On the first click, the panel is opened but the "open" prop's value is still false. Therefore the falsey verification
                      // This will make so the panel close itself when we click it while open 
                      close(); 
                    }

                    // Now we call the function to close the other opened panels (if any)
                    togglePanels({ ...panel, key: index });
                  }}>
                  </Disclosure.Button>
                  <Disclosure.Panel>
                    { item }
                  </Disclosure.Panel>
                </>)
              }
            }
          </Disclosure>
        ))
      }
    </ul>
  );
}

Upvotes: 10

Alejandro Rodriguez P.
Alejandro Rodriguez P.

Reputation: 1168

Ok, I stole from various sources and managed to hack it. I haven't tested it for accessibility but it has some interesting things because it deviates a little bit (rather usefully if you ask me) from the React mental model.

The tldr is that you will need to trigger clicks on the other elements imperatively via ref.current?.click()

Here are the steps:

1) Create the refs:

Here we can't use hooks since you can't call hooks inside loops or conditionals, we use React.createRef<HTMLButtonElement>() instead

  const refs = React.useMemo(() => {
    return (
      items.map(() => {
        return React.createRef<HTMLButtonElement>();
      }) ?? []
    );
  }, [items]);

2) Add the corresponding ref to the Disclosure.Button component

{items.map((item, idx) => (
  <Disclosure key={item.id}>
   {({open}) => (
      <>
        {/* other relevant stuff */}
        <Disclosure.Button ref={refs[idx]}>
          Button
        </Disclosure.Button>
        <Disclosure.Panel>
        {/* more stuff */}
        </Disclosure.Panel> 
      </>
   )}
  </Disclosure>)
)}

3) Use data attributes (for making it easy on yourself)

this one is gonna be specially useful for the next step

{items.map((item, idx) => (
  <Disclosure key={item.id}>
   {({open}) => (
      <>
        {/* other relevant stuff */}
        <Disclosure.Button 
          ref={refs[idx]}
          data-id={item.id}
          data-open={open}
         >
          Button
        </Disclosure.Button>
        <Disclosure.Panel>
        {/* more stuff */}
        </Disclosure.Panel> 
      </>
   )}
  </Disclosure>)
)}

4) define your handleClosingOthers function (an onClick handler)

Basically here we get all the buttons that aren't the one that the user is clicking, verifying if they are open and if they are, clicking programmatically on them to close them.

function handleClosingOthers(id: string) {
  const otherRefs = refs.filter((ref) => {
    return ref.current?.getAttribute("data-id") !== id;
  });

  otherRefs.forEach((ref) => {
    const isOpen = ref.current?.getAttribute("data-open") === "true";

    if (isOpen) {
      ref.current?.click();
    }
  });
}

5) finally we add that function to the onClick handler

{items.map((item, idx) => (
  <Disclosure key={item.id}>
   {({open}) => (
      <>
        {/* other relevant stuff */}
        <Disclosure.Button 
          ref={refs[idx]}
          data-id={item.id}
          data-open={open}
          onClick={() => handleClosingOthers(item.id)}
         >
          Button
        </Disclosure.Button>
        <Disclosure.Panel>
        {/* more stuff */}
        </Disclosure.Panel> 
      </>
   )}
  </Disclosure>)
)}

Upvotes: 2

A. Ahmad
A. Ahmad

Reputation: 536

I've used this approach:

function Akkordion({ items }) {
  const buttonRefs = useRef([]);
  const openedRef = useRef(null);

  const clickRecent = (index) => {
    const clickedButton = buttonRefs.current[index];
    if (clickedButton === openedRef.current) {
      openedRef.current = null;
      return;
    }
    if (Boolean(openedRef.current?.getAttribute("data-value"))) {
      openedRef.current?.click();
    }
    openedRef.current = clickedButton;
  };

  return (
    <div>
      {items.map((item, idx) => (
        <Disclosure key={item.id}>
          {({ open }) => (
            <div>
              <Disclosure.Button as="div">
                <button
                  data-value={open}
                  ref={(ref) => {
                    buttonRefs.current[idx] = ref;
                  }}
                  onClick={() => clickRecent(idx)}
                >
                  {item.label}
                </button>
              </Disclosure.Button>
              <Disclosure.Panel
              >
                {item.content}
              </Disclosure.Panel>
            </div>
          )}
        </Disclosure>
      ))}
    </div>
  );
}

Upvotes: 0

Jam Wong
Jam Wong

Reputation: 31

<template>
  <div class="mx-auto w-full max-w-md space-y-3 rounded-2xl bg-white p-2">
    <Disclosure v-for="(i, idx) in 3" :key="i" v-slot="{ open, close }">
      <DisclosureButton
        :ref="el => (disclosure[idx] = close)"
        class="flex w-full justify-between rounded-lg bg-purple-100 px-4 py-2 text-left text-sm font-medium text-purple-900 hover:bg-purple-200 focus:outline-none focus-visible:ring focus-visible:ring-purple-500 focus-visible:ring-opacity-75"
        @click="hideOther(idx)"
      >
        <span> What is your refund policy? {{ open }} </span>
      </DisclosureButton>

      <DisclosurePanel class="px-4 pt-4 pb-2 text-sm text-gray-500">
        If you're unhappy with your purchase for any reason, email us within 90 days and we'll
        refund you in full, no questions asked.
      </DisclosurePanel>
    </Disclosure>
  </div>
</template>

<script setup>
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'

const disclosure = ref([])

const hideOther = id => {
  disclosure.value.filter((d, i) => i !== id).forEach(c => c())
}
</script>

here how I did it in Vue.

Upvotes: 3

Related Questions