@sproutsocial/seeds-react-collapsible 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintignore ADDED
@@ -0,0 +1,6 @@
1
+ # Node modules
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+ coverage/
package/.eslintrc.js ADDED
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ["eslint-config-seeds/racine"],
4
+ };
@@ -0,0 +1,21 @@
1
+ yarn run v1.22.22
2
+ $ tsup --dts
3
+ CLI Building entry: src/index.ts
4
+ CLI Using tsconfig: tsconfig.json
5
+ CLI tsup v8.0.2
6
+ CLI Using tsup config: /home/runner/work/seeds/seeds/seeds-react/seeds-react-collapsible/tsup.config.ts
7
+ CLI Target: es2022
8
+ CLI Cleaning output folder
9
+ CJS Build start
10
+ ESM Build start
11
+ ESM dist/esm/index.js 4.68 KB
12
+ ESM dist/esm/index.js.map 17.91 KB
13
+ ESM ⚡️ Build success in 223ms
14
+ CJS dist/index.js 6.58 KB
15
+ CJS dist/index.js.map 17.90 KB
16
+ CJS ⚡️ Build success in 254ms
17
+ DTS Build start
18
+ DTS ⚡️ Build success in 15193ms
19
+ DTS dist/index.d.ts 1.03 KB
20
+ DTS dist/index.d.mts 1.03 KB
21
+ Done in 18.99s.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @sproutsocial/seeds-react-collapsible
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 9ec95ec: Migrated Collapsible from Racine to seeds-react-collapsible
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+ import { useState } from "react";
3
+ import {
4
+ render,
5
+ fireEvent,
6
+ screen,
7
+ } from "@sproutsocial/seeds-react-testing-library";
8
+ import Box from "@sproutsocial/seeds-react-box";
9
+ import Button from "@sproutsocial/seeds-react-button";
10
+ import { Collapsible } from "../src";
11
+
12
+ export interface TypeStatefulCollapseTestProps {
13
+ children: React.ReactNode;
14
+ isOpen?: boolean;
15
+ }
16
+
17
+ const StatefulCollapse = ({
18
+ isOpen = false,
19
+ children,
20
+ }: TypeStatefulCollapseTestProps) => {
21
+ const [open, setOpen] = useState(isOpen);
22
+
23
+ const toggle = () => setOpen(!open);
24
+
25
+ return (
26
+ <Collapsible isOpen={open}>
27
+ <Collapsible.Trigger>
28
+ <Button appearance="secondary" onClick={toggle}>
29
+ {open ? "Hide" : "Show"}
30
+ </Button>
31
+ </Collapsible.Trigger>
32
+
33
+ <Collapsible.Panel>{children}</Collapsible.Panel>
34
+ </Collapsible>
35
+ );
36
+ };
37
+
38
+ describe("Collapsible", () => {
39
+ it("should render properly", async () => {
40
+ const { container } = render(
41
+ <StatefulCollapse>
42
+ <Box />
43
+ </StatefulCollapse>
44
+ );
45
+ expect(container).toBeTruthy();
46
+ });
47
+
48
+ it("should open panel when clicking trigger", async () => {
49
+ render(<StatefulCollapse>Panel text</StatefulCollapse>);
50
+ const trigger = screen.queryByRole("button");
51
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
52
+ // TS thinks the trigger might be null even though the previous expect would have failed if it was
53
+ trigger && fireEvent.click(trigger);
54
+ const panel = screen.queryByText(/Panel text/);
55
+ expect(panel).toHaveAttribute("aria-hidden", "false");
56
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
57
+ });
58
+
59
+ it("should render open when isOpen is true", async () => {
60
+ render(<StatefulCollapse isOpen>Panel text</StatefulCollapse>);
61
+ const trigger = screen.queryByRole("button");
62
+ const panel = screen.queryByText(/Panel text/);
63
+ expect(panel).toHaveAttribute("aria-hidden", "false");
64
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
65
+ });
66
+
67
+ it("trigger should be properly labelled", async () => {
68
+ render(<StatefulCollapse isOpen>Panel text</StatefulCollapse>);
69
+ const trigger = screen.queryByRole("button");
70
+ const panel = screen.queryByText(/Panel text/);
71
+ expect(trigger).toHaveAttribute("aria-controls", panel?.id);
72
+ });
73
+ });
@@ -0,0 +1,59 @@
1
+ import * as React from "react";
2
+ import { render } from "@sproutsocial/seeds-react-testing-library";
3
+ import Box from "@sproutsocial/seeds-react-box";
4
+ import Button from "@sproutsocial/seeds-react-button";
5
+ import { Collapsible } from "../src";
6
+
7
+ const toggle = jest.fn();
8
+
9
+ describe.skip("Collapsible/types", () => {
10
+ it("should render valid props", () => {
11
+ const contentWithButton = (
12
+ <Box
13
+ width="100%"
14
+ height="200px"
15
+ bg="container.background.base"
16
+ p={400}
17
+ mt="100px"
18
+ >
19
+ <Button appearance="secondary">A button</Button>
20
+ </Box>
21
+ );
22
+ const shortContent = (
23
+ <Box width="15%" height="50px" bg="container.background.base" p={400}>
24
+ Hello.
25
+ </Box>
26
+ );
27
+ render(
28
+ <>
29
+ <Collapsible isOpen={true} offset={0}>
30
+ <Collapsible.Trigger>
31
+ <Button appearance="secondary" onClick={toggle}>
32
+ Hide
33
+ </Button>
34
+ </Collapsible.Trigger>
35
+ <Collapsible.Panel>{contentWithButton}</Collapsible.Panel>
36
+ </Collapsible>
37
+
38
+ <Collapsible isOpen={false} offset={100}>
39
+ <Collapsible.Trigger>
40
+ <Button appearance="secondary" onClick={toggle}>
41
+ Show
42
+ </Button>
43
+ </Collapsible.Trigger>
44
+ <Collapsible.Panel>{contentWithButton}</Collapsible.Panel>
45
+ </Collapsible>
46
+
47
+ <Collapsible isOpen={false} collapsedHeight={500}>
48
+ <Collapsible.Panel>{shortContent}</Collapsible.Panel>
49
+ <Collapsible.Trigger>
50
+ <Button onClick={toggle}>Show More</Button>
51
+ </Collapsible.Trigger>
52
+ </Collapsible>
53
+
54
+ {/* @ts-expect-error - test missing required props is rejected */}
55
+ <Collapsible />
56
+ </>
57
+ );
58
+ });
59
+ });
@@ -0,0 +1,167 @@
1
+ // src/Collapsible.tsx
2
+ import * as React from "react";
3
+ import { useState as useState2, useRef as useRef2, useContext, useEffect as useEffect2 } from "react";
4
+
5
+ // ../seeds-react-hooks/dist/index.mjs
6
+ import { useState, useLayoutEffect, useCallback, useReducer, useRef, useEffect, useMemo } from "react";
7
+ import { useTheme } from "styled-components";
8
+ var v = Object.freeze({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
9
+ function q(r) {
10
+ let [t, e] = useState(v);
11
+ return useLayoutEffect(() => {
12
+ if (!r.current || !("ResizeObserver" in window))
13
+ return;
14
+ let n = new ResizeObserver(([o]) => {
15
+ if (!o)
16
+ return;
17
+ let { x: u, y: a, width: i, height: f, top: m, right: b, bottom: d, left: y } = o.contentRect;
18
+ e({ x: u, y: a, width: i, height: f, top: m, right: b, bottom: d, left: y });
19
+ });
20
+ return n.observe(r.current), () => {
21
+ n.disconnect();
22
+ };
23
+ }, [r]), t;
24
+ }
25
+
26
+ // src/Collapsible.tsx
27
+ import Box2 from "@sproutsocial/seeds-react-box";
28
+
29
+ // src/styles.ts
30
+ import styled from "styled-components";
31
+ import Box from "@sproutsocial/seeds-react-box";
32
+ var CollapsingBox = styled(Box)`
33
+ transition: max-height ${(p) => p.theme.duration.medium}
34
+ ${(p) => p.theme.easing.ease_inout};
35
+ will-change: max-height;
36
+ position: relative;
37
+ overflow: auto;
38
+ ${({ hasShadow, scrollable }) => hasShadow ? `background: /* Shadow covers */ linear-gradient(
39
+ transparent 30%,
40
+ rgba(255, 255, 255, 0)
41
+ ),
42
+ linear-gradient(rgba(255, 255, 255, 0), transparent 70%) 0 100%,
43
+ /* Shadows */
44
+ radial-gradient(
45
+ farthest-side at 50% 0,
46
+ rgb(39 51 51 / 5%),
47
+ rgba(0, 0, 0, 0)
48
+ ),
49
+ radial-gradient(
50
+ farthest-side at 50% 100%,
51
+ rgb(39 51 51 / 5%),
52
+ rgba(0, 0, 0, 0)
53
+ )
54
+ 0 100%;
55
+ background-repeat: no-repeat;
56
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
57
+ background-attachment: local, local, scroll, scroll;
58
+ ${scrollable ? `overflow: auto` : `overflow: hidden`};` : ""}
59
+ `;
60
+
61
+ // src/Collapsible.tsx
62
+ import { jsx } from "react/jsx-runtime";
63
+ var idCounter = 0;
64
+ var CollapsibleContext = React.createContext({});
65
+ var Collapsible = ({
66
+ children,
67
+ isOpen = false,
68
+ offset = 0,
69
+ collapsedHeight = 0,
70
+ openHeight
71
+ }) => {
72
+ const [id] = useState2(`Racine-collapsible-${idCounter++}`);
73
+ return /* @__PURE__ */ jsx(
74
+ CollapsibleContext.Provider,
75
+ {
76
+ value: {
77
+ isOpen,
78
+ id,
79
+ offset,
80
+ collapsedHeight,
81
+ openHeight
82
+ },
83
+ children
84
+ }
85
+ );
86
+ };
87
+ var determineMaxHeight = (isHidden, openHeight, computedHeight) => {
88
+ if (isHidden === void 0)
89
+ return void 0;
90
+ if (openHeight)
91
+ return openHeight;
92
+ return computedHeight;
93
+ };
94
+ var Trigger = ({ children, ...rest }) => {
95
+ const { isOpen, id } = useContext(CollapsibleContext);
96
+ return /* @__PURE__ */ jsx(React.Fragment, { children: React.cloneElement(children, {
97
+ "aria-controls": id,
98
+ "aria-expanded": !!isOpen,
99
+ ...rest
100
+ }) });
101
+ };
102
+ Trigger.displayName = "Collapsible.Trigger";
103
+ var Panel = ({ children, ...rest }) => {
104
+ const {
105
+ isOpen,
106
+ id,
107
+ offset = 0,
108
+ collapsedHeight,
109
+ openHeight
110
+ } = useContext(CollapsibleContext);
111
+ const ref = useRef2(null);
112
+ const measurement = q(ref);
113
+ const [isHidden, setIsHidden] = useState2(void 0);
114
+ const maxHeight = determineMaxHeight(
115
+ isHidden,
116
+ openHeight,
117
+ // Round up to the nearest pixel to prevent subpixel rendering issues
118
+ Math.ceil(measurement.height + offset)
119
+ );
120
+ useEffect2(() => {
121
+ if (!isOpen) {
122
+ const timeoutID = setTimeout(() => setIsHidden(!isOpen), 300);
123
+ return () => clearTimeout(timeoutID);
124
+ } else {
125
+ const timeoutID = setTimeout(() => setIsHidden(!isOpen), 0);
126
+ return () => clearTimeout(timeoutID);
127
+ }
128
+ }, [isOpen]);
129
+ return /* @__PURE__ */ jsx(
130
+ CollapsingBox,
131
+ {
132
+ hasShadow: Boolean(collapsedHeight || openHeight && openHeight > 0),
133
+ scrollable: isOpen,
134
+ maxHeight: isOpen ? maxHeight : collapsedHeight,
135
+ minHeight: collapsedHeight,
136
+ "data-qa-collapsible": "",
137
+ "data-qa-collapsible-isopen": isOpen === true,
138
+ ...rest,
139
+ children: /* @__PURE__ */ jsx(
140
+ Box2,
141
+ {
142
+ width: "100%",
143
+ hidden: isHidden && collapsedHeight === 0,
144
+ "aria-hidden": !isOpen,
145
+ id,
146
+ ref,
147
+ children
148
+ }
149
+ )
150
+ }
151
+ );
152
+ };
153
+ Panel.displayName = "Collapsible.Panel";
154
+ Collapsible.Trigger = Trigger;
155
+ Collapsible.Panel = Panel;
156
+ var Collapsible_default = Collapsible;
157
+
158
+ // src/CollapsibleTypes.ts
159
+ import "react";
160
+
161
+ // src/index.ts
162
+ var src_default = Collapsible_default;
163
+ export {
164
+ Collapsible_default as Collapsible,
165
+ src_default as default
166
+ };
167
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/Collapsible.tsx","../../../seeds-react-hooks/src/useMeasure/useMeasure.ts","../../../seeds-react-hooks/src/useSelect/useSelect.ts","../../../seeds-react-hooks/src/useMultiselect/useMultiselect.ts","../../../seeds-react-hooks/src/useMutationObserver/useMutationObserver.ts","../../../seeds-react-hooks/src/useTextContent/useTextContent.ts","../../../seeds-react-hooks/src/useWhyDidYouUpdate/useWhyDidYouUpdate.ts","../../../seeds-react-hooks/src/useInteractiveColor/useInteractiveColor.ts","../../src/styles.ts","../../src/CollapsibleTypes.ts","../../src/index.ts"],"sourcesContent":["import * as React from \"react\";\nimport { useState, useRef, useContext, useEffect } from \"react\";\nimport { useMeasure } from \"@sproutsocial/seeds-react-hooks\";\nimport Box from \"@sproutsocial/seeds-react-box\";\nimport { CollapsingBox } from \"./styles\";\nimport type { TypeCollapsibleProps } from \"./CollapsibleTypes\";\n\nlet idCounter = 0;\n\ninterface TypeCollapsibleContext {\n isOpen?: boolean;\n id?: string;\n offset?: number;\n openHeight?: number;\n collapsedHeight?: number;\n}\n\nconst CollapsibleContext = React.createContext<TypeCollapsibleContext>({});\n\nconst Collapsible = ({\n children,\n isOpen = false,\n offset = 0,\n collapsedHeight = 0,\n openHeight,\n}: TypeCollapsibleProps) => {\n const [id] = useState(`Racine-collapsible-${idCounter++}`);\n return (\n <CollapsibleContext.Provider\n value={{\n isOpen,\n id,\n offset,\n collapsedHeight,\n openHeight,\n }}\n >\n {children}\n </CollapsibleContext.Provider>\n );\n};\n\nconst determineMaxHeight = (\n isHidden?: boolean,\n openHeight?: number,\n computedHeight?: number\n): number | undefined => {\n // If isHidden is undefined this is the first render. Return undefined so the max-height prop is not added\n // This is a hack to prevent css from animating if it begins in the open state\n // css animates when attribute values change (IE from 0 to another number)\n // css does not animate when simply adding an attribute to an HTML element\n if (isHidden === undefined) return undefined;\n // If the user has defined an explicit open height, return that as the max height\n if (openHeight) return openHeight;\n // Otherwise, fallback to the computed height\n return computedHeight;\n};\n\nconst Trigger = ({ children, ...rest }: { children: React.ReactElement }) => {\n const { isOpen, id } = useContext(CollapsibleContext);\n return (\n <React.Fragment>\n {React.cloneElement(children, {\n \"aria-controls\": id,\n \"aria-expanded\": !!isOpen,\n ...rest,\n })}\n </React.Fragment>\n );\n};\n\nTrigger.displayName = \"Collapsible.Trigger\";\n\nconst Panel = ({ children, ...rest }: { children: React.ReactNode }) => {\n const {\n isOpen,\n id,\n offset = 0,\n collapsedHeight,\n openHeight,\n } = useContext(CollapsibleContext);\n\n const ref = useRef<HTMLDivElement | null>(null);\n const measurement = useMeasure(ref);\n const [isHidden, setIsHidden] = useState<boolean | undefined>(undefined);\n const maxHeight = determineMaxHeight(\n isHidden,\n openHeight,\n // Round up to the nearest pixel to prevent subpixel rendering issues\n Math.ceil(measurement.height + offset)\n );\n\n /* We use the \"hidden\" attribute to remove the contents of the panel from the tab order of the page, but it interferes with the animation. This logic sets a slight timeout on setting the prop so that the animation has time to complete before the attribute is set. */\n useEffect(() => {\n if (!isOpen) {\n const timeoutID = setTimeout(() => setIsHidden(!isOpen), 300);\n return () => clearTimeout(timeoutID);\n } else {\n // Similar to the close animation, we need to delay setting hidden to run slightly async.\n // An issue occurs with the initial render isHidden logic that causes the animation to occur sporadically.\n // using this 0 second timeout just allows this component to initially render with an undefined max height,\n // Then go directly from undefined to the full max height, without a brief 0 value that triggers an animation\n const timeoutID = setTimeout(() => setIsHidden(!isOpen), 0);\n return () => clearTimeout(timeoutID);\n }\n }, [isOpen]);\n\n return (\n <CollapsingBox\n hasShadow={Boolean(collapsedHeight || (openHeight && openHeight > 0))}\n scrollable={isOpen}\n maxHeight={isOpen ? maxHeight : collapsedHeight}\n minHeight={collapsedHeight}\n data-qa-collapsible=\"\"\n data-qa-collapsible-isopen={isOpen === true}\n {...rest}\n >\n <Box\n width=\"100%\"\n hidden={isHidden && collapsedHeight === 0}\n aria-hidden={!isOpen}\n id={id}\n ref={ref}\n >\n {children}\n </Box>\n </CollapsingBox>\n );\n};\n\nPanel.displayName = \"Collapsible.Panel\";\n\nCollapsible.Trigger = Trigger;\nCollapsible.Panel = Panel;\n\nexport default Collapsible;\n","import { useState, useLayoutEffect, type RefObject } from \"react\";\n\ninterface DOMRectObject {\n x: number;\n y: number;\n width: number;\n height: number;\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\nconst initialBounds = Object.freeze({\n x: 0,\n y: 0,\n width: 0,\n height: 0,\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n});\n\nexport function useMeasure<TElement extends Element>(ref: RefObject<TElement>) {\n const [bounds, setContentRect] =\n useState<Readonly<DOMRectObject>>(initialBounds);\n\n useLayoutEffect(() => {\n const element = ref.current;\n\n if (\n !element ||\n // in non-browser environments (e.g. Jest tests) ResizeObserver is not defined\n !(\"ResizeObserver\" in window)\n ) {\n return;\n }\n\n const resizeObserver = new ResizeObserver(([entry]) => {\n if (!entry) return;\n const { x, y, width, height, top, right, bottom, left } =\n entry.contentRect;\n setContentRect({\n x,\n y,\n width,\n height,\n top,\n right,\n bottom,\n left,\n });\n });\n resizeObserver.observe(ref.current);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, [ref]);\n\n return bounds;\n}\n","import { useState, useCallback } from \"react\";\n\ntype TypeSingleSelectProps<T extends string> = {\n initialValue?: T | \"\";\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n onChange?: (value: string | T) => any;\n};\n\nexport const useSelect = <T extends string>(\n {\n initialValue = \"\",\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: userOnChange = () => {},\n }: TypeSingleSelectProps<T> = {\n initialValue: \"\",\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: () => {},\n }\n) => {\n const [value, setValue] = useState<string | T>(initialValue);\n\n const onChange = useCallback(\n (newValue: string) => {\n if (newValue !== value) {\n setValue(newValue);\n userOnChange(newValue);\n }\n },\n [userOnChange, value]\n );\n\n return { value, onChange };\n};\n","import { useCallback, useEffect, useReducer, useRef } from \"react\";\n\ntype TypeMultiSelectProps<T extends string> = {\n initialValue?: T[];\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n onChange?: (value: Array<string | T>) => any;\n};\n\nconst valueReducer = (\n state: Set<string>,\n action: { type: string; value?: string }\n): Set<string> => {\n const newState = new Set(state);\n switch (action.type) {\n case \"reset\": {\n return new Set();\n }\n case \"toggle_item\":\n default: {\n if (action.value) {\n if (newState.has(action.value)) {\n newState.delete(action.value);\n } else {\n newState.add(action.value);\n }\n }\n return newState;\n }\n }\n};\n\nexport const useMultiselect = <T extends string>(\n {\n initialValue = [],\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: userOnChange = () => {},\n }: TypeMultiSelectProps<T> = {\n initialValue: [],\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: () => {},\n }\n) => {\n const [value, dispatch] = useReducer(valueReducer, new Set(initialValue));\n\n const getArrayValue = (value: Set<string | T>) =>\n Array.from<string | T>(value);\n\n const onChange = useCallback(\n (newValue: string) => {\n dispatch({ type: \"toggle_item\", value: newValue });\n },\n [dispatch]\n );\n\n const isFirstRun = useRef(true);\n\n useEffect(() => {\n if (isFirstRun.current) {\n isFirstRun.current = false;\n return;\n }\n userOnChange(getArrayValue(value));\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [userOnChange, value]);\n\n const onClear = useCallback(() => {\n dispatch({ type: \"reset\" });\n }, [dispatch]);\n\n return { value: getArrayValue(value), onChange, onClear };\n};\n","import { canUseDOM } from \"@sproutsocial/seeds-react-utilities\";\nimport { useEffect, useMemo, useState } from \"react\";\n\ntype TypeMutationObserverInitRequired =\n | {\n childList: true;\n }\n | {\n attributes: true;\n }\n | {\n characterData: true;\n };\n\ntype TypeMutationObserverInit = {\n subtree?: boolean;\n attributeOldValue?: boolean;\n characterDataOldValue?: boolean;\n attributeFilter?: Array<string>;\n} & TypeMutationObserverInitRequired;\n\ntype TypeMutationObserverCallback = (\n mutationList?: MutationRecord[],\n observer?: MutationObserver\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n) => any;\n\nconst defaultCallback: TypeMutationObserverCallback = (mutationList) =>\n mutationList;\n\nexport function useMutationObserver(\n targetNode: Node | null,\n config: TypeMutationObserverInit,\n callback: TypeMutationObserverCallback = defaultCallback\n) {\n if (!canUseDOM()) {\n return;\n }\n /* eslint-disable-next-line */\n const [value, setValue] = useState(undefined);\n /* eslint-disable-next-line */\n const observer = useMemo(\n () =>\n new MutationObserver((mutationList, observer) => {\n const result = callback(mutationList, observer);\n setValue(result);\n }),\n [callback]\n );\n /* eslint-disable-next-line */\n useEffect(() => {\n if (targetNode) {\n observer.observe(targetNode, config);\n return () => {\n observer.disconnect();\n };\n }\n }, [targetNode, config, observer]);\n\n return value;\n}\n\nexport function useMutationObserverOnce(\n targetNode: Node | null,\n config: TypeMutationObserverInit,\n callback: TypeMutationObserverCallback\n) {\n const [isObserving, setObserving] = useState(true);\n const node = isObserving ? targetNode : null;\n const value = useMutationObserver(node, config, callback);\n if (value !== undefined && isObserving) {\n setObserving(false);\n }\n return value;\n}\n","import { useCallback, useState } from \"react\";\n\nexport type textContentRef = ((node: Node) => void) & { current?: string };\nexport function useTextContent(initial: string) {\n const [textContent, setTextContent] = useState(initial);\n\n const ref: textContentRef = useCallback((node: Node) => {\n if (node && node.textContent !== null) {\n setTextContent(node.textContent);\n }\n }, []);\n\n ref.current = textContent;\n return ref;\n}\n","import { useRef, useEffect } from \"react\";\n\nexport function useWhyDidYouUpdate(\n name: string,\n props: { [key: string]: any }\n) {\n // Get a mutable ref object where we can store props ...\n // ... for comparison next time this hook runs.\n const previousProps = useRef<typeof props>({});\n\n useEffect(() => {\n if (previousProps.current) {\n // Get all keys from previous and current props\n const allKeys = Object.keys({ ...previousProps.current, ...props });\n // Use this object to keep track of changed props\n const changesObj: typeof props = {};\n // Iterate through keys\n allKeys.forEach((key) => {\n // If previous is different from current\n\n if (previousProps.current[key] !== props[key]) {\n // Add to changesObj\n\n changesObj[key] = {\n from: previousProps.current[key],\n\n to: props[key],\n };\n }\n });\n\n // If changesObj not empty then output to console\n if (Object.keys(changesObj).length) {\n // eslint-disable-next-line no-console\n console.log(\"[why-did-you-update]\", name, changesObj);\n }\n }\n\n // Finally update previousProps with current props for next hook call\n previousProps.current = props;\n });\n}\n","import { darken, lighten } from \"polished\";\nimport { useTheme } from \"styled-components\";\nimport type { TypeTheme } from \"@sproutsocial/seeds-react-theme\";\n\n/**\n * The useInteractiveColor hook has context of theme mode (light or dark)\n * and can be used to lighten or darken a color dynamically\n *\n * note: colors are limited to our theme colors\n */\nconst useInteractiveColor = (themeColor: string): string => {\n // Throw error if used outside of a ThemeProvider (styled-components)\n if (!useTheme()) {\n throw new Error(\n \"useInteractiveColor() must be used within a Styled Components ThemeProvider\"\n );\n }\n\n // Get the current theme mode ie. 'light' or 'dark'\n const theme: TypeTheme = useTheme() as TypeTheme;\n const themeMode = theme.mode;\n\n // If the theme mode is dark, return a lightened version of the themeValue\n if (themeMode === \"dark\") {\n return lighten(0.2, themeColor);\n } else {\n // If the theme mode is light, return a darkened version of the themeValue\n return darken(0.2, themeColor);\n }\n};\n\nexport { useInteractiveColor };\n","import styled from \"styled-components\";\nimport Box from \"@sproutsocial/seeds-react-box\";\n\nexport const CollapsingBox = styled(Box)<{\n hasShadow?: boolean;\n scrollable?: boolean;\n}>`\n transition: max-height ${(p) => p.theme.duration.medium}\n ${(p) => p.theme.easing.ease_inout};\n will-change: max-height;\n position: relative;\n overflow: auto;\n ${({ hasShadow, scrollable }) =>\n hasShadow\n ? `background: /* Shadow covers */ linear-gradient(\n transparent 30%,\n rgba(255, 255, 255, 0)\n ),\n linear-gradient(rgba(255, 255, 255, 0), transparent 70%) 0 100%,\n /* Shadows */\n radial-gradient(\n farthest-side at 50% 0,\n rgb(39 51 51 / 5%),\n rgba(0, 0, 0, 0)\n ),\n radial-gradient(\n farthest-side at 50% 100%,\n rgb(39 51 51 / 5%),\n rgba(0, 0, 0, 0)\n )\n 0 100%;\n background-repeat: no-repeat;\n background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;\n background-attachment: local, local, scroll, scroll;\n ${scrollable ? `overflow: auto` : `overflow: hidden`};`\n : \"\"}\n`;\n","import * as React from \"react\";\n\n// The flow type is inexact but the underlying component does not accept any other props.\n// It might be worth extending the box props here for the refactor, but allowing it would provide no functionality right now.\nexport interface TypeCollapsibleProps {\n isOpen: boolean;\n children: React.ReactNode;\n\n /** If the children of the collapsible panel have a top or bottom margin, it will throw off the calculations for the height of the content. The total amount of vertical margin (in pixels) can be supplied to this prop to correct this. */\n offset?: number;\n collapsedHeight?: number;\n openHeight?: number;\n}\n","import Collapsible from \"./Collapsible\";\n\nexport default Collapsible;\nexport { Collapsible };\nexport * from \"./CollapsibleTypes\";\n"],"mappings":"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","names":["useState","useRef","useEffect","useState","Box","useState","useRef","useEffect","Box"]}
@@ -0,0 +1,29 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as React from 'react';
3
+
4
+ interface TypeCollapsibleProps {
5
+ isOpen: boolean;
6
+ children: React.ReactNode;
7
+ /** If the children of the collapsible panel have a top or bottom margin, it will throw off the calculations for the height of the content. The total amount of vertical margin (in pixels) can be supplied to this prop to correct this. */
8
+ offset?: number;
9
+ collapsedHeight?: number;
10
+ openHeight?: number;
11
+ }
12
+
13
+ declare const Collapsible: {
14
+ ({ children, isOpen, offset, collapsedHeight, openHeight, }: TypeCollapsibleProps): react_jsx_runtime.JSX.Element;
15
+ Trigger: {
16
+ ({ children, ...rest }: {
17
+ children: React.ReactElement;
18
+ }): react_jsx_runtime.JSX.Element;
19
+ displayName: string;
20
+ };
21
+ Panel: {
22
+ ({ children, ...rest }: {
23
+ children: React.ReactNode;
24
+ }): react_jsx_runtime.JSX.Element;
25
+ displayName: string;
26
+ };
27
+ };
28
+
29
+ export { Collapsible, type TypeCollapsibleProps, Collapsible as default };
@@ -0,0 +1,29 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as React from 'react';
3
+
4
+ interface TypeCollapsibleProps {
5
+ isOpen: boolean;
6
+ children: React.ReactNode;
7
+ /** If the children of the collapsible panel have a top or bottom margin, it will throw off the calculations for the height of the content. The total amount of vertical margin (in pixels) can be supplied to this prop to correct this. */
8
+ offset?: number;
9
+ collapsedHeight?: number;
10
+ openHeight?: number;
11
+ }
12
+
13
+ declare const Collapsible: {
14
+ ({ children, isOpen, offset, collapsedHeight, openHeight, }: TypeCollapsibleProps): react_jsx_runtime.JSX.Element;
15
+ Trigger: {
16
+ ({ children, ...rest }: {
17
+ children: React.ReactElement;
18
+ }): react_jsx_runtime.JSX.Element;
19
+ displayName: string;
20
+ };
21
+ Panel: {
22
+ ({ children, ...rest }: {
23
+ children: React.ReactNode;
24
+ }): react_jsx_runtime.JSX.Element;
25
+ displayName: string;
26
+ };
27
+ };
28
+
29
+ export { Collapsible, type TypeCollapsibleProps, Collapsible as default };
package/dist/index.js ADDED
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ Collapsible: () => Collapsible_default,
34
+ default: () => src_default
35
+ });
36
+ module.exports = __toCommonJS(src_exports);
37
+
38
+ // src/Collapsible.tsx
39
+ var React = __toESM(require("react"));
40
+ var import_react2 = require("react");
41
+
42
+ // ../seeds-react-hooks/dist/index.mjs
43
+ var import_react = require("react");
44
+ var import_styled_components = require("styled-components");
45
+ var v = Object.freeze({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
46
+ function q(r) {
47
+ let [t, e] = (0, import_react.useState)(v);
48
+ return (0, import_react.useLayoutEffect)(() => {
49
+ if (!r.current || !("ResizeObserver" in window))
50
+ return;
51
+ let n = new ResizeObserver(([o]) => {
52
+ if (!o)
53
+ return;
54
+ let { x: u, y: a, width: i, height: f, top: m, right: b, bottom: d, left: y } = o.contentRect;
55
+ e({ x: u, y: a, width: i, height: f, top: m, right: b, bottom: d, left: y });
56
+ });
57
+ return n.observe(r.current), () => {
58
+ n.disconnect();
59
+ };
60
+ }, [r]), t;
61
+ }
62
+
63
+ // src/Collapsible.tsx
64
+ var import_seeds_react_box2 = __toESM(require("@sproutsocial/seeds-react-box"));
65
+
66
+ // src/styles.ts
67
+ var import_styled_components2 = __toESM(require("styled-components"));
68
+ var import_seeds_react_box = __toESM(require("@sproutsocial/seeds-react-box"));
69
+ var CollapsingBox = (0, import_styled_components2.default)(import_seeds_react_box.default)`
70
+ transition: max-height ${(p) => p.theme.duration.medium}
71
+ ${(p) => p.theme.easing.ease_inout};
72
+ will-change: max-height;
73
+ position: relative;
74
+ overflow: auto;
75
+ ${({ hasShadow, scrollable }) => hasShadow ? `background: /* Shadow covers */ linear-gradient(
76
+ transparent 30%,
77
+ rgba(255, 255, 255, 0)
78
+ ),
79
+ linear-gradient(rgba(255, 255, 255, 0), transparent 70%) 0 100%,
80
+ /* Shadows */
81
+ radial-gradient(
82
+ farthest-side at 50% 0,
83
+ rgb(39 51 51 / 5%),
84
+ rgba(0, 0, 0, 0)
85
+ ),
86
+ radial-gradient(
87
+ farthest-side at 50% 100%,
88
+ rgb(39 51 51 / 5%),
89
+ rgba(0, 0, 0, 0)
90
+ )
91
+ 0 100%;
92
+ background-repeat: no-repeat;
93
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
94
+ background-attachment: local, local, scroll, scroll;
95
+ ${scrollable ? `overflow: auto` : `overflow: hidden`};` : ""}
96
+ `;
97
+
98
+ // src/Collapsible.tsx
99
+ var import_jsx_runtime = require("react/jsx-runtime");
100
+ var idCounter = 0;
101
+ var CollapsibleContext = React.createContext({});
102
+ var Collapsible = ({
103
+ children,
104
+ isOpen = false,
105
+ offset = 0,
106
+ collapsedHeight = 0,
107
+ openHeight
108
+ }) => {
109
+ const [id] = (0, import_react2.useState)(`Racine-collapsible-${idCounter++}`);
110
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
111
+ CollapsibleContext.Provider,
112
+ {
113
+ value: {
114
+ isOpen,
115
+ id,
116
+ offset,
117
+ collapsedHeight,
118
+ openHeight
119
+ },
120
+ children
121
+ }
122
+ );
123
+ };
124
+ var determineMaxHeight = (isHidden, openHeight, computedHeight) => {
125
+ if (isHidden === void 0)
126
+ return void 0;
127
+ if (openHeight)
128
+ return openHeight;
129
+ return computedHeight;
130
+ };
131
+ var Trigger = ({ children, ...rest }) => {
132
+ const { isOpen, id } = (0, import_react2.useContext)(CollapsibleContext);
133
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(React.Fragment, { children: React.cloneElement(children, {
134
+ "aria-controls": id,
135
+ "aria-expanded": !!isOpen,
136
+ ...rest
137
+ }) });
138
+ };
139
+ Trigger.displayName = "Collapsible.Trigger";
140
+ var Panel = ({ children, ...rest }) => {
141
+ const {
142
+ isOpen,
143
+ id,
144
+ offset = 0,
145
+ collapsedHeight,
146
+ openHeight
147
+ } = (0, import_react2.useContext)(CollapsibleContext);
148
+ const ref = (0, import_react2.useRef)(null);
149
+ const measurement = q(ref);
150
+ const [isHidden, setIsHidden] = (0, import_react2.useState)(void 0);
151
+ const maxHeight = determineMaxHeight(
152
+ isHidden,
153
+ openHeight,
154
+ // Round up to the nearest pixel to prevent subpixel rendering issues
155
+ Math.ceil(measurement.height + offset)
156
+ );
157
+ (0, import_react2.useEffect)(() => {
158
+ if (!isOpen) {
159
+ const timeoutID = setTimeout(() => setIsHidden(!isOpen), 300);
160
+ return () => clearTimeout(timeoutID);
161
+ } else {
162
+ const timeoutID = setTimeout(() => setIsHidden(!isOpen), 0);
163
+ return () => clearTimeout(timeoutID);
164
+ }
165
+ }, [isOpen]);
166
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
167
+ CollapsingBox,
168
+ {
169
+ hasShadow: Boolean(collapsedHeight || openHeight && openHeight > 0),
170
+ scrollable: isOpen,
171
+ maxHeight: isOpen ? maxHeight : collapsedHeight,
172
+ minHeight: collapsedHeight,
173
+ "data-qa-collapsible": "",
174
+ "data-qa-collapsible-isopen": isOpen === true,
175
+ ...rest,
176
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
177
+ import_seeds_react_box2.default,
178
+ {
179
+ width: "100%",
180
+ hidden: isHidden && collapsedHeight === 0,
181
+ "aria-hidden": !isOpen,
182
+ id,
183
+ ref,
184
+ children
185
+ }
186
+ )
187
+ }
188
+ );
189
+ };
190
+ Panel.displayName = "Collapsible.Panel";
191
+ Collapsible.Trigger = Trigger;
192
+ Collapsible.Panel = Panel;
193
+ var Collapsible_default = Collapsible;
194
+
195
+ // src/CollapsibleTypes.ts
196
+ var React2 = require("react");
197
+
198
+ // src/index.ts
199
+ var src_default = Collapsible_default;
200
+ // Annotate the CommonJS export names for ESM import in node:
201
+ 0 && (module.exports = {
202
+ Collapsible
203
+ });
204
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/Collapsible.tsx","../../seeds-react-hooks/src/useMeasure/useMeasure.ts","../../seeds-react-hooks/src/useSelect/useSelect.ts","../../seeds-react-hooks/src/useMultiselect/useMultiselect.ts","../../seeds-react-hooks/src/useMutationObserver/useMutationObserver.ts","../../seeds-react-hooks/src/useTextContent/useTextContent.ts","../../seeds-react-hooks/src/useWhyDidYouUpdate/useWhyDidYouUpdate.ts","../../seeds-react-hooks/src/useInteractiveColor/useInteractiveColor.ts","../src/styles.ts","../src/CollapsibleTypes.ts"],"sourcesContent":["import Collapsible from \"./Collapsible\";\n\nexport default Collapsible;\nexport { Collapsible };\nexport * from \"./CollapsibleTypes\";\n","import * as React from \"react\";\nimport { useState, useRef, useContext, useEffect } from \"react\";\nimport { useMeasure } from \"@sproutsocial/seeds-react-hooks\";\nimport Box from \"@sproutsocial/seeds-react-box\";\nimport { CollapsingBox } from \"./styles\";\nimport type { TypeCollapsibleProps } from \"./CollapsibleTypes\";\n\nlet idCounter = 0;\n\ninterface TypeCollapsibleContext {\n isOpen?: boolean;\n id?: string;\n offset?: number;\n openHeight?: number;\n collapsedHeight?: number;\n}\n\nconst CollapsibleContext = React.createContext<TypeCollapsibleContext>({});\n\nconst Collapsible = ({\n children,\n isOpen = false,\n offset = 0,\n collapsedHeight = 0,\n openHeight,\n}: TypeCollapsibleProps) => {\n const [id] = useState(`Racine-collapsible-${idCounter++}`);\n return (\n <CollapsibleContext.Provider\n value={{\n isOpen,\n id,\n offset,\n collapsedHeight,\n openHeight,\n }}\n >\n {children}\n </CollapsibleContext.Provider>\n );\n};\n\nconst determineMaxHeight = (\n isHidden?: boolean,\n openHeight?: number,\n computedHeight?: number\n): number | undefined => {\n // If isHidden is undefined this is the first render. Return undefined so the max-height prop is not added\n // This is a hack to prevent css from animating if it begins in the open state\n // css animates when attribute values change (IE from 0 to another number)\n // css does not animate when simply adding an attribute to an HTML element\n if (isHidden === undefined) return undefined;\n // If the user has defined an explicit open height, return that as the max height\n if (openHeight) return openHeight;\n // Otherwise, fallback to the computed height\n return computedHeight;\n};\n\nconst Trigger = ({ children, ...rest }: { children: React.ReactElement }) => {\n const { isOpen, id } = useContext(CollapsibleContext);\n return (\n <React.Fragment>\n {React.cloneElement(children, {\n \"aria-controls\": id,\n \"aria-expanded\": !!isOpen,\n ...rest,\n })}\n </React.Fragment>\n );\n};\n\nTrigger.displayName = \"Collapsible.Trigger\";\n\nconst Panel = ({ children, ...rest }: { children: React.ReactNode }) => {\n const {\n isOpen,\n id,\n offset = 0,\n collapsedHeight,\n openHeight,\n } = useContext(CollapsibleContext);\n\n const ref = useRef<HTMLDivElement | null>(null);\n const measurement = useMeasure(ref);\n const [isHidden, setIsHidden] = useState<boolean | undefined>(undefined);\n const maxHeight = determineMaxHeight(\n isHidden,\n openHeight,\n // Round up to the nearest pixel to prevent subpixel rendering issues\n Math.ceil(measurement.height + offset)\n );\n\n /* We use the \"hidden\" attribute to remove the contents of the panel from the tab order of the page, but it interferes with the animation. This logic sets a slight timeout on setting the prop so that the animation has time to complete before the attribute is set. */\n useEffect(() => {\n if (!isOpen) {\n const timeoutID = setTimeout(() => setIsHidden(!isOpen), 300);\n return () => clearTimeout(timeoutID);\n } else {\n // Similar to the close animation, we need to delay setting hidden to run slightly async.\n // An issue occurs with the initial render isHidden logic that causes the animation to occur sporadically.\n // using this 0 second timeout just allows this component to initially render with an undefined max height,\n // Then go directly from undefined to the full max height, without a brief 0 value that triggers an animation\n const timeoutID = setTimeout(() => setIsHidden(!isOpen), 0);\n return () => clearTimeout(timeoutID);\n }\n }, [isOpen]);\n\n return (\n <CollapsingBox\n hasShadow={Boolean(collapsedHeight || (openHeight && openHeight > 0))}\n scrollable={isOpen}\n maxHeight={isOpen ? maxHeight : collapsedHeight}\n minHeight={collapsedHeight}\n data-qa-collapsible=\"\"\n data-qa-collapsible-isopen={isOpen === true}\n {...rest}\n >\n <Box\n width=\"100%\"\n hidden={isHidden && collapsedHeight === 0}\n aria-hidden={!isOpen}\n id={id}\n ref={ref}\n >\n {children}\n </Box>\n </CollapsingBox>\n );\n};\n\nPanel.displayName = \"Collapsible.Panel\";\n\nCollapsible.Trigger = Trigger;\nCollapsible.Panel = Panel;\n\nexport default Collapsible;\n","import { useState, useLayoutEffect, type RefObject } from \"react\";\n\ninterface DOMRectObject {\n x: number;\n y: number;\n width: number;\n height: number;\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\nconst initialBounds = Object.freeze({\n x: 0,\n y: 0,\n width: 0,\n height: 0,\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n});\n\nexport function useMeasure<TElement extends Element>(ref: RefObject<TElement>) {\n const [bounds, setContentRect] =\n useState<Readonly<DOMRectObject>>(initialBounds);\n\n useLayoutEffect(() => {\n const element = ref.current;\n\n if (\n !element ||\n // in non-browser environments (e.g. Jest tests) ResizeObserver is not defined\n !(\"ResizeObserver\" in window)\n ) {\n return;\n }\n\n const resizeObserver = new ResizeObserver(([entry]) => {\n if (!entry) return;\n const { x, y, width, height, top, right, bottom, left } =\n entry.contentRect;\n setContentRect({\n x,\n y,\n width,\n height,\n top,\n right,\n bottom,\n left,\n });\n });\n resizeObserver.observe(ref.current);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, [ref]);\n\n return bounds;\n}\n","import { useState, useCallback } from \"react\";\n\ntype TypeSingleSelectProps<T extends string> = {\n initialValue?: T | \"\";\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n onChange?: (value: string | T) => any;\n};\n\nexport const useSelect = <T extends string>(\n {\n initialValue = \"\",\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: userOnChange = () => {},\n }: TypeSingleSelectProps<T> = {\n initialValue: \"\",\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: () => {},\n }\n) => {\n const [value, setValue] = useState<string | T>(initialValue);\n\n const onChange = useCallback(\n (newValue: string) => {\n if (newValue !== value) {\n setValue(newValue);\n userOnChange(newValue);\n }\n },\n [userOnChange, value]\n );\n\n return { value, onChange };\n};\n","import { useCallback, useEffect, useReducer, useRef } from \"react\";\n\ntype TypeMultiSelectProps<T extends string> = {\n initialValue?: T[];\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n onChange?: (value: Array<string | T>) => any;\n};\n\nconst valueReducer = (\n state: Set<string>,\n action: { type: string; value?: string }\n): Set<string> => {\n const newState = new Set(state);\n switch (action.type) {\n case \"reset\": {\n return new Set();\n }\n case \"toggle_item\":\n default: {\n if (action.value) {\n if (newState.has(action.value)) {\n newState.delete(action.value);\n } else {\n newState.add(action.value);\n }\n }\n return newState;\n }\n }\n};\n\nexport const useMultiselect = <T extends string>(\n {\n initialValue = [],\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: userOnChange = () => {},\n }: TypeMultiSelectProps<T> = {\n initialValue: [],\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n onChange: () => {},\n }\n) => {\n const [value, dispatch] = useReducer(valueReducer, new Set(initialValue));\n\n const getArrayValue = (value: Set<string | T>) =>\n Array.from<string | T>(value);\n\n const onChange = useCallback(\n (newValue: string) => {\n dispatch({ type: \"toggle_item\", value: newValue });\n },\n [dispatch]\n );\n\n const isFirstRun = useRef(true);\n\n useEffect(() => {\n if (isFirstRun.current) {\n isFirstRun.current = false;\n return;\n }\n userOnChange(getArrayValue(value));\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [userOnChange, value]);\n\n const onClear = useCallback(() => {\n dispatch({ type: \"reset\" });\n }, [dispatch]);\n\n return { value: getArrayValue(value), onChange, onClear };\n};\n","import { canUseDOM } from \"@sproutsocial/seeds-react-utilities\";\nimport { useEffect, useMemo, useState } from \"react\";\n\ntype TypeMutationObserverInitRequired =\n | {\n childList: true;\n }\n | {\n attributes: true;\n }\n | {\n characterData: true;\n };\n\ntype TypeMutationObserverInit = {\n subtree?: boolean;\n attributeOldValue?: boolean;\n characterDataOldValue?: boolean;\n attributeFilter?: Array<string>;\n} & TypeMutationObserverInitRequired;\n\ntype TypeMutationObserverCallback = (\n mutationList?: MutationRecord[],\n observer?: MutationObserver\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n) => any;\n\nconst defaultCallback: TypeMutationObserverCallback = (mutationList) =>\n mutationList;\n\nexport function useMutationObserver(\n targetNode: Node | null,\n config: TypeMutationObserverInit,\n callback: TypeMutationObserverCallback = defaultCallback\n) {\n if (!canUseDOM()) {\n return;\n }\n /* eslint-disable-next-line */\n const [value, setValue] = useState(undefined);\n /* eslint-disable-next-line */\n const observer = useMemo(\n () =>\n new MutationObserver((mutationList, observer) => {\n const result = callback(mutationList, observer);\n setValue(result);\n }),\n [callback]\n );\n /* eslint-disable-next-line */\n useEffect(() => {\n if (targetNode) {\n observer.observe(targetNode, config);\n return () => {\n observer.disconnect();\n };\n }\n }, [targetNode, config, observer]);\n\n return value;\n}\n\nexport function useMutationObserverOnce(\n targetNode: Node | null,\n config: TypeMutationObserverInit,\n callback: TypeMutationObserverCallback\n) {\n const [isObserving, setObserving] = useState(true);\n const node = isObserving ? targetNode : null;\n const value = useMutationObserver(node, config, callback);\n if (value !== undefined && isObserving) {\n setObserving(false);\n }\n return value;\n}\n","import { useCallback, useState } from \"react\";\n\nexport type textContentRef = ((node: Node) => void) & { current?: string };\nexport function useTextContent(initial: string) {\n const [textContent, setTextContent] = useState(initial);\n\n const ref: textContentRef = useCallback((node: Node) => {\n if (node && node.textContent !== null) {\n setTextContent(node.textContent);\n }\n }, []);\n\n ref.current = textContent;\n return ref;\n}\n","import { useRef, useEffect } from \"react\";\n\nexport function useWhyDidYouUpdate(\n name: string,\n props: { [key: string]: any }\n) {\n // Get a mutable ref object where we can store props ...\n // ... for comparison next time this hook runs.\n const previousProps = useRef<typeof props>({});\n\n useEffect(() => {\n if (previousProps.current) {\n // Get all keys from previous and current props\n const allKeys = Object.keys({ ...previousProps.current, ...props });\n // Use this object to keep track of changed props\n const changesObj: typeof props = {};\n // Iterate through keys\n allKeys.forEach((key) => {\n // If previous is different from current\n\n if (previousProps.current[key] !== props[key]) {\n // Add to changesObj\n\n changesObj[key] = {\n from: previousProps.current[key],\n\n to: props[key],\n };\n }\n });\n\n // If changesObj not empty then output to console\n if (Object.keys(changesObj).length) {\n // eslint-disable-next-line no-console\n console.log(\"[why-did-you-update]\", name, changesObj);\n }\n }\n\n // Finally update previousProps with current props for next hook call\n previousProps.current = props;\n });\n}\n","import { darken, lighten } from \"polished\";\nimport { useTheme } from \"styled-components\";\nimport type { TypeTheme } from \"@sproutsocial/seeds-react-theme\";\n\n/**\n * The useInteractiveColor hook has context of theme mode (light or dark)\n * and can be used to lighten or darken a color dynamically\n *\n * note: colors are limited to our theme colors\n */\nconst useInteractiveColor = (themeColor: string): string => {\n // Throw error if used outside of a ThemeProvider (styled-components)\n if (!useTheme()) {\n throw new Error(\n \"useInteractiveColor() must be used within a Styled Components ThemeProvider\"\n );\n }\n\n // Get the current theme mode ie. 'light' or 'dark'\n const theme: TypeTheme = useTheme() as TypeTheme;\n const themeMode = theme.mode;\n\n // If the theme mode is dark, return a lightened version of the themeValue\n if (themeMode === \"dark\") {\n return lighten(0.2, themeColor);\n } else {\n // If the theme mode is light, return a darkened version of the themeValue\n return darken(0.2, themeColor);\n }\n};\n\nexport { useInteractiveColor };\n","import styled from \"styled-components\";\nimport Box from \"@sproutsocial/seeds-react-box\";\n\nexport const CollapsingBox = styled(Box)<{\n hasShadow?: boolean;\n scrollable?: boolean;\n}>`\n transition: max-height ${(p) => p.theme.duration.medium}\n ${(p) => p.theme.easing.ease_inout};\n will-change: max-height;\n position: relative;\n overflow: auto;\n ${({ hasShadow, scrollable }) =>\n hasShadow\n ? `background: /* Shadow covers */ linear-gradient(\n transparent 30%,\n rgba(255, 255, 255, 0)\n ),\n linear-gradient(rgba(255, 255, 255, 0), transparent 70%) 0 100%,\n /* Shadows */\n radial-gradient(\n farthest-side at 50% 0,\n rgb(39 51 51 / 5%),\n rgba(0, 0, 0, 0)\n ),\n radial-gradient(\n farthest-side at 50% 100%,\n rgb(39 51 51 / 5%),\n rgba(0, 0, 0, 0)\n )\n 0 100%;\n background-repeat: no-repeat;\n background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;\n background-attachment: local, local, scroll, scroll;\n ${scrollable ? `overflow: auto` : `overflow: hidden`};`\n : \"\"}\n`;\n","import * as React from \"react\";\n\n// The flow type is inexact but the underlying component does not accept any other props.\n// It might be worth extending the box props here for the refactor, but allowing it would provide no functionality right now.\nexport interface TypeCollapsibleProps {\n isOpen: boolean;\n children: React.ReactNode;\n\n /** If the children of the collapsible panel have a top or bottom margin, it will throw off the calculations for the height of the content. The total amount of vertical margin (in pixels) can be supplied to this prop to correct this. */\n offset?: number;\n collapsedHeight?: number;\n openHeight?: number;\n}\n"],"mappings":"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","names":["import_react","import_seeds_react_box","import_styled_components","styled","Box","Box","React"]}
package/jest.config.js ADDED
@@ -0,0 +1,9 @@
1
+ const baseConfig = require("@sproutsocial/seeds-testing");
2
+
3
+ /** * @type {import('jest').Config} */
4
+ const config = {
5
+ ...baseConfig,
6
+ displayName: "seeds-react-collapsible",
7
+ };
8
+
9
+ module.exports = config;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@sproutsocial/seeds-react-collapsible",
3
+ "version": "1.0.0",
4
+ "description": "Seeds React Collapsible",
5
+ "author": "Sprout Social, Inc.",
6
+ "license": "MIT",
7
+ "main": "dist/index.js",
8
+ "module": "dist/esm/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "scripts": {
11
+ "build": "tsup --dts",
12
+ "build:debug": "tsup --dts --metafile",
13
+ "dev": "tsup --watch --dts",
14
+ "clean": "rm -rf .turbo dist",
15
+ "clean:modules": "rm -rf node_modules",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "jest",
18
+ "test:watch": "jest --watch --coverage=false"
19
+ },
20
+ "dependencies": {
21
+ "@sproutsocial/seeds-react-box": "*"
22
+ },
23
+ "devDependencies": {
24
+ "tsup": "^8.0.2",
25
+ "typescript": "^5.6.2",
26
+ "@types/react": "^18.0.0",
27
+ "@types/styled-components": "^5.1.26",
28
+ "react": "^18.0.0",
29
+ "styled-components": "^5.2.3",
30
+ "@sproutsocial/seeds-tsconfig": "*",
31
+ "@sproutsocial/seeds-testing": "*",
32
+ "@sproutsocial/seeds-react-testing-library": "*",
33
+ "@sproutsocial/seeds-react-button": "*"
34
+ },
35
+ "peerDependencies": {
36
+ "styled-components": "^5.2.3"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
@@ -0,0 +1,165 @@
1
+ import React, { useState } from "react";
2
+ import { Box } from "@sproutsocial/seeds-react-box";
3
+ import { Button } from "@sproutsocial/seeds-react-button";
4
+ import { Collapsible } from "./";
5
+ import type { Meta, StoryObj } from "@storybook/react";
6
+
7
+ export interface TypeStatefulCollapseProps {
8
+ children: React.ReactElement;
9
+ offset?: number;
10
+ initialIsOpen?: boolean;
11
+ }
12
+
13
+ const StatefulCollapse = ({
14
+ children,
15
+ offset = 0,
16
+ initialIsOpen = false,
17
+ }: TypeStatefulCollapseProps) => {
18
+ const [open, setOpen] = useState(initialIsOpen);
19
+
20
+ const toggle = () => setOpen(!open);
21
+
22
+ return (
23
+ <Collapsible isOpen={open} offset={offset}>
24
+ <Collapsible.Trigger>
25
+ <Button appearance="secondary" onClick={toggle}>
26
+ {open ? "Hide" : "Show"}
27
+ </Button>
28
+ </Collapsible.Trigger>
29
+
30
+ <Collapsible.Panel>{children}</Collapsible.Panel>
31
+ </Collapsible>
32
+ );
33
+ };
34
+
35
+ const meta: Meta<typeof Collapsible> = {
36
+ title: "Components/Collapsible",
37
+ component: Collapsible,
38
+ };
39
+
40
+ export default meta;
41
+
42
+ type Story = StoryObj<typeof Collapsible>;
43
+
44
+ export const DefaultStory: Story = {
45
+ render: () => (
46
+ <StatefulCollapse>
47
+ <Box width="100%" height="200px" bg="container.background.base" p={400}>
48
+ <Button appearance="secondary">A button</Button>
49
+ </Box>
50
+ </StatefulCollapse>
51
+ ),
52
+ };
53
+
54
+ export const InitialIsOpen: Story = {
55
+ render: () => (
56
+ <StatefulCollapse initialIsOpen>
57
+ <Box width="100%" height="200px" bg="container.background.base" p={400}>
58
+ <Button appearance="secondary">A button</Button>
59
+ </Box>
60
+ </StatefulCollapse>
61
+ ),
62
+ };
63
+
64
+ export const WithOffset: Story = {
65
+ render: () => (
66
+ <StatefulCollapse offset={100}>
67
+ <Box
68
+ width="100%"
69
+ height="200px"
70
+ bg="container.background.base"
71
+ p={400}
72
+ mt="100px"
73
+ >
74
+ <Button appearance="secondary">A button</Button>
75
+ </Box>
76
+ </StatefulCollapse>
77
+ ),
78
+ };
79
+
80
+ export const WithShortContent: Story = {
81
+ render: () => (
82
+ <StatefulCollapse>
83
+ <Box width="15%" height="50px" bg="container.background.base" p={400}>
84
+ Hello.
85
+ </Box>
86
+ </StatefulCollapse>
87
+ ),
88
+ };
89
+
90
+ export const WithTallContent: Story = {
91
+ render: () => (
92
+ <StatefulCollapse>
93
+ <Box width="15%" height="200vh" bg="container.background.base" p={400}>
94
+ Hello.
95
+ </Box>
96
+ </StatefulCollapse>
97
+ ),
98
+ };
99
+
100
+ interface TypeStatefulCollapseMinHeightProps {
101
+ children: React.ReactElement;
102
+ offset?: number;
103
+ collapsedHeight?: number;
104
+ openHeight?: number;
105
+ }
106
+
107
+ const StatefulCollapseWithMinHeight = ({
108
+ children,
109
+ offset = 0,
110
+ collapsedHeight = 0,
111
+ openHeight,
112
+ }: TypeStatefulCollapseMinHeightProps) => {
113
+ const [open, setOpen] = useState(false);
114
+
115
+ const toggle = () => setOpen(!open);
116
+
117
+ return (
118
+ <Collapsible
119
+ isOpen={open}
120
+ offset={offset}
121
+ openHeight={openHeight}
122
+ collapsedHeight={collapsedHeight}
123
+ >
124
+ <Collapsible.Panel>{children}</Collapsible.Panel>
125
+ <Collapsible.Trigger>
126
+ <Button onClick={toggle}>{open ? "Show Less" : "Show More"}</Button>
127
+ </Collapsible.Trigger>
128
+ </Collapsible>
129
+ );
130
+ };
131
+
132
+ export const WithCollapsedHeight: Story = {
133
+ render: () => (
134
+ <StatefulCollapseWithMinHeight collapsedHeight={100} openHeight={300}>
135
+ <Box width="500px" p={400}>
136
+ Threepio! Come in, Threepio! Threepio! Get to the top! I can’t Where
137
+ could he be? Threepio! Threepio, will you come in? They aren’t here!
138
+ Something must have happened to them. See if they’ve been captured.
139
+ Hurry! One thing’s for sure. We’re all going to be a lot thinner! Get on
140
+ top of it! I’m trying! Thank goodness, they haven’t found them! Where
141
+ could they be? Use the comlink? Oh, my! I forgot I turned it off! Are
142
+ you there, sir? Threepio! We’ve had some problems… Will you shut up and
143
+ listen to me? Shut down all garbage mashers on the detention level, will
144
+ you? Do you copy? Shut down all the garbage mashers on the detention
145
+ level. Shut down all the garbage mashers on the detention level. No.
146
+ Shut them all down! Hurry! Listen to them! They’re dying, Artoo! Curse
147
+ my metal body! I wasn’t fast enough. It’s all my fault! My poor master!
148
+ Threepio, we’re all right! We’re all right. You did great. Threepio!
149
+ Come in, Threepio! Threepio! Get to the top! I can’t Where could he be?
150
+ Threepio! Threepio, will you come in? They aren’t here! Something must
151
+ have happened to them. See if they’ve been captured. Hurry! One thing’s
152
+ for sure. We’re all going to be a lot thinner! Get on top of it! I’m
153
+ trying! Thank goodness, they haven’t found them! Where could they be?
154
+ Use the comlink? Oh, my! I forgot I turned it off! Are you there, sir?
155
+ Threepio! We’ve had some problems… Will you shut up and listen to me?
156
+ Shut down all garbage mashers on the detention level, will you? Do you
157
+ copy? Shut down all the garbage mashers on the detention level. Shut
158
+ down all the garbage mashers on the detention level. No. Shut them all
159
+ down! Hurry! Listen to them! They’re dying, Artoo! Curse my metal body!
160
+ I wasn’t fast enough. It’s all my fault! My poor master! Threepio, we’re
161
+ all right! We’re all right. You did great.
162
+ </Box>
163
+ </StatefulCollapseWithMinHeight>
164
+ ),
165
+ };
@@ -0,0 +1,136 @@
1
+ import * as React from "react";
2
+ import { useState, useRef, useContext, useEffect } from "react";
3
+ import { useMeasure } from "@sproutsocial/seeds-react-hooks";
4
+ import Box from "@sproutsocial/seeds-react-box";
5
+ import { CollapsingBox } from "./styles";
6
+ import type { TypeCollapsibleProps } from "./CollapsibleTypes";
7
+
8
+ let idCounter = 0;
9
+
10
+ interface TypeCollapsibleContext {
11
+ isOpen?: boolean;
12
+ id?: string;
13
+ offset?: number;
14
+ openHeight?: number;
15
+ collapsedHeight?: number;
16
+ }
17
+
18
+ const CollapsibleContext = React.createContext<TypeCollapsibleContext>({});
19
+
20
+ const Collapsible = ({
21
+ children,
22
+ isOpen = false,
23
+ offset = 0,
24
+ collapsedHeight = 0,
25
+ openHeight,
26
+ }: TypeCollapsibleProps) => {
27
+ const [id] = useState(`Racine-collapsible-${idCounter++}`);
28
+ return (
29
+ <CollapsibleContext.Provider
30
+ value={{
31
+ isOpen,
32
+ id,
33
+ offset,
34
+ collapsedHeight,
35
+ openHeight,
36
+ }}
37
+ >
38
+ {children}
39
+ </CollapsibleContext.Provider>
40
+ );
41
+ };
42
+
43
+ const determineMaxHeight = (
44
+ isHidden?: boolean,
45
+ openHeight?: number,
46
+ computedHeight?: number
47
+ ): number | undefined => {
48
+ // If isHidden is undefined this is the first render. Return undefined so the max-height prop is not added
49
+ // This is a hack to prevent css from animating if it begins in the open state
50
+ // css animates when attribute values change (IE from 0 to another number)
51
+ // css does not animate when simply adding an attribute to an HTML element
52
+ if (isHidden === undefined) return undefined;
53
+ // If the user has defined an explicit open height, return that as the max height
54
+ if (openHeight) return openHeight;
55
+ // Otherwise, fallback to the computed height
56
+ return computedHeight;
57
+ };
58
+
59
+ const Trigger = ({ children, ...rest }: { children: React.ReactElement }) => {
60
+ const { isOpen, id } = useContext(CollapsibleContext);
61
+ return (
62
+ <React.Fragment>
63
+ {React.cloneElement(children, {
64
+ "aria-controls": id,
65
+ "aria-expanded": !!isOpen,
66
+ ...rest,
67
+ })}
68
+ </React.Fragment>
69
+ );
70
+ };
71
+
72
+ Trigger.displayName = "Collapsible.Trigger";
73
+
74
+ const Panel = ({ children, ...rest }: { children: React.ReactNode }) => {
75
+ const {
76
+ isOpen,
77
+ id,
78
+ offset = 0,
79
+ collapsedHeight,
80
+ openHeight,
81
+ } = useContext(CollapsibleContext);
82
+
83
+ const ref = useRef<HTMLDivElement | null>(null);
84
+ const measurement = useMeasure(ref);
85
+ const [isHidden, setIsHidden] = useState<boolean | undefined>(undefined);
86
+ const maxHeight = determineMaxHeight(
87
+ isHidden,
88
+ openHeight,
89
+ // Round up to the nearest pixel to prevent subpixel rendering issues
90
+ Math.ceil(measurement.height + offset)
91
+ );
92
+
93
+ /* We use the "hidden" attribute to remove the contents of the panel from the tab order of the page, but it interferes with the animation. This logic sets a slight timeout on setting the prop so that the animation has time to complete before the attribute is set. */
94
+ useEffect(() => {
95
+ if (!isOpen) {
96
+ const timeoutID = setTimeout(() => setIsHidden(!isOpen), 300);
97
+ return () => clearTimeout(timeoutID);
98
+ } else {
99
+ // Similar to the close animation, we need to delay setting hidden to run slightly async.
100
+ // An issue occurs with the initial render isHidden logic that causes the animation to occur sporadically.
101
+ // using this 0 second timeout just allows this component to initially render with an undefined max height,
102
+ // Then go directly from undefined to the full max height, without a brief 0 value that triggers an animation
103
+ const timeoutID = setTimeout(() => setIsHidden(!isOpen), 0);
104
+ return () => clearTimeout(timeoutID);
105
+ }
106
+ }, [isOpen]);
107
+
108
+ return (
109
+ <CollapsingBox
110
+ hasShadow={Boolean(collapsedHeight || (openHeight && openHeight > 0))}
111
+ scrollable={isOpen}
112
+ maxHeight={isOpen ? maxHeight : collapsedHeight}
113
+ minHeight={collapsedHeight}
114
+ data-qa-collapsible=""
115
+ data-qa-collapsible-isopen={isOpen === true}
116
+ {...rest}
117
+ >
118
+ <Box
119
+ width="100%"
120
+ hidden={isHidden && collapsedHeight === 0}
121
+ aria-hidden={!isOpen}
122
+ id={id}
123
+ ref={ref}
124
+ >
125
+ {children}
126
+ </Box>
127
+ </CollapsingBox>
128
+ );
129
+ };
130
+
131
+ Panel.displayName = "Collapsible.Panel";
132
+
133
+ Collapsible.Trigger = Trigger;
134
+ Collapsible.Panel = Panel;
135
+
136
+ export default Collapsible;
@@ -0,0 +1,13 @@
1
+ import * as React from "react";
2
+
3
+ // The flow type is inexact but the underlying component does not accept any other props.
4
+ // It might be worth extending the box props here for the refactor, but allowing it would provide no functionality right now.
5
+ export interface TypeCollapsibleProps {
6
+ isOpen: boolean;
7
+ children: React.ReactNode;
8
+
9
+ /** If the children of the collapsible panel have a top or bottom margin, it will throw off the calculations for the height of the content. The total amount of vertical margin (in pixels) can be supplied to this prop to correct this. */
10
+ offset?: number;
11
+ collapsedHeight?: number;
12
+ openHeight?: number;
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import Collapsible from "./Collapsible";
2
+
3
+ export default Collapsible;
4
+ export { Collapsible };
5
+ export * from "./CollapsibleTypes";
package/src/styles.ts ADDED
@@ -0,0 +1,37 @@
1
+ import styled from "styled-components";
2
+ import Box from "@sproutsocial/seeds-react-box";
3
+
4
+ export const CollapsingBox = styled(Box)<{
5
+ hasShadow?: boolean;
6
+ scrollable?: boolean;
7
+ }>`
8
+ transition: max-height ${(p) => p.theme.duration.medium}
9
+ ${(p) => p.theme.easing.ease_inout};
10
+ will-change: max-height;
11
+ position: relative;
12
+ overflow: auto;
13
+ ${({ hasShadow, scrollable }) =>
14
+ hasShadow
15
+ ? `background: /* Shadow covers */ linear-gradient(
16
+ transparent 30%,
17
+ rgba(255, 255, 255, 0)
18
+ ),
19
+ linear-gradient(rgba(255, 255, 255, 0), transparent 70%) 0 100%,
20
+ /* Shadows */
21
+ radial-gradient(
22
+ farthest-side at 50% 0,
23
+ rgb(39 51 51 / 5%),
24
+ rgba(0, 0, 0, 0)
25
+ ),
26
+ radial-gradient(
27
+ farthest-side at 50% 100%,
28
+ rgb(39 51 51 / 5%),
29
+ rgba(0, 0, 0, 0)
30
+ )
31
+ 0 100%;
32
+ background-repeat: no-repeat;
33
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
34
+ background-attachment: local, local, scroll, scroll;
35
+ ${scrollable ? `overflow: auto` : `overflow: hidden`};`
36
+ : ""}
37
+ `;
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@sproutsocial/seeds-tsconfig/bundler/dom/library-monorepo",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "module": "esnext"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "coverage"]
9
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig((options) => ({
4
+ entry: ["src/index.ts"],
5
+ format: ["cjs", "esm"],
6
+ clean: true,
7
+ legacyOutput: true,
8
+ dts: options.dts,
9
+ external: ["react"],
10
+ sourcemap: true,
11
+ metafile: options.metafile,
12
+ }));