From ba76625840ee070cb1df97a96aed439ecd75f086 Mon Sep 17 00:00:00 2001 From: Eric Sizer Date: Thu, 23 Mar 2023 14:18:56 -0400 Subject: [PATCH 1/6] create initial inline toggle section --- package-lock.json | 9 +- packages/ui/package.json | 4 +- .../ToggleSection/ToggleSection.stories.tsx | 38 ++++ .../ToggleSection/ToggleSection.tsx | 199 ++++++++++++++++++ .../ToggleSection/ToggleSectionProvider.tsx | 44 ++++ 5 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx create mode 100644 packages/ui/src/components/ToggleSection/ToggleSection.tsx create mode 100644 packages/ui/src/components/ToggleSection/ToggleSectionProvider.tsx diff --git a/package-lock.json b/package-lock.json index be7c90b98c8..e39592ace55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5320,7 +5320,8 @@ }, "node_modules/@radix-ui/react-slot": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" @@ -34574,8 +34575,10 @@ "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-scroll-area": "^1.0.3", "@radix-ui/react-separator": "^1.0.2", + "@radix-ui/react-slot": "^1.0.1", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", + "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", "lodash": "4.17.21", "react-csv": "2.2.2", @@ -36737,8 +36740,10 @@ "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-scroll-area": "^1.0.3", "@radix-ui/react-separator": "^1.0.2", + "@radix-ui/react-slot": "*", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", + "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", "@storybook/addon-actions": "^6.5.14", "@storybook/react": "^6.5.16", @@ -38857,6 +38862,8 @@ }, "@radix-ui/react-slot": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "requires": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" diff --git a/packages/ui/package.json b/packages/ui/package.json index ab5820c1cdb..b349a61c876 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,8 +25,8 @@ "dependencies": { "@gc-digital-talent/graphql": "*", "@gc-digital-talent/helpers": "*", - "@gc-digital-talent/jest-helpers": "*", "@gc-digital-talent/i18n": "*", + "@gc-digital-talent/jest-helpers": "*", "@headlessui/react": "^1.7.13", "@heroicons/react": "^2.0.16", "@radix-ui/react-accordion": "^1.1.1", @@ -36,8 +36,10 @@ "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-scroll-area": "^1.0.3", "@radix-ui/react-separator": "^1.0.2", + "@radix-ui/react-slot": "^1.0.1", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", + "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", "lodash": "4.17.21", "react-csv": "2.2.2", diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx new file mode 100644 index 00000000000..cf4f1c90a8c --- /dev/null +++ b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import type { ComponentStory, ComponentMeta } from "@storybook/react"; + +import ToggleSection from "./ToggleSection"; +import Heading from "../Heading"; +import Button from "../Button"; + +export default { + component: ToggleSection.Root, + title: "Components/Toggle Section", +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + Toggle Section + + + + + + +

Initial Content Here

+ + + +
+ + +

Open Content Here

+ + + +
+
+
+); + +export const Default = Template.bind({}); diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.tsx new file mode 100644 index 00000000000..ad7221a9b49 --- /dev/null +++ b/packages/ui/src/components/ToggleSection/ToggleSection.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; + +import { + ToggleSectionProvider, + useToggleSectionContext, +} from "./ToggleSectionProvider"; + +interface RootProps { + defaultOpen?: boolean; + onOpenToggle?: (open: boolean) => void; + children: React.ReactNode; +} + +const Root = React.forwardRef( + ({ defaultOpen, onOpenToggle, children }, forwardedRef) => { + const [open, setOpen] = React.useState(defaultOpen || false); + + const handleOpenToggle = React.useCallback(() => { + setOpen((prevOpen) => { + const newOpen = !prevOpen; + if (onOpenToggle) { + onOpenToggle(newOpen); + } + return newOpen; + }); + }, [onOpenToggle]); + + return ( + +
+ {children} +
+
+ ); + }, +); + +type TriggerProps = { + children: React.ReactNode; + openText?: React.ReactNode; +}; + +const Content = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(({ children, ...props }, forwardedRef) => { + const context = useToggleSectionContext(); + + return ( +
+ {children} +
+ ); +}); + +const INITIAL_CONTENT_NAME = "InitialContent"; + +const InitialContent = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(({ children, ...props }, forwardedRef) => { + const context = useToggleSectionContext(); + let id; + if (context?.contentId) { + id = `${INITIAL_CONTENT_NAME}-${context.contentId}`; + } + + return ( +
+ {context?.open ? null : children} +
+ ); +}); + +const OPEN_CONTENT_NAME = "InitialContent"; + +const OpenContent = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(({ children, ...props }, forwardedRef) => { + const context = useToggleSectionContext(); + let id; + if (context?.contentId) { + id = `${OPEN_CONTENT_NAME}-${context.contentId}`; + } + + return ( +
+ {context?.open ? children : null} +
+ ); +}); + +const composeControls = (contentId?: string) => { + let controls; + if (contentId) { + controls = `${INITIAL_CONTENT_NAME}-${contentId} ${OPEN_CONTENT_NAME}-${contentId}`; + } + + return controls; +}; + +const Trigger = React.forwardRef< + React.ElementRef, + TriggerProps +>(({ children, ...toggleProps }, forwardedRef) => { + const context = useToggleSectionContext(); + const controls = composeControls(context?.contentId); + + return ( + + {children} + + ); +}); + +interface ToggleProps extends React.HTMLAttributes { + children?: React.ReactNode; + open: boolean; +} + +const Toggle = React.forwardRef( + ({ onClick, open, ...props }, forwardedRef) => { + const context = useToggleSectionContext(); + const controls = composeControls(context?.contentId); + + const handleClick: React.MouseEventHandler = (event) => { + if (onClick) { + onClick(event); + } + + context?.onOpenChange?.(open); + }; + + return ( + + ); + }, +); + +const Open = React.forwardRef>( + (props, forwardedRef) => { + return ; + }, +); + +const Close = React.forwardRef>( + (props, forwardedRef) => { + return ; + }, +); + +export default { + Root, + Trigger, + InitialContent, + OpenContent, + Content, + Open, + Close, +}; diff --git a/packages/ui/src/components/ToggleSection/ToggleSectionProvider.tsx b/packages/ui/src/components/ToggleSection/ToggleSectionProvider.tsx new file mode 100644 index 00000000000..a4fd791569c --- /dev/null +++ b/packages/ui/src/components/ToggleSection/ToggleSectionProvider.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +type ToggleSectionContextValue = { + contentId: string; + open: boolean; + onOpenToggle?: () => void; + onOpenChange?: (newOpen: boolean) => void; +}; + +export const ToggleSectionContext = React.createContext< + ToggleSectionContextValue | undefined +>(undefined); + +type ToggleSectionProviderProps = { + children: React.ReactNode; +} & ToggleSectionContextValue; + +export const ToggleSectionProvider = ({ + children, + ...context +}: ToggleSectionProviderProps) => { + const { contentId, open, onOpenToggle, onOpenChange } = context; + const value = React.useMemo( + () => ({ + contentId, + open, + onOpenToggle, + onOpenChange, + }), + [contentId, open, onOpenToggle, onOpenChange], + ); + + return ( + + {children} + + ); +}; + +export const useToggleSectionContext = () => { + const context = React.useContext(ToggleSectionContext); + + return context; +}; From 997b597ab3369968b2dbf333509f8714202637fc Mon Sep 17 00:00:00 2001 From: Eric Sizer Date: Thu, 23 Mar 2023 14:32:53 -0400 Subject: [PATCH 2/6] add header section with open/close components --- .../ToggleSection/ToggleSection.stories.tsx | 19 +++++++--- .../ToggleSection/ToggleSection.tsx | 36 ++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx index cf4f1c90a8c..722e706f88a 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx @@ -1,10 +1,20 @@ import React from "react"; import type { ComponentStory, ComponentMeta } from "@storybook/react"; +import { AcademicCapIcon } from "@heroicons/react/24/solid"; import ToggleSection from "./ToggleSection"; -import Heading from "../Heading"; import Button from "../Button"; +const Toggle = () => { + const context = ToggleSection.useContext(); + + return ( + + + + ); +}; + export default { component: ToggleSection.Root, title: "Components/Toggle Section", @@ -12,10 +22,9 @@ export default { const Template: ComponentStory = (args) => ( - Toggle Section - - - + }> + Toggle Section + diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.tsx index ad7221a9b49..a2c1b63de07 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Slot } from "@radix-ui/react-slot"; import * as TogglePrimitive from "@radix-ui/react-toggle"; +import Heading, { HeadingProps } from "../Heading"; + import { ToggleSectionProvider, useToggleSectionContext, @@ -34,7 +36,13 @@ const Root = React.forwardRef( onOpenToggle={handleOpenToggle} onOpenChange={setOpen} > -
+
{children}
@@ -188,6 +196,30 @@ const Close = React.forwardRef>( }, ); +interface HeaderProps extends HeadingProps { + toggle: React.ReactElement; +} + +const Header = React.forwardRef( + ({ toggle, ...headingProps }, forwardedRef) => { + return ( +
+ +
{toggle}
+
+ ); + }, +); + export default { Root, Trigger, @@ -196,4 +228,6 @@ export default { Content, Open, Close, + Header, + useContext: useToggleSectionContext, }; From 338da454c18f164443bcf8d69f7553afeae5159c Mon Sep 17 00:00:00 2001 From: Eric Sizer Date: Thu, 23 Mar 2023 15:20:58 -0400 Subject: [PATCH 3/6] add documentation to the toggle section --- .../ToggleSection/ToggleSection.tsx | 117 +++++++++++++----- 1 file changed, 87 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.tsx index a2c1b63de07..3f107c2b50c 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.tsx @@ -9,12 +9,56 @@ import { useToggleSectionContext, } from "./ToggleSectionProvider"; +// Names used for unique IDs +const NAME = { + INITIAL_CONTENT: "InitialContent", + OPEN_CONTENT: "OpenContent", +}; + +/** + * Compose the value for `aria-controls` + * attribute on toggles + * + * @param contentId string The unique ID for this section + * @returns string + */ +const composeControls = (contentId?: string) => { + let controls; + if (contentId) { + controls = `${NAME.INITIAL_CONTENT}-${contentId} ${NAME.OPEN_CONTENT}-${contentId}`; + } + + return controls; +}; + +/** + * Creates a unique ID for content sections + * + * @param name string ID prefix (component name) + * @param contentId string The unique ID for this section + * @returns string + */ +const composeId = (name: string, contentId?: string) => { + let id; + if (contentId) { + id = `${name}-${contentId}`; + } + + return id; +}; + interface RootProps { + /** Sets the section to be 'open' by default */ defaultOpen?: boolean; + /** Callback when the section has been 'opened */ onOpenToggle?: (open: boolean) => void; children: React.ReactNode; } +/** + * The root component that contains all other components + * and provides context to those components + */ const Root = React.forwardRef( ({ defaultOpen, onOpenToggle, children }, forwardedRef) => { const [open, setOpen] = React.useState(defaultOpen || false); @@ -50,11 +94,10 @@ const Root = React.forwardRef( }, ); -type TriggerProps = { - children: React.ReactNode; - openText?: React.ReactNode; -}; - +/** + * A wrapper used to style the content + * portion of the section + */ const Content = React.forwardRef< HTMLDivElement, React.HTMLProps @@ -77,17 +120,16 @@ const Content = React.forwardRef< ); }); -const INITIAL_CONTENT_NAME = "InitialContent"; - +/** + * The content that is displayed when + * the section is 'closed' + */ const InitialContent = React.forwardRef< HTMLDivElement, React.HTMLProps >(({ children, ...props }, forwardedRef) => { const context = useToggleSectionContext(); - let id; - if (context?.contentId) { - id = `${INITIAL_CONTENT_NAME}-${context.contentId}`; - } + const id = composeId(NAME.INITIAL_CONTENT, context?.contentId); return (
>(({ children, ...props }, forwardedRef) => { const context = useToggleSectionContext(); - let id; - if (context?.contentId) { - id = `${OPEN_CONTENT_NAME}-${context.contentId}`; - } + const id = composeId(NAME.OPEN_CONTENT, context?.contentId); return (
{ - let controls; - if (contentId) { - controls = `${INITIAL_CONTENT_NAME}-${contentId} ${OPEN_CONTENT_NAME}-${contentId}`; - } - - return controls; -}; - +/** + * A toggle that opens and closes + * the section + * + * SEE: https://www.radix-ui.com/docs/primitives/components/toggle + */ const Trigger = React.forwardRef< React.ElementRef, - TriggerProps ->(({ children, ...toggleProps }, forwardedRef) => { + Omit, "asChild"> +>(({ children, onPressedChange, ...toggleProps }, forwardedRef) => { const context = useToggleSectionContext(); const controls = composeControls(context?.contentId); + const handleOnPressedChange = (newPressed: boolean) => { + context?.onOpenToggle?.(); + + onPressedChange?.(newPressed); + }; + return ( {children} @@ -157,6 +201,7 @@ const Trigger = React.forwardRef< interface ToggleProps extends React.HTMLAttributes { children?: React.ReactNode; + /** Determine if this toggle opens or closes the section */ open: boolean; } @@ -184,12 +229,20 @@ const Toggle = React.forwardRef( }, ); +/** + * Generic wrapper that attaches a click + * handler to a button that 'opens' the section + */ const Open = React.forwardRef>( (props, forwardedRef) => { return ; }, ); +/** + * Generic wrapper that attaches a click + * handler to a button that 'closes' the section + */ const Close = React.forwardRef>( (props, forwardedRef) => { return ; @@ -197,9 +250,13 @@ const Close = React.forwardRef>( ); interface HeaderProps extends HeadingProps { + /** The toggle for the component (appears on right side of header) */ toggle: React.ReactElement; } +/** + * A styled header for the section + */ const Header = React.forwardRef( ({ toggle, ...headingProps }, forwardedRef) => { return ( From 4ba00d3a26376a3be462267e89169d19dfb4c173 Mon Sep 17 00:00:00 2001 From: Eric Sizer Date: Thu, 23 Mar 2023 15:36:44 -0400 Subject: [PATCH 4/6] update to use controlled state --- package-lock.json | 9 +++++++-- packages/ui/package.json | 1 + .../ToggleSection/ToggleSection.stories.tsx | 6 +++++- .../components/ToggleSection/ToggleSection.tsx | 18 +++++++++++------- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e39592ace55..33bcab691fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5415,7 +5415,8 @@ }, "node_modules/@radix-ui/react-use-controllable-state": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" @@ -34580,6 +34581,7 @@ "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", + "@radix-ui/react-use-controllable-state": "^1.0.0", "lodash": "4.17.21", "react-csv": "2.2.2", "react-dom": "^18.2.0", @@ -36740,11 +36742,12 @@ "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-scroll-area": "^1.0.3", "@radix-ui/react-separator": "^1.0.2", - "@radix-ui/react-slot": "*", + "@radix-ui/react-slot": "^1.0.1", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", + "@radix-ui/react-use-controllable-state": "*", "@storybook/addon-actions": "^6.5.14", "@storybook/react": "^6.5.16", "@swc/core": "^1.3.42", @@ -38934,6 +38937,8 @@ }, "@radix-ui/react-use-controllable-state": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "requires": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" diff --git a/packages/ui/package.json b/packages/ui/package.json index b349a61c876..c0f73969f30 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", + "@radix-ui/react-use-controllable-state": "^1.0.0", "lodash": "4.17.21", "react-csv": "2.2.2", "react-dom": "^18.2.0", diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx index 722e706f88a..b102f0444e2 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { ComponentStory, ComponentMeta } from "@storybook/react"; import { AcademicCapIcon } from "@heroicons/react/24/solid"; +import { action } from "@storybook/addon-actions"; import ToggleSection from "./ToggleSection"; import Button from "../Button"; @@ -21,7 +22,10 @@ export default { } as ComponentMeta; const Template: ComponentStory = (args) => ( - + action("onOpenToggle")(open)} + > }> Toggle Section diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.tsx index 3f107c2b50c..308e8db385f 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Slot } from "@radix-ui/react-slot"; +import { useControllableState } from "@radix-ui/react-use-controllable-state"; import * as TogglePrimitive from "@radix-ui/react-toggle"; import Heading, { HeadingProps } from "../Heading"; @@ -50,8 +51,10 @@ const composeId = (name: string, contentId?: string) => { interface RootProps { /** Sets the section to be 'open' by default */ defaultOpen?: boolean; + /** Controllable open state */ + open?: boolean; /** Callback when the section has been 'opened */ - onOpenToggle?: (open: boolean) => void; + onOpenChange?: (open: boolean) => void; children: React.ReactNode; } @@ -60,18 +63,19 @@ interface RootProps { * and provides context to those components */ const Root = React.forwardRef( - ({ defaultOpen, onOpenToggle, children }, forwardedRef) => { - const [open, setOpen] = React.useState(defaultOpen || false); + ({ defaultOpen, open: openProp, onOpenChange, children }, forwardedRef) => { + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); const handleOpenToggle = React.useCallback(() => { setOpen((prevOpen) => { const newOpen = !prevOpen; - if (onOpenToggle) { - onOpenToggle(newOpen); - } return newOpen; }); - }, [onOpenToggle]); + }, [setOpen]); return ( Date: Tue, 28 Mar 2023 09:07:52 -0400 Subject: [PATCH 5/6] re-implement radix hooks --- package-lock.json | 2 - packages/ui/package.json | 1 - .../ToggleSection/ToggleSection.tsx | 8 +- packages/ui/src/hooks/useCallbackRef.ts | 25 +++++++ packages/ui/src/hooks/useControllableState.ts | 75 +++++++++++++++++++ 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/hooks/useCallbackRef.ts create mode 100644 packages/ui/src/hooks/useControllableState.ts diff --git a/package-lock.json b/package-lock.json index 33bcab691fa..3cf238ece90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34581,7 +34581,6 @@ "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", - "@radix-ui/react-use-controllable-state": "^1.0.0", "lodash": "4.17.21", "react-csv": "2.2.2", "react-dom": "^18.2.0", @@ -36747,7 +36746,6 @@ "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", - "@radix-ui/react-use-controllable-state": "*", "@storybook/addon-actions": "^6.5.14", "@storybook/react": "^6.5.16", "@swc/core": "^1.3.42", diff --git a/packages/ui/package.json b/packages/ui/package.json index c0f73969f30..b349a61c876 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -41,7 +41,6 @@ "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toggle": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.3", - "@radix-ui/react-use-controllable-state": "^1.0.0", "lodash": "4.17.21", "react-csv": "2.2.2", "react-dom": "^18.2.0", diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.tsx index 308e8db385f..d68c36c05f6 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.tsx @@ -1,9 +1,9 @@ import React from "react"; import { Slot } from "@radix-ui/react-slot"; -import { useControllableState } from "@radix-ui/react-use-controllable-state"; import * as TogglePrimitive from "@radix-ui/react-toggle"; import Heading, { HeadingProps } from "../Heading"; +import useControllableState from "../../hooks/useControllableState"; import { ToggleSectionProvider, @@ -64,9 +64,9 @@ interface RootProps { */ const Root = React.forwardRef( ({ defaultOpen, open: openProp, onOpenChange, children }, forwardedRef) => { - const [open = false, setOpen] = useControllableState({ - prop: openProp, - defaultProp: defaultOpen, + const [open = false, setOpen] = useControllableState({ + controlledProp: openProp, + defaultValue: defaultOpen, onChange: onOpenChange, }); diff --git a/packages/ui/src/hooks/useCallbackRef.ts b/packages/ui/src/hooks/useCallbackRef.ts new file mode 100644 index 00000000000..eeb61edd995 --- /dev/null +++ b/packages/ui/src/hooks/useCallbackRef.ts @@ -0,0 +1,25 @@ +import React from "react"; + +// Note: This is a generic type and can accept any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericFunc = (...args: any[]) => any; + +/** + * Stabilize a callback with React.useRef + * + * Ref: https://github.com/radix-ui/primitives/tree/main/packages/react/use-callback-ref + */ +const useCallbackRef = (callback: T | undefined): T => { + const callbackRef = React.useRef(callback); + + React.useEffect(() => { + callbackRef.current = callback; + }); + + return React.useMemo( + () => ((...args) => callbackRef.current?.(...args)) as T, + [], + ); +}; + +export default useCallbackRef; diff --git a/packages/ui/src/hooks/useControllableState.ts b/packages/ui/src/hooks/useControllableState.ts new file mode 100644 index 00000000000..647fe77efd6 --- /dev/null +++ b/packages/ui/src/hooks/useControllableState.ts @@ -0,0 +1,75 @@ +import React from "react"; + +import useCallbackRef from "./useCallbackRef"; + +type UseControllableStateArgs = { + // The state prop being controlled + controlledProp?: T | undefined; + // A default value for the state + defaultValue: T | undefined; + // Callback for when state changes + onChange?: (state: T) => void; +}; + +export type SetStateFunc = (prevState?: T) => T; + +const useUnControlledState = ({ + defaultValue, + onChange, +}: Omit, "controlledProp">) => { + const unControlledState = React.useState(defaultValue); + const [value] = unControlledState; + const prevValueRef = React.useRef(value); + const handleChange = useCallbackRef(onChange); + + React.useEffect(() => { + if (prevValueRef.current !== value) { + handleChange(value as T); + prevValueRef.current = value; + } + }, [value, prevValueRef, handleChange]); + + return unControlledState; +}; + +/** + * A controlled version of React.useState + * + * Ref: https://github.com/radix-ui/primitives/tree/main/packages/react/use-controllable-state + */ +const useControllableState = ({ + controlledProp, + defaultValue, + // Note: Setting a default here ( we do want it to be empty ) + // eslint-disable-next-line @typescript-eslint/no-empty-function + onChange = () => {}, +}: UseControllableStateArgs) => { + const [unControlledProp, setUncontrolledProp] = useUnControlledState({ + defaultValue, + onChange, + }); + const isControlled = controlledProp !== undefined; + const value = isControlled ? controlledProp : unControlledProp; + const handleChange = useCallbackRef(onChange); + + const setValue: React.Dispatch> = + React.useCallback( + (newValue) => { + if (isControlled) { + const setter = newValue as SetStateFunc; + const nextValue = + typeof newValue === "function" ? setter(controlledProp) : newValue; + if (nextValue !== controlledProp) { + handleChange(nextValue as T); + } + } else { + setUncontrolledProp(newValue); + } + }, + [controlledProp, handleChange, isControlled, setUncontrolledProp], + ); + + return [value, setValue] as const; +}; + +export default useControllableState; From bbf75830e5d65a132d866b5dff36cb24fc31b79f Mon Sep 17 00:00:00 2001 From: Eric Sizer Date: Tue, 28 Mar 2023 09:12:39 -0400 Subject: [PATCH 6/6] add story for nested toggle sections --- .../ToggleSection/ToggleSection.stories.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx index b102f0444e2..ea348dbc4b0 100644 --- a/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx +++ b/packages/ui/src/components/ToggleSection/ToggleSection.stories.tsx @@ -49,3 +49,48 @@ const Template: ComponentStory = (args) => ( ); export const Default = Template.bind({}); + +const NestedTemplate: ComponentStory = (args) => ( + action("onOpenToggle")(open)} + > + }> + Toggle Section + + + + +

Initial Content Here

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

Nested Initial Content Here

+
+ + +

Nested Open Content Here

+
+
+
+
+ + +

Open Content Here

+ + + +
+
+
+); + +export const Nested = NestedTemplate.bind({});