diff --git a/.changeset/tender-singers-notice.md b/.changeset/tender-singers-notice.md new file mode 100644 index 0000000000..4e837ddb01 --- /dev/null +++ b/.changeset/tender-singers-notice.md @@ -0,0 +1,8 @@ +--- +"@heroui/disclosure": patch +"@heroui/accordion": patch +"@heroui/react": patch +"@heroui/theme": patch +--- + +Adding the disclosure component and updating the api for the Accordion. diff --git a/apps/docs/app/examples/perf/client-page.tsx b/apps/docs/app/examples/perf/client-page.tsx index 4e3e27c629..d3b15d7dbb 100644 --- a/apps/docs/app/examples/perf/client-page.tsx +++ b/apps/docs/app/examples/perf/client-page.tsx @@ -479,7 +479,7 @@ export default function HeroUIPerf() { - + Non est aliqua tempor occaecat laborum. Lorem culpa minim irure mollit. Est qui reprehenderit commodo magna proident anim ipsum ex. Mollit id amet officia nisi excepteur eu. Commodo elit commodo nisi nisi aute eu aliquip aliquip voluptate exercitation ullamco @@ -497,7 +497,7 @@ export default function HeroUIPerf() { elit consequat ea id. Lorem ea qui sunt enim occaecat excepteur officia ex consequat nostrud. Tempor sint Lorem est culpa do. - + Non est aliqua tempor occaecat laborum. Lorem culpa minim irure mollit. Est qui reprehenderit commodo magna proident anim ipsum ex. Mollit id amet officia nisi excepteur eu. Commodo elit commodo nisi nisi aute eu aliquip aliquip voluptate exercitation ullamco @@ -505,7 +505,7 @@ export default function HeroUIPerf() { Pariatur ullamco cillum proident aliqua nostrud. Labore ea veniam cillum duis veniam in cupidatat voluptate eu officia. Ut laborum sunt nostrud magna. Ex magna esse cillum enim - + Non est aliqua tempor occaecat laborum. Lorem culpa minim irure mollit. Est qui reprehenderit commodo magna proident anim ipsum ex. Mollit id amet officia nisi excepteur eu. Commodo elit commodo nisi nisi aute eu aliquip aliquip voluptate exercitation ullamco diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json index f1ec7ce7b2..9441427286 100644 --- a/apps/docs/config/routes.json +++ b/apps/docs/config/routes.json @@ -260,6 +260,13 @@ "keywords": "date-range-picker, date-picker, time, input, timezone", "path": "/docs/components/date-range-picker.mdx" }, + { + "key": "disclosure", + "title": "Disclosure", + "keywords": "disclosure, collapse, expandable sections, content hiding", + "path": "/docs/components/disclosure.mdx", + "newPost": true + }, { "key": "divider", "title": "Divider", diff --git a/apps/docs/content/components/accordion/bordered-variant.raw.jsx b/apps/docs/content/components/accordion/bordered-variant.raw.jsx index 44df244c55..ce6e31b013 100644 --- a/apps/docs/content/components/accordion/bordered-variant.raw.jsx +++ b/apps/docs/content/components/accordion/bordered-variant.raw.jsx @@ -6,13 +6,13 @@ export default function App() { return ( - + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/compact.raw.jsx b/apps/docs/content/components/accordion/compact.raw.jsx index 396c84d290..cdab8e8c96 100644 --- a/apps/docs/content/components/accordion/compact.raw.jsx +++ b/apps/docs/content/components/accordion/compact.raw.jsx @@ -6,13 +6,13 @@ export default function App() { return ( - + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/controlled.raw.jsx b/apps/docs/content/components/accordion/controlled.raw.jsx index c2cc812049..cb39e8cfd4 100644 --- a/apps/docs/content/components/accordion/controlled.raw.jsx +++ b/apps/docs/content/components/accordion/controlled.raw.jsx @@ -7,14 +7,14 @@ export default function App() { "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; return ( - - + + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/controlled.raw.tsx b/apps/docs/content/components/accordion/controlled.raw.tsx index 4119cc2397..a66e4a1904 100644 --- a/apps/docs/content/components/accordion/controlled.raw.tsx +++ b/apps/docs/content/components/accordion/controlled.raw.tsx @@ -9,14 +9,14 @@ export default function App() { "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; return ( - - + + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/custom-motion.raw.jsx b/apps/docs/content/components/accordion/custom-motion.raw.jsx index 92315c7acd..019019866d 100644 --- a/apps/docs/content/components/accordion/custom-motion.raw.jsx +++ b/apps/docs/content/components/accordion/custom-motion.raw.jsx @@ -3,55 +3,19 @@ import {Accordion, AccordionItem} from "@heroui/react"; export default function App() { const defaultContent = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + const classNames = { + content: "ease-soft-spring", + }; return ( - - + + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/custom-styles.raw.jsx b/apps/docs/content/components/accordion/custom-styles.raw.jsx index bd33dd3fb5..68b0505856 100644 --- a/apps/docs/content/components/accordion/custom-styles.raw.jsx +++ b/apps/docs/content/components/accordion/custom-styles.raw.jsx @@ -207,13 +207,13 @@ export default function App() { return ( } subtitle={

@@ -225,8 +225,9 @@ export default function App() { {defaultContent} } subtitle="3 apps have read permissions" title="Apps Permissions" @@ -234,9 +235,9 @@ export default function App() { {defaultContent} } subtitle="Complete your profile" title="Pending tasks" @@ -244,9 +245,9 @@ export default function App() { {defaultContent} } subtitle="Please, update now" title={ diff --git a/apps/docs/content/components/accordion/default-expanded-keys.raw.jsx b/apps/docs/content/components/accordion/default-expanded-keys.raw.jsx index 44fc6e7362..779139e707 100644 --- a/apps/docs/content/components/accordion/default-expanded-keys.raw.jsx +++ b/apps/docs/content/components/accordion/default-expanded-keys.raw.jsx @@ -6,32 +6,22 @@ export default function App() { return ( - + {defaultContent} - Press to expand key 2 + Press to expand id 2 } title="Accordion 2" > {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/disabled-keys.raw.jsx b/apps/docs/content/components/accordion/disabled-keys.raw.jsx index 12fce39c24..6bd9dc35df 100644 --- a/apps/docs/content/components/accordion/disabled-keys.raw.jsx +++ b/apps/docs/content/components/accordion/disabled-keys.raw.jsx @@ -6,32 +6,22 @@ export default function App() { return ( - + {defaultContent} - Press to expand key 2 + Press to expand id 2 } title="Accordion 2" > {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/indicator-function.raw.jsx b/apps/docs/content/components/accordion/indicator-function.raw.jsx index a2f0a9431e..0c99ef8ab2 100644 --- a/apps/docs/content/components/accordion/indicator-function.raw.jsx +++ b/apps/docs/content/components/accordion/indicator-function.raw.jsx @@ -84,19 +84,19 @@ export default function App() { "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; return ( - + (isOpen ? : )} + id="theme" + indicator={({isExpanded}) => (isExpanded ? : )} title="Theme" > {defaultContent} - } title="Anchor"> + } title="Anchor"> {defaultContent} - } title="Sun"> + } title="Sun"> {defaultContent} diff --git a/apps/docs/content/components/accordion/indicator.raw.jsx b/apps/docs/content/components/accordion/indicator.raw.jsx index 5f53484b46..93611f39b4 100644 --- a/apps/docs/content/components/accordion/indicator.raw.jsx +++ b/apps/docs/content/components/accordion/indicator.raw.jsx @@ -85,13 +85,13 @@ export default function App() { return ( - } title="Anchor"> + } title="Anchor"> {defaultContent} - } title="Moon"> + } title="Moon"> {defaultContent} - } title="Sun"> + } title="Sun"> {defaultContent} diff --git a/apps/docs/content/components/accordion/light-variant.raw.jsx b/apps/docs/content/components/accordion/light-variant.raw.jsx index fda87aab1f..cfd730267b 100644 --- a/apps/docs/content/components/accordion/light-variant.raw.jsx +++ b/apps/docs/content/components/accordion/light-variant.raw.jsx @@ -6,13 +6,13 @@ export default function App() { return ( - + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/multiple.raw.jsx b/apps/docs/content/components/accordion/multiple.raw.jsx index bdb67f0f2a..c993e52b9e 100644 --- a/apps/docs/content/components/accordion/multiple.raw.jsx +++ b/apps/docs/content/components/accordion/multiple.raw.jsx @@ -5,14 +5,14 @@ export default function App() { "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; return ( - - + + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/shadow-variant.raw.jsx b/apps/docs/content/components/accordion/shadow-variant.raw.jsx index 0f432c6df7..3439840030 100644 --- a/apps/docs/content/components/accordion/shadow-variant.raw.jsx +++ b/apps/docs/content/components/accordion/shadow-variant.raw.jsx @@ -6,13 +6,13 @@ export default function App() { return ( - + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/splitted-variant.raw.jsx b/apps/docs/content/components/accordion/splitted-variant.raw.jsx index c170eab8f8..cb7b04f3d7 100644 --- a/apps/docs/content/components/accordion/splitted-variant.raw.jsx +++ b/apps/docs/content/components/accordion/splitted-variant.raw.jsx @@ -6,13 +6,13 @@ export default function App() { return ( - + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/start-content.raw.jsx b/apps/docs/content/components/accordion/start-content.raw.jsx index e089b1d62a..1fa5c2237b 100644 --- a/apps/docs/content/components/accordion/start-content.raw.jsx +++ b/apps/docs/content/components/accordion/start-content.raw.jsx @@ -7,8 +7,8 @@ export default function App() { return ( - + {defaultContent} - Press to expand key 2 + Press to expand id 2 } title="Accordion 2" > {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/usage.raw.jsx b/apps/docs/content/components/accordion/usage.raw.jsx index d2319b3954..99988b8f74 100644 --- a/apps/docs/content/components/accordion/usage.raw.jsx +++ b/apps/docs/content/components/accordion/usage.raw.jsx @@ -6,13 +6,13 @@ export default function App() { return ( - + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/accordion/variant.raw.jsx b/apps/docs/content/components/accordion/variant.raw.jsx index 2efc7d2268..182fcebe30 100644 --- a/apps/docs/content/components/accordion/variant.raw.jsx +++ b/apps/docs/content/components/accordion/variant.raw.jsx @@ -9,13 +9,14 @@ export default function App() {

Light

- + {defaultContent} - + x + {defaultContent} - + {defaultContent} @@ -23,13 +24,13 @@ export default function App() {

Bordered

- + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -37,13 +38,13 @@ export default function App() {

Shadow

- + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -51,13 +52,13 @@ export default function App() {

Splitted

- + {defaultContent} - + {defaultContent} - + {defaultContent} diff --git a/apps/docs/content/components/disclosure/compact.raw.jsx b/apps/docs/content/components/disclosure/compact.raw.jsx new file mode 100644 index 0000000000..600ee41cce --- /dev/null +++ b/apps/docs/content/components/disclosure/compact.raw.jsx @@ -0,0 +1,12 @@ +import {Disclosure} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/compact.ts b/apps/docs/content/components/disclosure/compact.ts new file mode 100644 index 0000000000..c3cdfc316e --- /dev/null +++ b/apps/docs/content/components/disclosure/compact.ts @@ -0,0 +1,9 @@ +import App from "./compact.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/controlled.raw.jsx b/apps/docs/content/components/disclosure/controlled.raw.jsx new file mode 100644 index 0000000000..6aeda31cf4 --- /dev/null +++ b/apps/docs/content/components/disclosure/controlled.raw.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import {Disclosure, Button} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + const [isExpanded, onExpandedChange] = React.useState < boolean > false; + + return ( +
+
+ +
+
+ + {defaultContent} + +
+
+ ); +} diff --git a/apps/docs/content/components/disclosure/controlled.ts b/apps/docs/content/components/disclosure/controlled.ts new file mode 100644 index 0000000000..2c3f0cacb4 --- /dev/null +++ b/apps/docs/content/components/disclosure/controlled.ts @@ -0,0 +1,9 @@ +import App from "./controlled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/custom-motion.raw.jsx b/apps/docs/content/components/disclosure/custom-motion.raw.jsx new file mode 100644 index 0000000000..d4a310e176 --- /dev/null +++ b/apps/docs/content/components/disclosure/custom-motion.raw.jsx @@ -0,0 +1,15 @@ +import {Disclosure} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + const classNames = { + content: "ease-soft-spring", + }; + + return ( + + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/custom-motion.ts b/apps/docs/content/components/disclosure/custom-motion.ts new file mode 100644 index 0000000000..389f462ddd --- /dev/null +++ b/apps/docs/content/components/disclosure/custom-motion.ts @@ -0,0 +1,9 @@ +import App from "./custom-motion.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/custom-styles.raw.jsx b/apps/docs/content/components/disclosure/custom-styles.raw.jsx new file mode 100644 index 0000000000..0851a08a02 --- /dev/null +++ b/apps/docs/content/components/disclosure/custom-styles.raw.jsx @@ -0,0 +1,89 @@ +import {Disclosure} from "@heroui/disclosure"; + +const MonitorMobileIcon = (props) => { + return ( + + ); +}; + +export default function App() { + const classNames = { + base: "py-0 w-full", + title: "font-normal text-medium", + trigger: "px-2 py-0 data-[hover=true]:bg-default-100 rounded-lg h-14 flex items-center", + indicator: "text-medium", + content: "text-small px-2", + }; + + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + } + subtitle={ +

+ 2 issues to fix now +

+ } + title="Connected devices" + > + {defaultContent} +
+ ); +} diff --git a/apps/docs/content/components/disclosure/custom-styles.ts b/apps/docs/content/components/disclosure/custom-styles.ts new file mode 100644 index 0000000000..da3ea9093a --- /dev/null +++ b/apps/docs/content/components/disclosure/custom-styles.ts @@ -0,0 +1,9 @@ +import App from "./custom-styles.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/default-expanded.raw.jsx b/apps/docs/content/components/disclosure/default-expanded.raw.jsx new file mode 100644 index 0000000000..97ef46fe02 --- /dev/null +++ b/apps/docs/content/components/disclosure/default-expanded.raw.jsx @@ -0,0 +1,12 @@ +import {Disclosure} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/default-expanded.ts b/apps/docs/content/components/disclosure/default-expanded.ts new file mode 100644 index 0000000000..9e9d7e4a09 --- /dev/null +++ b/apps/docs/content/components/disclosure/default-expanded.ts @@ -0,0 +1,9 @@ +import App from "./default-expanded.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/disabled.raw.jsx b/apps/docs/content/components/disclosure/disabled.raw.jsx new file mode 100644 index 0000000000..5b44a3371f --- /dev/null +++ b/apps/docs/content/components/disclosure/disabled.raw.jsx @@ -0,0 +1,12 @@ +import {Disclosure} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/disabled.ts b/apps/docs/content/components/disclosure/disabled.ts new file mode 100644 index 0000000000..1a215cc91f --- /dev/null +++ b/apps/docs/content/components/disclosure/disabled.ts @@ -0,0 +1,9 @@ +import App from "./disabled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/index.ts b/apps/docs/content/components/disclosure/index.ts new file mode 100644 index 0000000000..4a4360d278 --- /dev/null +++ b/apps/docs/content/components/disclosure/index.ts @@ -0,0 +1,25 @@ +import usage from "./usage"; +import subtitle from "./subtitle"; +import compact from "./compact"; +import defaultExpanded from "./default-expanded"; +import disabled from "./disabled"; +import startContent from "./start-content"; +import indicator from "./indicator"; +import indicatorFunction from "./indicator-function"; +import customMotion from "./custom-motion"; +import controlled from "./controlled"; +import customStyles from "./custom-styles"; + +export const disclosureContent = { + usage, + subtitle, + compact, + defaultExpanded, + disabled, + startContent, + indicator, + indicatorFunction, + customMotion, + controlled, + customStyles, +}; diff --git a/apps/docs/content/components/disclosure/indicator-function.raw.jsx b/apps/docs/content/components/disclosure/indicator-function.raw.jsx new file mode 100644 index 0000000000..3296fb828b --- /dev/null +++ b/apps/docs/content/components/disclosure/indicator-function.raw.jsx @@ -0,0 +1,72 @@ +import {Disclosure} from "@heroui/react"; + +const MoonIcon = (props) => { + return ( + + ); +}; + +const SunIcon = (props) => { + return ( + + ); +}; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + (isExpanded ? : )} + title="Disclosure with indicator function" + > + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/indicator-function.ts b/apps/docs/content/components/disclosure/indicator-function.ts new file mode 100644 index 0000000000..42c08c9f3c --- /dev/null +++ b/apps/docs/content/components/disclosure/indicator-function.ts @@ -0,0 +1,9 @@ +import App from "./indicator-function.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/indicator.raw.jsx b/apps/docs/content/components/disclosure/indicator.raw.jsx new file mode 100644 index 0000000000..c7d67a9463 --- /dev/null +++ b/apps/docs/content/components/disclosure/indicator.raw.jsx @@ -0,0 +1,39 @@ +import {Disclosure} from "@heroui/react"; + +const MoonIcon = (props) => { + return ( + + ); +}; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + } + title="Disclosure with custom indicator" + > + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/indicator.ts b/apps/docs/content/components/disclosure/indicator.ts new file mode 100644 index 0000000000..221ac6ad9e --- /dev/null +++ b/apps/docs/content/components/disclosure/indicator.ts @@ -0,0 +1,9 @@ +import App from "./indicator.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/start-content.raw.jsx b/apps/docs/content/components/disclosure/start-content.raw.jsx new file mode 100644 index 0000000000..a96fac92b7 --- /dev/null +++ b/apps/docs/content/components/disclosure/start-content.raw.jsx @@ -0,0 +1,25 @@ +import {Disclosure, Avatar} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + + } + subtitle="4 unread messages" + title="Chung Miller" + > + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/start-content.ts b/apps/docs/content/components/disclosure/start-content.ts new file mode 100644 index 0000000000..59191d72a7 --- /dev/null +++ b/apps/docs/content/components/disclosure/start-content.ts @@ -0,0 +1,9 @@ +import App from "./start-content.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/subtitle.raw.jsx b/apps/docs/content/components/disclosure/subtitle.raw.jsx new file mode 100644 index 0000000000..8a9273e845 --- /dev/null +++ b/apps/docs/content/components/disclosure/subtitle.raw.jsx @@ -0,0 +1,12 @@ +import {Disclosure} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( + + {defaultContent} + + ); +} diff --git a/apps/docs/content/components/disclosure/subtitle.ts b/apps/docs/content/components/disclosure/subtitle.ts new file mode 100644 index 0000000000..d196f1d53f --- /dev/null +++ b/apps/docs/content/components/disclosure/subtitle.ts @@ -0,0 +1,9 @@ +import App from "./subtitle.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/disclosure/usage.raw.jsx b/apps/docs/content/components/disclosure/usage.raw.jsx new file mode 100644 index 0000000000..dc1e89a0d4 --- /dev/null +++ b/apps/docs/content/components/disclosure/usage.raw.jsx @@ -0,0 +1,8 @@ +import {Disclosure} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return {defaultContent}; +} diff --git a/apps/docs/content/components/disclosure/usage.ts b/apps/docs/content/components/disclosure/usage.ts new file mode 100644 index 0000000000..1118304c37 --- /dev/null +++ b/apps/docs/content/components/disclosure/usage.ts @@ -0,0 +1,9 @@ +import App from "./usage.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/accordion.mdx b/apps/docs/content/docs/components/accordion.mdx index e474e6f981..97cd96bcd3 100644 --- a/apps/docs/content/docs/components/accordion.mdx +++ b/apps/docs/content/docs/components/accordion.mdx @@ -33,7 +33,7 @@ Accordion display a list of high-level options that can expand/collapse to revea HeroUI exports 2 accordion-related components: - **Accordion**: The main component to display a list of accordion items. -- **AccordionItem**: The item component to display a single accordion item. +- **AccordionItem**: The item component to display a single accordion item. This uses the Disclosure component internally. @@ -106,7 +106,7 @@ Accordion items have a property called `indicator`. You can use it to customize -The indicator can be also a `function`, which receives the `isOpen`, `isDisabled` and the default `indicator` as parameters. +The indicator can be also a `function`, which receives the `isExpanded`, `isDisabled` and the default `indicator` as parameters. -> Learn more about Framer motion variants [here](https://www.framer.com/motion/animation/#variants). - ### Controlled Accordion is a controlled component, which means you need to control the `selectedKeys` property by yourself. @@ -156,12 +154,10 @@ Here's an example of how to customize the accordion styles: ## Data Attributes -`AccordionItem` has the following attributes on the `base` element: +`Disclosure` in the `AccordionItem` has the following attributes on the `base` element: -- **data-open**: +- **data-expanded**: Whether the accordion item is open. -- **data-disabled**: - When the accordion item is disabled. - **data-hover**: When the accordion item is being hovered. Based on [useHover](https://react-spectrum.adobe.com/react-aria/useHover.html). - **data-focus**: @@ -177,7 +173,7 @@ Here's an example of how to customize the accordion styles: ## Accessibility -- Keyboard event support for Space, Enter, Arrow Up, Arrow Down and Home / End keys. +- Keyboard event support for Space, Enter, Tab and Shift + Tab keys. - Keyboard focus management and cross browser normalization. - `aria-expanded` attribute for the accordion item. - `aria-disabled` attribute for the accordion item. @@ -204,17 +200,11 @@ Here's an example of how to customize the accordion styles: default: "light" }, { - attribute: "selectionMode", - type: "none | single | multiple", - description: "The type of selection that is allowed in the collection.", + attribute: "allowsMultipleExpanded", + type: "boolean", + description: "Should allow multiple accordion items to expand.", default: "-" }, - { - attribute: "selectionBehavior", - type: "toggle | replace", - description: "The accordion selection behavior.", - default: "toggle" - }, { attribute: "isCompact", type: "boolean", @@ -257,12 +247,6 @@ Here's an example of how to customize the accordion styles: description: "Whether the Accordion items indicator animation is disabled.", default: "false" }, - { - attribute: "disallowEmptySelection", - type: "boolean", - description: "Whether the collection allows empty selection.", - default: "false" - }, { attribute: "keepContentMounted", type: "boolean", @@ -288,21 +272,15 @@ Here's an example of how to customize the accordion styles: default: "-" }, { - attribute: "itemClasses", - type: "AccordionItemClassnames", - description: "The accordion items classNames.", - default: "-" - }, - { - attribute: "selectedKeys", + attribute: "expandedKeys", type: "all | React.Key[]", - description: "The currently selected keys in the collection (controlled).", + description: "The currently expanded keys in the collection (controlled).", default: "-" }, { - attribute: "defaultSelectedKeys", + attribute: "defaultExpandedKeys", type: "all | React.Key[]", - description: "The initial selected keys in the collection (uncontrolled).", + description: "The initial expanded keys in the collection (uncontrolled).", default: "-" } ]} @@ -313,7 +291,7 @@ Here's an example of how to customize the accordion styles: ) => any", description: "Handler that is called when the selection changes.", default: "-" @@ -345,7 +323,7 @@ Here's an example of how to customize the accordion styles: }, { attribute: "indicator", - type: "IndicatorProps", + type: "AccordionItemIndicatorProps", description: "The accordion item expanded indicator, usually an arrow icon.", default: "-" }, @@ -355,12 +333,6 @@ Here's an example of how to customize the accordion styles: description: "The accordion item start content, usually an icon or avatar.", default: "-" }, - { - attribute: "motionProps", - type: "MotionProps", - description: "The props to modify the framer motion animation. Use the variants API to create your own animation.", - default: "-" - }, { attribute: "isCompact", type: "boolean", @@ -498,9 +470,9 @@ export type AccordionItemIndicatorProps = { */ indicator?: ReactNode; /** - * The current open status. + * The current expanded status. */ - isOpen?: boolean; + isExpanded?: boolean; /** * The current disabled status. * @default false @@ -512,6 +484,7 @@ type indicator?: ReactNode | ((props: AccordionItemIndicatorProps) => ReactNode) ``` ### Accordion Item classNames +- This classNames are propagated to the internal disclosure component. ```ts export type AccordionItemClassnames = { @@ -525,36 +498,4 @@ export type AccordionItemClassnames = { indicator?: string; content?: string; }; -``` - -#### Motion Props - -```ts -export type MotionProps = { - /** - * If `true`, the opacity of the content will be animated - * @default true - */ - animateOpacity?: boolean; - /** - * The height you want the content in its collapsed state. - * @default 0 - */ - startingHeight?: number; - /** - * The height you want the content in its expanded state. - * @default "auto" - */ - endingHeight?: number | string; - /** - * The y-axis offset you want the content in its collapsed state. - * @default 10 - */ - startingY?: number; - /** - * The y-axis offset you want the content in its expanded state. - * @default 0 - */ - endingY?: number; -} & HTMLMotionProps; -``` +``` \ No newline at end of file diff --git a/apps/docs/content/docs/components/disclosure.mdx b/apps/docs/content/docs/components/disclosure.mdx new file mode 100644 index 0000000000..e2369a6e52 --- /dev/null +++ b/apps/docs/content/docs/components/disclosure.mdx @@ -0,0 +1,328 @@ +--- +title: "Disclosure" +description: "Disclosure displays hidden content that can be revealed or concealed." +--- + +import {disclosureContent} from "@/content/components/disclosure"; + +# Disclosure + +Disclosure displays hidden content that can be revealed or concealed. + + + +--- + + + +## Installation + + + +## Import + + + +## Usage + + + +### With Subtitle + + + +### Compact + +If you set `isCompact` to `true`, the `Disclosure` will be displayed in a compact style. + + + +### Default expanded + +If you want to expand the disclosure by default, you can set the `defaultExpanded` to `true`. + + + +### Disabled + +If you want to disable the disclosure, you can set the `isDisabled` property to `true`. + + + +### Start content + +If you want to display some content before the Disclosure, you can set the `startContent` property. + + + +### Custom Indicator + +Disclosure has a property called `indicator`. You can use it to customize the open/close indicator. + + + +The indicator can be also a `function`, which receives the `isExpanded`, `isDisabled` and the default `indicator` as parameters. + + + +### Custom Motion + +Disclosure's animation can be changed by upadting the `content` style. + + + +### Controlled + +Disclosure is a controlled component, which means you need to control the `isExpanded` property by yourself. + + + +### Custom Styles + + + + + +## Disclosure Slots + +- **base**: The disclosure wrapper. +- **heading**: The dsiclosure heading. It contains the `indicator` and the `title`. +- **trigger**: The button that open/close the disclosure. +- **titleWrapper**: The wrapper of the `title` and `subtitle`. +- **title**: The disclosure item title. +- **subtitle**: The disclosure item subtitle. +- **startContent**: The content before the disclosure. +- **indicator**: The element that indicates the open/close state of the dsiclosure. +- **content**: Disclosure content. + +## Data Attributes + +`Disclosure` has the following attributes on the `base` element: + +- **data-expanded**: + Whether the disclosure item is expanded. +- **data-hover**: + When the disclosure item is being hovered. Based on [useHover](https://react-spectrum.adobe.com/react-aria/useHover.html). +- **data-focus**: + When the disclosure item is being focused. Based on [useFocusRing](https://react-spectrum.adobe.com/react-aria/useFocusRing.html). +- **data-focus-visible**: + When the disclosure item is being focused with the keyboard. Based on [useFocusRing](https://react-spectrum.adobe.com/react-aria/useFocusRing.html). +- **data-disabled**: + When the disclosure item is disabled. Based on `isDisabled` prop. +- **data-pressed**: + When the disclosure item is pressed. Based on [usePress](https://react-spectrum.adobe.com/react-aria/usePress.html). + + + +## Accessibility + +- Keyboard event support for Space, Enter, Tab and Shift + Tab keys. +- Keyboard focus management and cross browser normalization. +- `aria-expanded` attribute. +- `aria-disabled` attribute. + + + +## API + +### Disclosure Props + + + +### Disclosure Events + + void", + description: "Handler that is called when the element receives focus.", + default: "-" + }, + { + attribute: "onBlur", + type: "(e: FocusEvent) => void", + description: "Handler that is called when the element loses focus.", + default: "-" + }, + { + attribute: "onFocusChange", + type: "(isFocused: boolean) => void", + description: "Handler that is called when the element's focus status changes.", + default: "-" + }, + { + attribute: "onKeyDown", + type: "(e: KeyboardEvent) => void", + description: "Handler that is called when a key is pressed.", + default: "-" + }, + { + attribute: "onKeyUp", + type: "(e: KeyboardEvent) => void", + description: "Handler that is called when a key is released.", + default: "-" + }, + { + attribute: "onPress", + type: "(e: PressEvent) => void", + description: "Handler called when the press is released over the target.", + default: "-" + }, + { + attribute: "onPressStart", + type: "(e: PressEvent) => void", + description: "Handler called when a press interaction starts.", + default: "-" + }, + { + attribute: "onPressEnd", + type: "(e: PressEvent) => void", + description: "Handler called when a press interaction ends, either over the target or when the pointer leaves the target.", + default: "-" + }, + { + attribute: "onPressChange", + type: "(isPressed: boolean) => void", + description: "Handler called when the press state changes.", + default: "-" + }, + { + attribute: "onPressUp", + type: "(e: PressEvent) => void", + description: "Handler called when a press is released over the target, regardless of whether it started on the target or not.", + default: "-" + }, + { + attribute: "onClick", + type: "MouseEventHandler", + description: "The native button click event handler (Deprecated) use onPress instead.", + default: "-" + } + ]} +/> + +--- + +### Types + +#### Disclosure Indicator Props + +```ts +export type DisclosureIndicatorProps = { + /** + * The current indicator, usually an arrow icon. + */ + indicator?: ReactNode; + /** + * The current expanded status. + */ + isExpanded?: boolean; + /** + * The current disabled status. + * @default false + */ + isDisabled?: boolean; +}; + +type indicator?: ReactNode | ((props: DisclosureIndicatorProps) => ReactNode) | null; +``` \ No newline at end of file diff --git a/packages/components/accordion/__tests__/accordion.test.tsx b/packages/components/accordion/__tests__/accordion.test.tsx index 85e4f58236..71837e8cab 100644 --- a/packages/components/accordion/__tests__/accordion.test.tsx +++ b/packages/components/accordion/__tests__/accordion.test.tsx @@ -24,7 +24,7 @@ describe("Accordion", () => { it("should render correctly", () => { const wrapper = render( - Accordion Item + Accordion Item , ); @@ -38,7 +38,7 @@ describe("Accordion", () => { render( - Accordion Item + Accordion Item , ); expect(ref.current).not.toBeNull(); @@ -47,8 +47,8 @@ describe("Accordion", () => { it("should display the correct number of items", () => { const wrapper = render( - Accordion Item - Accordion Item + Accordion Item + Accordion Item , ); @@ -58,25 +58,25 @@ describe("Accordion", () => { it("should be opened when defaultExpandedKeys is set", () => { const wrapper = render( - + Accordion Item 1 description - + Accordion Item 2 description , ); - expect(wrapper.getByTestId("item-1")).toHaveAttribute("data-open", "true"); + expect(wrapper.getAllByRole("button")[0]).toHaveAttribute("data-expanded", "true"); }); it("should be disabled when disabledKeys is set", () => { const wrapper = render( - + Accordion Item 1 description - + Accordion Item 2 description , @@ -88,13 +88,13 @@ describe("Accordion", () => { it("should hide the accordion item when the hidden prop is set", () => { const wrapper = render( - + Accordion Item 1 description - , @@ -107,17 +107,16 @@ describe("Accordion", () => { it("should expand the accordion item when clicked", async () => { const wrapper = render( - + Accordion Item 1 description - + Accordion Item 2 description , ); - const base = wrapper.getByTestId("item-1"); - const button = base.querySelector("button") as HTMLElement; + const button = wrapper.getAllByRole("button")[0] as HTMLElement; expect(button).toHaveAttribute("aria-expanded", "false"); @@ -130,8 +129,8 @@ describe("Accordion", () => { const wrapper = render( } title="Accordion Item 1" > @@ -143,79 +142,20 @@ describe("Accordion", () => { expect(wrapper.getByTestId("start-content")).toBeInTheDocument(); }); - it("arrow up & down moves focus to next/previous accordion item", async () => { - const wrapper = render( - - - Accordion Item 1 description - - - Accordion Item 2 description - - , - ); - - const first = wrapper.getByTestId("item-1"); - const firstButton = first.querySelector("button") as HTMLElement; - - const second = wrapper.getByTestId("item-2"); - const secondButton = second.querySelector("button") as HTMLElement; - - await focus(firstButton); - await user.keyboard("[ArrowDown]"); - expect(secondButton).toHaveFocus(); - - await user.keyboard("[ArrowUp]"); - - expect(firstButton).toHaveFocus(); - }); - - it("home & end keys moves focus to first/last accordion", async () => { - const wrapper = render( - - - Accordion Item 1 description - - - Accordion Item 2 description - - , - ); - - const first = wrapper.getByTestId("item-1"); - const firstButton = first.querySelector("button") as HTMLElement; - - const second = wrapper.getByTestId("item-2"); - const secondButton = second.querySelector("button") as HTMLElement; - - act(() => { - focus(secondButton); - }); - - await user.keyboard("[Home]"); - expect(firstButton).toHaveFocus(); - - await user.keyboard("[End]"); - expect(secondButton).toHaveFocus(); - }); - it("tab moves focus to the next focusable element", async () => { const wrapper = render( - + Accordion Item 1 description - + Accordion Item 2 description , ); - const first = wrapper.getByTestId("item-1"); - const firstButton = first.querySelector("button") as HTMLElement; - - const second = wrapper.getByTestId("item-2"); - const secondButton = second.querySelector("button") as HTMLElement; + const firstButton = wrapper.getAllByRole("button")[0] as HTMLElement; + const secondButton = wrapper.getAllByRole("button")[1] as HTMLElement; act(() => { focus(firstButton); @@ -225,40 +165,19 @@ describe("Accordion", () => { expect(secondButton).toHaveFocus(); }); - it("aria-controls for button is same as id for region", async () => { - const wrapper = render( - - - Accordion Item 1 description - - - Accordion Item 2 description - - , - ); - - const base = wrapper.getByTestId("item-1"); - const button = base.querySelector("button") as HTMLElement; - - const region = base.querySelector("[role='region']") as HTMLElement; - - expect(button).toHaveAttribute("aria-controls", region.id); - }); - it("aria-expanded is true/false when accordion is open/closed", async () => { const wrapper = render( - + Accordion Item 1 description - + Accordion Item 2 description , ); - const base = wrapper.getByTestId("item-1"); - const button = base.querySelector("button") as HTMLElement; + const button = wrapper.getAllByRole("button")[0] as HTMLElement; expect(button).toHaveAttribute("aria-expanded", "false"); @@ -266,39 +185,13 @@ describe("Accordion", () => { expect(button).toHaveAttribute("aria-expanded", "true"); }); - it("should support keepContentMounted", async () => { - const wrapper = render( - - - Accordion Item 1 description - - - Accordion Item 2 description - - , - ); - - const item1 = wrapper.getByTestId("item-1"); - const button = item1.querySelector("button") as HTMLElement; - - expect(item1.querySelector("[role='region']")).toBeInTheDocument(); - - await user.click(button); - const item2 = wrapper.getByTestId("item-2"); - const button2 = item2.querySelector("button") as HTMLElement; - - await user.click(button2); - expect(item1.querySelector("[role='region']")).toBeInTheDocument(); - expect(item2.querySelector("[role='region']")).toBeInTheDocument(); - }); - it("should handle arrow key navigation within Input inside AccordionItem", async () => { const wrapper = render( - + - + Accordion Item 2 description , @@ -306,7 +199,7 @@ describe("Accordion", () => { const input = wrapper.getByLabelText("name"); - const firstButton = await wrapper.getByRole("button", {name: "Accordion Item 1"}); + const firstButton = wrapper.getAllByRole("button")[0] as HTMLElement; act(() => { focus(firstButton); @@ -334,10 +227,10 @@ describe("Accordion", () => { className: "bg-rose-500", }} > - + Accordion Item 1 description - + Accordion Item 2 description , diff --git a/packages/components/accordion/package.json b/packages/components/accordion/package.json index 631ae49962..cfe5a77817 100644 --- a/packages/components/accordion/package.json +++ b/packages/components/accordion/package.json @@ -55,13 +55,14 @@ "@heroui/divider": "workspace:*", "@heroui/use-aria-accordion": "workspace:*", "@heroui/dom-animation": "workspace:*", + "@heroui/disclosure": "workspace:*", "@react-aria/interactions": "3.22.5", "@react-aria/focus": "3.19.0", "@react-aria/utils": "3.26.0", - "@react-stately/tree": "3.8.6", "@react-aria/button": "3.11.0", "@react-types/accordion": "3.0.0-alpha.25", - "@react-types/shared": "3.26.0" + "@react-types/shared": "3.26.0", + "@react-stately/disclosure": "^3.0.0" }, "devDependencies": { "@heroui/theme": "workspace:*", diff --git a/packages/components/accordion/src/accordian-context.tsx b/packages/components/accordion/src/accordian-context.tsx new file mode 100644 index 0000000000..1b9946b5ab --- /dev/null +++ b/packages/components/accordion/src/accordian-context.tsx @@ -0,0 +1,14 @@ +import {createContext} from "@heroui/react-utils"; +import {DisclosureGroupState} from "@react-stately/disclosure"; + +import {ValuesType} from "./use-accordion"; + +export const [AccordianContext, useAccordianContext] = createContext<{ + state: DisclosureGroupState; + values: ValuesType; +}>({ + name: "AccordianContext", + strict: true, + errorMessage: + "useAccordianContext: `context` is undefined. Seems you forgot to wrap component within ", +}); diff --git a/packages/components/accordion/src/accordion-item.tsx b/packages/components/accordion/src/accordion-item.tsx index ee7d5e0aca..b9200bf413 100644 --- a/packages/components/accordion/src/accordion-item.tsx +++ b/packages/components/accordion/src/accordion-item.tsx @@ -1,128 +1,22 @@ -import type {Variants} from "framer-motion"; - import {forwardRef} from "@heroui/system"; -import {useMemo, ReactNode} from "react"; -import {ChevronIcon} from "@heroui/shared-icons"; -import {AnimatePresence, LazyMotion, m, useWillChange} from "framer-motion"; -import {TRANSITION_VARIANTS} from "@heroui/framer-utils"; +import {Disclosure} from "@heroui/disclosure"; +import {Divider} from "@heroui/divider"; import {UseAccordionItemProps, useAccordionItem} from "./use-accordion-item"; export interface AccordionItemProps extends UseAccordionItemProps {} -const domAnimation = () => import("@heroui/dom-animation").then((res) => res.default); - const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => { - const { - Component, - HeadingComponent, - classNames, - slots, - indicator, - children, - title, - subtitle, - startContent, - isOpen, - isDisabled, - hideIndicator, - keepContentMounted, - disableAnimation, - motionProps, - getBaseProps, - getHeadingProps, - getButtonProps, - getTitleProps, - getSubtitleProps, - getContentProps, - getIndicatorProps, - } = useAccordionItem({...props, ref}); - - const willChange = useWillChange(); - - const indicatorContent = useMemo(() => { - if (typeof indicator === "function") { - return indicator({indicator: , isOpen, isDisabled}); - } - - if (indicator) return indicator; - - return null; - }, [indicator, isOpen, isDisabled]); - - const indicatorComponent = indicatorContent || ; - - const content = useMemo(() => { - if (disableAnimation) { - return
{children}
; - } - - const transitionVariants: Variants = { - exit: {...TRANSITION_VARIANTS.collapse.exit, overflowY: "hidden"}, - enter: {...TRANSITION_VARIANTS.collapse.enter, overflowY: "unset"}, - }; - - return keepContentMounted ? ( - - { - e.stopPropagation(); - }} - {...motionProps} - > -
{children}
-
-
- ) : ( - - {isOpen && ( - - { - e.stopPropagation(); - }} - {...motionProps} - > -
{children}
-
-
- )} -
- ); - }, [isOpen, disableAnimation, keepContentMounted, children, motionProps]); + const {disclosureProps, children, dividerProps, hidden, showDivider, getBaseProps} = + useAccordionItem(props); return ( - - - - - {content} - +
+ + {children} + + {showDivider && !hidden && } +
); }); diff --git a/packages/components/accordion/src/accordion.tsx b/packages/components/accordion/src/accordion.tsx index 84f6dab666..ef18bb2a01 100644 --- a/packages/components/accordion/src/accordion.tsx +++ b/packages/components/accordion/src/accordion.tsx @@ -1,61 +1,17 @@ import {forwardRef} from "@heroui/system"; -import {LayoutGroup} from "framer-motion"; -import {Divider} from "@heroui/divider"; -import {Fragment, Key, useCallback, useMemo} from "react"; -import {UseAccordionProps, useAccordion} from "./use-accordion"; -import AccordionItem from "./accordion-item"; +import {useAccordion, UseAccordionProps} from "./use-accordion"; +import {AccordianContext} from "./accordian-context"; export interface AccordionProps extends UseAccordionProps {} const AccordionGroup = forwardRef<"div", AccordionProps>((props, ref) => { - const { - Component, - values, - state, - isSplitted, - showDivider, - getBaseProps, - disableAnimation, - handleFocusChanged: handleFocusChangedProps, - itemClasses, - dividerProps, - } = useAccordion({ - ...props, - ref, - }); - const handleFocusChanged = useCallback( - (isFocused: boolean, key: Key) => handleFocusChangedProps(isFocused, key), - [handleFocusChangedProps], - ); - - const content = useMemo(() => { - return [...state.collection].map((item, index) => { - const classNames = {...itemClasses, ...(item.props.classNames || {})}; - - return ( - - - {!item.props.hidden && - !isSplitted && - showDivider && - index < state.collection.size - 1 && } - - ); - }); - }, [values, itemClasses, handleFocusChanged, isSplitted, showDivider, state.collection]); + const {state, values, children, Component, getBaseProps} = useAccordion({...props, ref}); return ( - - {disableAnimation ? content : {content}} - + + {children} + ); }); diff --git a/packages/components/accordion/src/base/accordion-item-base.tsx b/packages/components/accordion/src/base/accordion-item-base.tsx index d462e3398f..006cbc34b2 100644 --- a/packages/components/accordion/src/base/accordion-item-base.tsx +++ b/packages/components/accordion/src/base/accordion-item-base.tsx @@ -1,5 +1,3 @@ -import type {AccordionItemVariantProps, AccordionItemSlots, SlotsToClasses} from "@heroui/theme"; - import {As} from "@heroui/system"; import {ItemProps, BaseItem} from "@heroui/aria-utils"; import {FocusableProps, PressEvents} from "@react-types/shared"; @@ -62,26 +60,6 @@ export interface Props * @deprecated - use `onPress` instead. */ onClick?: MouseEventHandler; - /** - * Classname or List of classes to change the classNames of the element. - * if `className` is passed, it will be added to the base slot. - * - * @example - * ```ts - * - * ``` - */ - classNames?: SlotsToClasses; /** * Customizable heading tag for Web accessibility: * use headings to describe content and use them consistently and semantically. @@ -90,7 +68,7 @@ export interface Props HeadingComponent?: As; } -export type AccordionItemBaseProps = Props & AccordionItemVariantProps; +export type AccordionItemBaseProps = Props; const AccordionItemBase = BaseItem as (props: AccordionItemBaseProps) => JSX.Element; diff --git a/packages/components/accordion/src/index.ts b/packages/components/accordion/src/index.ts index be286a2064..d8bc423344 100644 --- a/packages/components/accordion/src/index.ts +++ b/packages/components/accordion/src/index.ts @@ -1,4 +1,4 @@ -import AccordionItem from "./base/accordion-item-base"; +import AccordionItem from "./accordion-item"; import Accordion from "./accordion"; // export types diff --git a/packages/components/accordion/src/use-accordion-item.ts b/packages/components/accordion/src/use-accordion-item.ts index 9ece5c27be..3134d571be 100644 --- a/packages/components/accordion/src/use-accordion-item.ts +++ b/packages/components/accordion/src/use-accordion-item.ts @@ -1,274 +1,94 @@ -import type {AccordionItemVariantProps} from "@heroui/theme"; +import type {DisclosureSlots, DisclosureVariantProps} from "@heroui/theme"; -import {HTMLHeroUIProps, PropGetter, useProviderContext} from "@heroui/system"; -import {useFocusRing} from "@react-aria/focus"; -import {accordionItem} from "@heroui/theme"; -import {clsx, callAllHandlers, dataAttr, objectToDeps} from "@heroui/shared-utils"; -import {ReactRef, useDOMRef, filterDOMProps} from "@heroui/react-utils"; -import {NodeWithProps} from "@heroui/aria-utils"; -import {useReactAriaAccordionItem} from "@heroui/use-aria-accordion"; -import {useCallback, useMemo} from "react"; -import {chain, mergeProps} from "@react-aria/utils"; -import {useHover, usePress} from "@react-aria/interactions"; -import {TreeState} from "@react-stately/tree"; +import {HTMLHeroUIProps, PropGetter} from "@heroui/system"; +import {ReactRef} from "@heroui/react-utils"; +import {DisclosureProps} from "@heroui/disclosure"; +import {SlotsToClasses} from "@heroui/theme"; +import {Key, useCallback} from "react"; +import {callAllHandlers} from "@heroui/shared-utils"; +import {useAccordianContext} from "./accordian-context"; import {AccordionItemBaseProps} from "./base/accordion-item-base"; -export interface Props extends HTMLHeroUIProps<"div"> { +export interface Props extends HTMLHeroUIProps<"div"> { /** * Ref to the DOM node. */ ref?: ReactRef; - /** - * The item node. - */ - item: NodeWithProps>; - /** - * The accordion tree state. - */ - state: TreeState; - /** - * Current focused key. - */ - focusedKey: React.Key | null; - /** - * Callback fired when the focus state changes. - */ + id: string; + disabledKeys?: Iterable; + classNames?: SlotsToClasses; onFocusChange?: (isFocused: boolean, key?: React.Key) => void; } -export type UseAccordionItemProps = Props & - AccordionItemVariantProps & +export type UseAccordionItemProps = Props & + DisclosureVariantProps & + DisclosureProps & Omit; -export function useAccordionItem(props: UseAccordionItemProps) { - const globalContext = useProviderContext(); - - const {ref, as, item, onFocusChange} = props; - - const { - state, - className, - indicator, - children, - title, - subtitle, - startContent, - motionProps, - focusedKey, - variant, - isCompact = false, - classNames: classNamesProp = {}, - isDisabled: isDisabledProp = false, - hideIndicator = false, - disableAnimation = globalContext?.disableAnimation ?? false, - keepContentMounted = false, - disableIndicatorAnimation = false, - HeadingComponent = as || "h2", - onPress, - onPressStart, - onPressEnd, - onPressChange, - onPressUp, - onClick, - ...otherProps - } = props; - - const Component = as || "div"; - const shouldFilterDOMProps = typeof Component === "string"; - - const domRef = useDOMRef(ref); - - const isDisabled = state.disabledKeys.has(item.key) || isDisabledProp; - const isOpen = state.selectionManager.isSelected(item.key); - - const {buttonProps: buttonCompleteProps, regionProps} = useReactAriaAccordionItem( - {item, isDisabled}, - {...state, focusedKey: focusedKey}, - domRef, - ); +export function useAccordionItem(originalProps: UseAccordionItemProps) { + const {state, values} = useAccordianContext(); - const {onFocus: onFocusButton, onBlur: onBlurButton, ...buttonProps} = buttonCompleteProps; + const {id, classNames, onFocusChange, ...otherProps} = originalProps; + const {isDisabled} = values; + const showDivider = values.showDivider && values.lastChildId != id; - const {isFocused, isFocusVisible, focusProps} = useFocusRing({ - autoFocus: item.props?.autoFocus, - }); + const containsKey = (iterable: Iterable | undefined, key: Key): boolean => { + if (!iterable) { + return false; + } + for (const item of iterable) { + if (item === key) { + return true; + } + } - const {isHovered, hoverProps} = useHover({isDisabled}); + return false; + }; - const {pressProps, isPressed} = usePress({ - ref: domRef, - isDisabled, - onPress, - onPressStart, - onPressEnd, - onPressChange, - onPressUp, - }); + const disabledKeys = values.disabledKeys; const handleFocus = useCallback(() => { - onFocusChange?.(true, item.key); + onFocusChange?.(true, id); }, []); const handleBlur = useCallback(() => { - onFocusChange?.(false, item.key); + onFocusChange?.(false, id); }, []); - const classNames = useMemo( - () => ({ - ...classNamesProp, - }), - [objectToDeps(classNamesProp)], - ); - - const slots = useMemo( - () => - accordionItem({ - isCompact, - isDisabled, - hideIndicator, - disableAnimation, - disableIndicatorAnimation, - variant, - }), - [isCompact, isDisabled, hideIndicator, disableAnimation, disableIndicatorAnimation, variant], - ); - - const baseStyles = clsx(classNames?.base, className); - - const getBaseProps = useCallback( - (props = {}) => { - return { - "data-open": dataAttr(isOpen), - "data-disabled": dataAttr(isDisabled), - className: slots.base({class: baseStyles}), - ...mergeProps( - filterDOMProps(otherProps, { - enabled: shouldFilterDOMProps, - }), - props, - ), - }; + const disclosureProps: DisclosureProps = { + ...values, + ...otherProps, + isExpanded: state.expandedKeys.has(id), + isDisabled: containsKey(disabledKeys, id) || isDisabled, + onExpandedChange(isExpanded) { + if (state) { + state.toggleKey(id); + } + originalProps.onExpandedChange?.(isExpanded); }, - [baseStyles, shouldFilterDOMProps, otherProps, slots, item.props, isOpen, isDisabled], - ); - - const getButtonProps: PropGetter = (props = {}) => { - return { - ref: domRef, - "data-open": dataAttr(isOpen), - "data-focus": dataAttr(isFocused), - "data-focus-visible": dataAttr(isFocusVisible), - "data-disabled": dataAttr(isDisabled), - "data-hover": dataAttr(isHovered), - "data-pressed": dataAttr(isPressed), - className: slots.trigger({class: classNames?.trigger}), - onFocus: callAllHandlers( - handleFocus, - onFocusButton, - focusProps.onFocus, - otherProps.onFocus, - item.props?.onFocus, - ), - onBlur: callAllHandlers( - handleBlur, - onBlurButton, - focusProps.onBlur, - otherProps.onBlur, - item.props?.onBlur, - ), - ...mergeProps(buttonProps, hoverProps, pressProps, props, { - onClick: chain(pressProps.onClick, onClick), - }), - }; + onFocus: callAllHandlers(handleFocus, originalProps.onFocus), + onBlur: callAllHandlers(handleBlur, originalProps.onBlur), + classNames, }; - const getContentProps = useCallback( + const getBaseProps: PropGetter = useCallback( (props = {}) => { return { - "data-open": dataAttr(isOpen), - "data-disabled": dataAttr(isDisabled), - className: slots.content({class: classNames?.content}), - ...mergeProps(regionProps, props), - }; - }, - [slots, classNames, regionProps, isOpen, isDisabled, classNames?.content], - ); - - const getIndicatorProps = useCallback( - (props = {}) => { - return { - "aria-hidden": dataAttr(true), - "data-open": dataAttr(isOpen), - "data-disabled": dataAttr(isDisabled), - className: slots.indicator({class: classNames?.indicator}), - ...props, - }; - }, - [slots, classNames?.indicator, isOpen, isDisabled, classNames?.indicator], - ); - - const getHeadingProps = useCallback( - (props = {}) => { - return { - "data-open": dataAttr(isOpen), - "data-disabled": dataAttr(isDisabled), - className: slots.heading({class: classNames?.heading}), + "data-hidden": originalProps.hidden, ...props, }; }, - [slots, classNames?.heading, isOpen, isDisabled, classNames?.heading], - ); - - const getTitleProps = useCallback( - (props = {}) => { - return { - "data-open": dataAttr(isOpen), - "data-disabled": dataAttr(isDisabled), - className: slots.title({class: classNames?.title}), - ...props, - }; - }, - [slots, classNames?.title, isOpen, isDisabled, classNames?.title], - ); - - const getSubtitleProps = useCallback( - (props = {}) => { - return { - "data-open": dataAttr(isOpen), - "data-disabled": dataAttr(isDisabled), - className: slots.subtitle({class: classNames?.subtitle}), - ...props, - }; - }, - [slots, classNames, isOpen, isDisabled, classNames?.subtitle], + [originalProps.hidden], ); return { - Component, - HeadingComponent, - item, - slots, - classNames, - domRef, - indicator, - children, - title, - subtitle, - startContent, - isOpen, - isDisabled, - hideIndicator, - keepContentMounted, - disableAnimation, - motionProps, + disclosureProps, + children: originalProps.children, + dividerProps: values.dividerProps, + hidden: originalProps.hidden, + showDivider, getBaseProps, - getHeadingProps, - getButtonProps, - getContentProps, - getIndicatorProps, - getTitleProps, - getSubtitleProps, }; } diff --git a/packages/components/accordion/src/use-accordion.ts b/packages/components/accordion/src/use-accordion.ts index d5668543ce..f561824c5b 100644 --- a/packages/components/accordion/src/use-accordion.ts +++ b/packages/components/accordion/src/use-accordion.ts @@ -1,18 +1,16 @@ -import type {SelectionBehavior, MultipleSelection} from "@react-types/shared"; import type {AriaAccordionProps} from "@react-types/accordion"; -import type {AccordionGroupVariantProps} from "@heroui/theme"; import type {HTMLHeroUIProps, PropGetter} from "@heroui/system"; +import {AccordionGroupVariantProps, accordion} from "@heroui/theme"; import {useProviderContext} from "@heroui/system"; import {ReactRef, filterDOMProps} from "@heroui/react-utils"; -import React, {Key, useCallback} from "react"; -import {TreeState, useTreeState} from "@react-stately/tree"; +import {Children, isValidElement, Key, useCallback} from "react"; import {mergeProps} from "@react-aria/utils"; -import {accordion} from "@heroui/theme"; import {useDOMRef} from "@heroui/react-utils"; -import {useMemo, useState} from "react"; +import {useMemo} from "react"; import {DividerProps} from "@heroui/divider"; -import {useReactAriaAccordion} from "@heroui/use-aria-accordion"; +import {useDisclosureGroupState} from "@react-stately/disclosure"; +import {clsx} from "@heroui/shared-utils"; import {AccordionItemProps} from "./accordion-item"; @@ -31,11 +29,7 @@ interface Props extends HTMLHeroUIProps<"div"> { * The divider props. */ dividerProps?: Partial; - /** - * The accordion selection behavior. - * @default "toggle" - */ - selectionBehavior?: SelectionBehavior; + allowsMultipleExpanded?: boolean; /** * Whether to keep the accordion content mounted when collapsed. * @default false @@ -45,24 +39,19 @@ interface Props extends HTMLHeroUIProps<"div"> { * The accordion items classNames. */ itemClasses?: AccordionItemProps["classNames"]; + disabledKeys?: Iterable; } export type UseAccordionProps = Props & + AccordionGroupVariantProps & + AriaAccordionProps & AccordionGroupVariantProps & Pick< AccordionItemProps, - | "isCompact" - | "isDisabled" - | "hideIndicator" - | "disableAnimation" - | "disableIndicatorAnimation" - | "motionProps" - > & - AriaAccordionProps & - MultipleSelection; + "isCompact" | "isDisabled" | "hideIndicator" | "disableAnimation" | "disableIndicatorAnimation" + >; -export type ValuesType = { - state: TreeState; +export type ValuesType = { focusedKey?: Key | null; isCompact?: AccordionItemProps["isCompact"]; isDisabled?: AccordionItemProps["isDisabled"]; @@ -70,180 +59,104 @@ export type ValuesType = { disableAnimation?: AccordionItemProps["disableAnimation"]; keepContentMounted?: Props["keepContentMounted"]; disableIndicatorAnimation?: AccordionItemProps["disableAnimation"]; - motionProps?: AccordionItemProps["motionProps"]; + disabledKeys?: Iterable; + lastChildId?: string; + dividerProps?: Partial; + showDivider?: boolean; + fullWidth?: boolean; }; -export function useAccordion(props: UseAccordionProps) { +export function useAccordion(originalProps: UseAccordionProps) { const globalContext = useProviderContext(); const { - ref, as, - className, - items, - variant, - motionProps, - expandedKeys, - disabledKeys, - selectedKeys, - children: childrenProp, - defaultExpandedKeys, - selectionMode = "single", - selectionBehavior = "toggle", - keepContentMounted = false, - disallowEmptySelection, - defaultSelectedKeys, - onExpandedChange, - onSelectionChange, - dividerProps = {}, + ref, isCompact = false, - isDisabled = false, - showDivider = true, + isDisabled, hideIndicator = false, disableAnimation = globalContext?.disableAnimation ?? false, disableIndicatorAnimation = false, - itemClasses, - ...otherProps - } = props; - - const [focusedKey, setFocusedKey] = useState(null); - - const Component = as || "div"; - const shouldFilterDOMProps = typeof Component === "string"; - - const domRef = useDOMRef(ref); - - const classNames = useMemo( - () => - accordion({ - variant, - className, - }), - [variant, className], - ); - - // TODO: Remove this once the issue is fixed. - const children = useMemo(() => { - let treeChildren: any = []; - - /** - * This is a workaround for rendering ReactNode children in the AccordionItem. - * @see https://github.com/adobe/react-spectrum/issues/3882 - */ - React.Children.map(childrenProp, (child) => { - if (React.isValidElement(child) && typeof child.props?.children !== "string") { - const clonedChild = React.cloneElement(child, { - // @ts-ignore - hasChildItems: false, - }); - - treeChildren.push(clonedChild); - } else { - treeChildren.push(child); - } - }); - - return treeChildren; - }, [childrenProp]); - - const commonProps = { + disabledKeys, + variant, + className, children, - items, - }; - - const expandableProps = { - expandedKeys, - defaultExpandedKeys, + dividerProps, + keepContentMounted, + showDivider = true, + fullWidth = true, onExpandedChange, - }; - - const treeProps = { - disabledKeys, - selectedKeys, - selectionMode, - selectionBehavior, - disallowEmptySelection, - defaultSelectedKeys: defaultSelectedKeys ?? defaultExpandedKeys, - onSelectionChange, - ...commonProps, - ...expandableProps, - }; + } = originalProps; - const state = useTreeState(treeProps); + const state = useDisclosureGroupState({...originalProps, onExpandedChange}); - state.selectionManager.setFocusedKey = (key: Key | null) => { - setFocusedKey(key); - }; - - const {accordionProps} = useReactAriaAccordion( - { - ...commonProps, - ...expandableProps, - }, - state, - domRef, - ); + const Component = as || "div"; + const shouldFilterDOMProps = typeof Component === "string"; + const lastChild = Children.toArray(children).at(-1); + const lastChildId = isValidElement(lastChild) ? lastChild.props.id : undefined; - const values: ValuesType = useMemo( + const values: ValuesType = useMemo( () => ({ - state, - focusedKey, - motionProps, isCompact, isDisabled, hideIndicator, disableAnimation, - keepContentMounted, disableIndicatorAnimation, + disabledKeys, + lastChildId, + dividerProps, + keepContentMounted, + showDivider, + fullWidth, }), [ - focusedKey, isCompact, isDisabled, hideIndicator, - selectedKeys, disableAnimation, - keepContentMounted, state?.expandedKeys.values, disableIndicatorAnimation, state.expandedKeys.size, - state.disabledKeys.size, - motionProps, + disabledKeys, + lastChildId, + keepContentMounted, + showDivider, + fullWidth, ], ); + const domRef = useDOMRef(ref); + const classNames = useMemo( + () => + accordion({ + variant, + className, + }), + [variant, className], + ); + const getBaseProps: PropGetter = useCallback((props = {}) => { return { ref: domRef, - className: classNames, "data-orientation": "vertical", ...mergeProps( - accordionProps, - filterDOMProps(otherProps, { + filterDOMProps(originalProps, { enabled: shouldFilterDOMProps, }), props, ), + className: clsx(classNames, className), }; }, []); - const handleFocusChanged = useCallback((isFocused: boolean, key: Key | null) => { - isFocused && setFocusedKey(key); - }, []); - return { - Component, - values, state, - focusedKey, + values, + children, + Component, getBaseProps, - isSplitted: variant === "splitted", - classNames, + domRef, showDivider, - dividerProps, - disableAnimation, - handleFocusChanged, - itemClasses, }; } diff --git a/packages/components/accordion/stories/accordion.stories.tsx b/packages/components/accordion/stories/accordion.stories.tsx index fcf55191a3..f8f45ee52d 100644 --- a/packages/components/accordion/stories/accordion.stories.tsx +++ b/packages/components/accordion/stories/accordion.stories.tsx @@ -2,7 +2,8 @@ import type {Selection} from "@react-types/shared"; import React from "react"; import {Meta} from "@storybook/react"; -import {accordionItem, button} from "@heroui/theme"; +import {button} from "@heroui/theme"; + import { AnchorIcon, MoonIcon, @@ -33,23 +34,31 @@ export default { type: "boolean", }, }, - selectionMode: { + allowsMultipleExpanded: { control: { - type: "select", + type: "boolean", }, - options: ["single", "multiple"], }, disableAnimation: { control: { type: "boolean", }, }, + showDivider: { + control: { + type: "boolean", + }, + }, + hideIndicator: { + control: { + type: "boolean", + }, + }, }, } as Meta; const defaultProps = { - ...accordionItem.defaultVariants, - selectionMode: "single", + allowsMultipleExpanded: false, }; const defaultContent = @@ -57,13 +66,13 @@ const defaultContent = const Template = (args: AccordionProps) => ( - + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -71,22 +80,22 @@ const Template = (args: AccordionProps) => ( const TemplateWithSubtitle = (args: AccordionProps) => ( - + {defaultContent} - Press to expand key 2 + Press to expand id 2 } title="Accordion 2" > {defaultContent} - + {defaultContent} @@ -95,8 +104,8 @@ const TemplateWithSubtitle = (args: AccordionProps) => ( const TemplateWithStartContent = (args: AccordionProps) => ( ( {defaultContent} ( {defaultContent} (

Default

- + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -168,13 +177,13 @@ const VariantsTemplate = (args: AccordionProps) => (

Shadow

- + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -182,13 +191,13 @@ const VariantsTemplate = (args: AccordionProps) => (

Bordered

- + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -196,13 +205,13 @@ const VariantsTemplate = (args: AccordionProps) => (

Splitted

- + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -210,15 +219,35 @@ const VariantsTemplate = (args: AccordionProps) => (
); +const CustomAnimationTemplate = (args: AccordionProps) => { + const classNames = { + content: "ease-soft-spring", + }; + + return ( + + + {defaultContent} + + + {defaultContent} + + + {defaultContent} + + + ); +}; + const CustomInidicatorTemplate = (args: AccordionProps) => ( - } title="Anchor"> + } title="Anchor"> {defaultContent} - } title="Moon"> + } title="Moon"> {defaultContent} - } title="Sun"> + } title="Sun"> {defaultContent} @@ -232,14 +261,14 @@ const ControlledTemplate = (args: AccordionProps) => { return (
- - + + {defaultContent} - + {defaultContent} - + {defaultContent} @@ -288,9 +317,9 @@ const CustomWithClassNamesTemplate = (args: AccordionProps) => { variant="shadow" > } subtitle={

@@ -302,9 +331,9 @@ const CustomWithClassNamesTemplate = (args: AccordionProps) => { {defaultContent} } subtitle="3 apps have read permissions" title="Apps Permissions" @@ -312,9 +341,9 @@ const CustomWithClassNamesTemplate = (args: AccordionProps) => { {defaultContent} } subtitle="Complete your profile" title="Pending tasks" @@ -322,9 +351,9 @@ const CustomWithClassNamesTemplate = (args: AccordionProps) => { {defaultContent} } subtitle="Please, update now" title={ @@ -363,13 +392,13 @@ const WithFormTemplate = (args: AccordionProps) => { return ( - + {form} - + {defaultContent} - + {defaultContent} @@ -398,7 +427,7 @@ export const Multiple = { args: { ...defaultProps, - selectionMode: "multiple", + allowsMultipleExpanded: "multiple", }, }; @@ -462,46 +491,10 @@ export const WithForm = { }; export const CustomMotion = { - render: Template, + render: CustomAnimationTemplate, args: { ...defaultProps, - motionProps: { - variants: { - enter: { - y: 0, - opacity: 1, - height: "auto", - transition: { - height: { - type: "spring", - stiffness: 500, - damping: 30, - duration: 1, - }, - opacity: { - easings: "ease", - duration: 1, - }, - }, - }, - exit: { - y: -10, - opacity: 0, - height: 0, - transition: { - height: { - easings: "ease", - duration: 0.25, - }, - opacity: { - easings: "ease", - duration: 0.3, - }, - }, - }, - }, - }, }, }; diff --git a/packages/components/disclosure/README.md b/packages/components/disclosure/README.md new file mode 100644 index 0000000000..6e9d364a6b --- /dev/null +++ b/packages/components/disclosure/README.md @@ -0,0 +1,22 @@ +# @heroui/disclosure + +Disclosure displays hidden content that can be revealed or concealed. + +## Installation + +```sh +yarn add @heroui/disclosure +# or +npm i @heroui/disclosure +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). diff --git a/packages/components/disclosure/__tests__/disclosure.test.tsx b/packages/components/disclosure/__tests__/disclosure.test.tsx new file mode 100644 index 0000000000..ad40e41df7 --- /dev/null +++ b/packages/components/disclosure/__tests__/disclosure.test.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import {render} from "@testing-library/react"; + +import {Disclosure} from "../src"; + +describe("Disclosure", () => { + it("should render correctly", () => { + const wrapper = render(); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", () => { + const ref = React.createRef(); + + render(); + expect(ref.current).not.toBeNull(); + }); +}); diff --git a/packages/components/disclosure/package.json b/packages/components/disclosure/package.json new file mode 100644 index 0000000000..57c23bcee2 --- /dev/null +++ b/packages/components/disclosure/package.json @@ -0,0 +1,63 @@ +{ + "name": "@heroui/disclosure", + "version": "2.0.0", + "description": "A disclosure is a collapsible section of content.", + "keywords": [ + "disclosure" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/disclosure" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "react": ">=18 || >=19.0.0-rc.0", + "react-dom": ">=18 || >=19.0.0-rc.0", + "@heroui/theme": ">=2.4.0", + "@heroui/system": ">=2.4.0" + }, + "dependencies": { + "@heroui/shared-utils": "workspace:*", + "@heroui/react-utils": "workspace:*", + "@heroui/shared-icons": "workspace:*", + "@react-aria/disclosure": "^3.0.0", + "@react-aria/button": "^3.11.0", + "@react-stately/disclosure": "^3.0.0", + "@react-aria/utils": "3.26.0", + "@react-aria/focus": "3.19.0" + }, + "devDependencies": { + "@heroui/theme": "workspace:*", + "@heroui/system": "workspace:*", + "@heroui/avatar": "workspace:*", + "@heroui/input": "workspace:*", + "@heroui/button": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/disclosure/src/disclosure.tsx b/packages/components/disclosure/src/disclosure.tsx new file mode 100644 index 0000000000..87297ba368 --- /dev/null +++ b/packages/components/disclosure/src/disclosure.tsx @@ -0,0 +1,73 @@ +import {forwardRef} from "@heroui/system"; +import {ChevronIcon} from "@heroui/shared-icons"; +import {ReactNode, useMemo} from "react"; + +import {UseDisclosureProps, useDisclosure} from "./use-disclosure"; + +export interface DisclosureProps extends UseDisclosureProps {} + +const Disclosure = forwardRef<"div", DisclosureProps>((props, ref) => { + const { + Component, + HeadingComponent, + domRef, + slots, + classNames, + startContent, + title, + subtitle, + children, + isExpanded, + isDisabled, + indicator, + hideIndicator, + keepContentMounted, + getBaseProps, + getTriggerProps, + getContentProps, + getHeadingProps, + getTitleProps, + getSubtitleProps, + getIndicatorProps, + } = useDisclosure({...props, ref}); + + const indicatorContent = useMemo(() => { + if (typeof indicator === "function") { + return indicator({indicator: , isExpanded: isExpanded, isDisabled}); + } + + if (indicator) return indicator; + + return null; + }, [indicator, isExpanded, isDisabled]); + + const indicatorComponent = indicatorContent || ; + + return ( + + + + +

+ {keepContentMounted || isExpanded ? children : null} +
+ + ); +}); + +Disclosure.displayName = "NextUI.Disclosure"; + +export default Disclosure; diff --git a/packages/components/disclosure/src/index.ts b/packages/components/disclosure/src/index.ts new file mode 100644 index 0000000000..f7dc7be37d --- /dev/null +++ b/packages/components/disclosure/src/index.ts @@ -0,0 +1,10 @@ +import Disclosure from "./disclosure"; + +// export types +export type {DisclosureProps} from "./disclosure"; + +// export hooks +export {useDisclosure as useDisclosureComponent} from "./use-disclosure"; + +// export component +export {Disclosure}; diff --git a/packages/components/disclosure/src/use-disclosure.ts b/packages/components/disclosure/src/use-disclosure.ts new file mode 100644 index 0000000000..524c9093b0 --- /dev/null +++ b/packages/components/disclosure/src/use-disclosure.ts @@ -0,0 +1,260 @@ +import type {DisclosureSlots, DisclosureVariantProps, SlotsToClasses} from "@heroui/theme"; + +import {disclosure} from "@heroui/theme"; +import {As, HTMLHeroUIProps, mapPropsVariants, PropGetter} from "@heroui/system"; +import {ReactRef, useDOMRef} from "@heroui/react-utils"; +import {useDisclosure as useAriaDisclosure} from "@react-aria/disclosure"; +import {DisclosureProps, useDisclosureState} from "@react-stately/disclosure"; +import {ReactNode, useCallback, useMemo, useRef} from "react"; +import {clsx, dataAttr, objectToDeps} from "@heroui/shared-utils"; +import {chain, mergeProps} from "@react-aria/utils"; +import {useButton} from "@react-aria/button"; +import {useFocusRing} from "@react-aria/focus"; +import {usePress, useHover} from "@react-aria/interactions"; +import {PressEvents} from "@react-types/shared"; + +export type DisclosureIndicatorProps = { + /** + * The current indicator, usually an arrow icon. + */ + indicator?: ReactNode; + /** + * The current open status. + */ + isExpanded?: boolean; + /** + * The current disabled status. + * @default false + */ + isDisabled?: boolean; +}; + +interface Props extends Omit, "title"> { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + /** + * Start icon to be displayed inside the disclosure. + */ + startContent?: ReactNode; + /** + * The accordion item `expanded` indicator, it's usually an arrow icon. + * If you pass a function, NextUI will expose the current indicator and the open status, + * In case you want to use a custom indicator or modify the current one. + */ + indicator?: ReactNode | ((props: DisclosureIndicatorProps) => ReactNode) | null; + /** + * Customizable heading tag for Web accessibility: + * use headings to describe content and use them consistently and semantically. + * This will help all users to better find the content they are looking for. + */ + HeadingComponent?: As; + /** + * The content of the component. + */ + children?: ReactNode | null; + title?: ReactNode | string; + subtitle?: ReactNode | string; + classNames?: SlotsToClasses; + keepContentMounted?: boolean; +} + +export type UseDisclosureProps = Props & DisclosureVariantProps & DisclosureProps & PressEvents; + +export function useDisclosure(originalProps: UseDisclosureProps) { + const [props, variantProps] = mapPropsVariants(originalProps, disclosure.variantKeys); + const { + ref, + as, + className, + defaultExpanded, + onExpandedChange, + classNames, + title, + subtitle, + startContent, + children, + HeadingComponent = as || "h2", + indicator, + onPress, + onPressStart, + onPressEnd, + onPressChange, + onPressUp, + onClick, + } = props; + + const Component = as || "div"; + const domRef = useDOMRef(ref); + const { + isDisabled, + isExpanded: isExpandedProp, + isCompact = false, + hideIndicator = false, + disableIndicatorAnimation = false, + disableAnimation = false, + hidden = false, + keepContentMounted = false, + } = originalProps; + + const slots = useMemo( + () => + disclosure({ + ...variantProps, + disableAnimation, + disableIndicatorAnimation, + isCompact, + className, + }), + [objectToDeps(variantProps), className, disableAnimation, disableIndicatorAnimation, isCompact], + ); + + const state = useDisclosureState({ + isExpanded: isExpandedProp, + defaultExpanded, + onExpandedChange: (isExpanded) => { + onExpandedChange?.(isExpanded); + }, + }); + const isExpanded = state.isExpanded; + + const {isFocused, isFocusVisible, focusProps} = useFocusRing({ + autoFocus: originalProps?.autoFocus, + }); + + const triggerRef = useRef(null); + const contentRef = useRef(null); + + const {buttonProps: triggerProps, panelProps: contentProps} = useAriaDisclosure( + props, + state, + contentRef, + ); + + const {buttonProps} = useButton(triggerProps, triggerRef); + + const {pressProps, isPressed} = usePress({ + ref: domRef, + isDisabled, + onPress, + onPressStart, + onPressEnd, + onPressChange, + onPressUp, + }); + const {isHovered, hoverProps} = useHover({isDisabled}); + + const getBaseProps = useCallback( + (props = {}) => ({ + className: slots.base({class: clsx(classNames?.base, props?.className)}), + ...props, + }), + [], + ); + + const getHeadingProps = useCallback( + (props = {}) => ({ + className: slots.heading({class: clsx(classNames?.heading, props?.className)}), + ...props, + }), + [], + ); + + const getTriggerProps = useCallback( + (props = {}) => ({ + ref: triggerRef, + className: slots.trigger({class: clsx(classNames?.trigger, props?.className)}), + "aria-expanded": isExpanded, + "data-expanded": isExpanded, + "data-pressed": dataAttr(isPressed), + "data-hover": dataAttr(isHovered), + "data-focus": dataAttr(isFocused), + "data-disabled": dataAttr(isDisabled), + "data-focus-visible": dataAttr(isFocusVisible), + onFocus: chain(originalProps.onFocus, focusProps.onFocus), + onBlur: chain(originalProps.onBlur, focusProps.onBlur), + ...mergeProps(buttonProps, props, focusProps, hoverProps, pressProps, { + onClick: chain(pressProps.onClick, onClick), + }), + disabled: isDisabled, + hidden, + }), + [triggerProps, focusProps, pressProps, isExpanded, isDisabled, hidden], + ); + + const getContentProps = useCallback( + (props = {}) => ({ + ref: contentRef, + className: slots.content({class: clsx(classNames?.content, props?.className)}), + "data-expanded": dataAttr(isExpanded), + ...mergeProps(contentProps, props), + }), + [contentProps, contentRef, isExpanded], + ); + + const getTitleProps = useCallback( + (props = {}) => { + return { + "data-expanded": dataAttr(isExpanded), + "data-disabled": dataAttr(isDisabled), + className: slots.title({class: classNames?.title}), + ...props, + }; + }, + [slots, classNames?.title, isExpanded, isDisabled], + ); + + const getSubtitleProps = useCallback( + (props = {}) => { + return { + "data-expanded": dataAttr(isExpanded), + "data-disabled": dataAttr(isDisabled), + className: slots.subtitle({class: classNames?.subtitle}), + ...props, + }; + }, + [slots, classNames?.subtitle, isExpanded, isDisabled], + ); + + const getIndicatorProps = useCallback( + (props = {}) => { + return { + "aria-hidden": dataAttr(true), + "data-expanded": dataAttr(isExpanded), + "data-disabled": dataAttr(isDisabled), + className: slots.indicator({class: classNames?.indicator}), + ...props, + }; + }, + [slots, classNames?.indicator, isExpanded, isDisabled, classNames?.indicator], + ); + + return { + Component, + HeadingComponent, + domRef, + startContent, + classNames, + slots, + title, + subtitle, + children, + isExpanded, + isDisabled, + indicator, + hideIndicator, + contentRef, + keepContentMounted, + getBaseProps, + getTriggerProps, + getContentProps, + getHeadingProps, + getTitleProps, + getSubtitleProps, + getIndicatorProps, + state, + }; +} + +export type UseDisclosureReturn = ReturnType; diff --git a/packages/components/disclosure/stories/disclosure.stories.tsx b/packages/components/disclosure/stories/disclosure.stories.tsx new file mode 100644 index 0000000000..1a3fc2739a --- /dev/null +++ b/packages/components/disclosure/stories/disclosure.stories.tsx @@ -0,0 +1,252 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {button, disclosure} from "@heroui/theme"; +import {Avatar} from "@heroui/avatar"; +import {Button} from "@heroui/button"; +import {Input, Textarea} from "@heroui/input"; +import {MonitorMobileIcon, MoonIcon} from "@heroui/shared-icons"; + +import {Disclosure, DisclosureProps} from "../src"; + +export default { + title: "Components/Disclosure", + component: Disclosure, + argTypes: { + isDisabled: { + control: { + type: "boolean", + }, + }, + }, +} as Meta; + +const defaultProps = { + ...disclosure.defaultVariants, +}; + +const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamcolaboris nisi ut aliquip ex ea commodo consequat."; + +const Template = (args: DisclosureProps) => ( + + {defaultContent} + +); + +const TemplateWithStartContent = () => ( + + } + subtitle="4 unread messages" + title="Chung Miller" + > + {defaultContent} + +); + +const WithFormTemplate = (args: DisclosureProps) => { + const form = ( +
+ + // eslint-disable-next-line no-console + console.log(value) + } + /> + +