@varialkit/expandcollapse 0.1.1

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/docs.md ADDED
@@ -0,0 +1,99 @@
1
+ # ExpandCollapse
2
+
3
+ ExpandCollapse provides an accordion-style disclosure for revealing or hiding related content.
4
+ Use ExpandCollapseGroup to coordinate multiple sections.
5
+
6
+ ## Why It Exists
7
+
8
+ ExpandCollapse helps keep dense layouts readable by letting users reveal details only when needed.
9
+ It is ideal for settings panels, sidebars, summaries, and advanced sections where the content should
10
+ stay related but not always visible.
11
+
12
+ ## How It Works
13
+
14
+ - Each section renders a header button and a body container.
15
+ - Clicking the header toggles visibility of the body content.
16
+ - Body expand/collapse is animated with JS-measured height for smoother motion and less jump.
17
+ - ExpandCollapseGroup can manage multiple sections and optionally allow more than one open at a time.
18
+ - The `size` prop adjusts header spacing and typography while respecting global density.
19
+
20
+ ## How to Use
21
+
22
+ ```tsx
23
+ import { ExpandCollapse, ExpandCollapseGroup } from "@solara/expandcollapse";
24
+
25
+ export function Example() {
26
+ return (
27
+ <ExpandCollapseGroup
28
+ defaultOpen={[0]}
29
+ showBottomBorder
30
+ showToggleAllButton
31
+ defaultExpandAll={false}
32
+ showAllLabel="Show all sections"
33
+ collapseAllLabel="Collapse all sections">
34
+ <ExpandCollapse title="Overview">Overview content</ExpandCollapse>
35
+ <ExpandCollapse title="Details">Detailed content</ExpandCollapse>
36
+ </ExpandCollapseGroup>
37
+ );
38
+ }
39
+ ```
40
+
41
+ ## Group Toggle-All Behavior
42
+
43
+ - Set `showToggleAllButton` to render a ghost button at the bottom-left of the group.
44
+ - The button label switches between `showAllLabel` and `collapseAllLabel` based on current state.
45
+ - `defaultExpandAll` initializes all sections as open.
46
+ - If `defaultExpandAll` is `true`, it takes precedence over `defaultOpen`.
47
+
48
+ ## Icons
49
+
50
+ You can render a leading icon before the title. Icons inherit the header text color.
51
+
52
+ ```tsx
53
+ <ExpandCollapse title="Details" iconLeft="data_spreadsheet_search_24">
54
+ Content here
55
+ </ExpandCollapse>
56
+ ```
57
+
58
+ ## Best Practices
59
+
60
+ - Keep header titles short and scannable.
61
+ - Use the right-side slot for metadata or counts.
62
+ - Avoid nesting multiple accordion groups deeply.
63
+ - Use `flush` when the accordion should align with surrounding container edges.
64
+ - Use `paddingX` when the host layout needs a tighter or wider header row.
65
+ - Use `iconLeft` to add contextual icons without replacing the chevron.
66
+
67
+ ## Props
68
+
69
+ ### ExpandCollapse
70
+
71
+ | Prop | Type | Default | Description |
72
+ | --- | --- | --- | --- |
73
+ | `title` | `ReactNode` | _Required_ | Header title content. |
74
+ | `children` | `ReactNode` | _Required_ | Collapsible content. |
75
+ | `isOpen` | `boolean` | | Controlled open state. |
76
+ | `onToggle` | `(isOpen: boolean) => void` | | Open state callback. |
77
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Header size. |
78
+ | `iconPosition` | `"left" \| "right"` | `"right"` | Chevron placement. |
79
+ | `iconLeft` | `SolaraIconName \| IconProps` | | Optional leading icon before the title. |
80
+ | `rightContent` | `ReactNode` | | Optional content on the right side. |
81
+ | `paddingX` | `string \| number` | | Horizontal padding override. |
82
+ | `flush` | `boolean` | `false` | Remove horizontal padding. |
83
+ | `showBottomBorder` | `boolean` | `false` | Show a divider on the bottom edge of this item. |
84
+ | `className` | `string` | | Custom class name. |
85
+
86
+ ### ExpandCollapseGroup
87
+
88
+ | Prop | Type | Default | Description |
89
+ | --- | --- | --- | --- |
90
+ | `allowMultiple` | `boolean` | `false` | Allow multiple sections open at once. |
91
+ | `defaultOpen` | `number[]` | `[]` | Indexes to open by default. |
92
+ | `paddingX` | `string \| number` | | Shared horizontal padding override. |
93
+ | `flush` | `boolean` | | Shared flush state. |
94
+ | `showBottomBorder` | `boolean` | `false` | Show a divider under each item. |
95
+ | `showToggleAllButton` | `boolean` | `false` | Show a ghost button at the bottom to expand/collapse all. |
96
+ | `defaultExpandAll` | `boolean` | `false` | Start with all sections expanded. |
97
+ | `showAllLabel` | `string` | `"Show all"` | Bottom button label when sections are collapsed. |
98
+ | `collapseAllLabel` | `string` | `"Collapse all"` | Bottom button label when sections are expanded. |
99
+ | `className` | `string` | | Custom class name. |
@@ -0,0 +1 @@
1
+ export { stories } from "../examples";
package/examples.tsx ADDED
@@ -0,0 +1,288 @@
1
+ import React from "react";
2
+ import type { ReactElement } from "react";
3
+ import { iconNames } from "@solara/icons";
4
+ import type { SolaraIconName } from "@solara/icons";
5
+ import { ExpandCollapse, ExpandCollapseGroup } from "./src/ExpandCollapse";
6
+ import type { ExpandCollapseSize } from "./src/ExpandCollapse.types";
7
+
8
+ type StoryControlOption = {
9
+ label: string;
10
+ value: string;
11
+ };
12
+
13
+ type StoryControl = {
14
+ name: string;
15
+ label?: string;
16
+ type: "select" | "text" | "boolean" | "number";
17
+ options?: Array<string | StoryControlOption>;
18
+ min?: number;
19
+ max?: number;
20
+ step?: number;
21
+ };
22
+
23
+ type StoryDefinition = {
24
+ title: string;
25
+ description?: string;
26
+ render: (props: Record<string, unknown>) => ReactElement;
27
+ controls?: StoryControl[];
28
+ initialProps?: Record<string, unknown>;
29
+ showProps?: boolean;
30
+ applyPropsToPreview?: boolean;
31
+ code?: string;
32
+ };
33
+
34
+ export const stories: Record<string, StoryDefinition> = {
35
+ playground: {
36
+ title: "Playground",
37
+ description: "Expand or collapse sections with optional right-side metadata.",
38
+ render: (props) => (
39
+ <ExpandCollapse
40
+ title={(props.title as string) ?? "Project details"}
41
+ size={props.size as ExpandCollapseSize}
42
+ iconPosition={(props.iconPosition as "left" | "right") ?? "right"}
43
+ iconLeft={(props.iconLeft as SolaraIconName) || undefined}
44
+ rightContent={
45
+ props.showRightContent ? (
46
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>
47
+ Updated today
48
+ </span>
49
+ ) : null
50
+ }
51
+ flush={props.flush as boolean}>
52
+ <div style={{ display: "grid", gap: "0.5rem" }}>
53
+ <p style={{ margin: 0 }}>
54
+ Keep related metadata grouped so it is easy to scan when expanded.
55
+ </p>
56
+ <p style={{ margin: 0 }}>Add links, fields, or actions here.</p>
57
+ </div>
58
+ </ExpandCollapse>
59
+ ),
60
+ controls: [
61
+ { name: "title", label: "Title", type: "text" },
62
+ {
63
+ name: "size",
64
+ label: "Size",
65
+ type: "select",
66
+ options: ["sm", "md", "lg"],
67
+ },
68
+ {
69
+ name: "iconPosition",
70
+ label: "Icon Position",
71
+ type: "select",
72
+ options: ["left", "right"],
73
+ },
74
+ {
75
+ name: "iconLeft",
76
+ label: "Icon Left",
77
+ type: "select",
78
+ options: ["", ...iconNames],
79
+ },
80
+ { name: "showRightContent", label: "Right Content", type: "boolean" },
81
+ { name: "flush", label: "Flush", type: "boolean" },
82
+ ],
83
+ initialProps: {
84
+ title: "Project details",
85
+ size: "md",
86
+ iconPosition: "right",
87
+ showRightContent: true,
88
+ flush: false,
89
+ iconLeft: "",
90
+ },
91
+ },
92
+ group: {
93
+ title: "Group",
94
+ showProps: true,
95
+ render: (props) => (
96
+ <ExpandCollapseGroup
97
+ defaultOpen={[0]}
98
+ showBottomBorder={props.showBottomBorder as boolean}
99
+ showToggleAllButton={props.showToggleAllButton as boolean}
100
+ defaultExpandAll={props.defaultExpandAll as boolean}
101
+ showAllLabel={(props.showAllLabel as string) ?? "Show all"}
102
+ collapseAllLabel={(props.collapseAllLabel as string) ?? "Collapse all"}>
103
+ <ExpandCollapse title="Overview">
104
+ <p style={{ margin: 0 }}>High-level summary and status go here.</p>
105
+ </ExpandCollapse>
106
+ <ExpandCollapse title="Metrics">
107
+ <p style={{ margin: 0 }}>Show charts, KPIs, or tables in this section.</p>
108
+ </ExpandCollapse>
109
+ <ExpandCollapse title="Timeline">
110
+ <p style={{ margin: 0 }}>List milestones or upcoming events.</p>
111
+ </ExpandCollapse>
112
+ </ExpandCollapseGroup>
113
+ ),
114
+ controls: [
115
+ { name: "showBottomBorder", label: "Show Bottom Border", type: "boolean" },
116
+ { name: "showToggleAllButton", label: "Show Toggle All Button", type: "boolean" },
117
+ { name: "defaultExpandAll", label: "Default Expand All", type: "boolean" },
118
+ { name: "showAllLabel", label: "Show All Label", type: "text" },
119
+ { name: "collapseAllLabel", label: "Collapse All Label", type: "text" },
120
+ ],
121
+ initialProps: {
122
+ showBottomBorder: true,
123
+ showToggleAllButton: true,
124
+ defaultExpandAll: false,
125
+ showAllLabel: "Show all",
126
+ collapseAllLabel: "Collapse all",
127
+ },
128
+ code: `import { ExpandCollapse, ExpandCollapseGroup } from "@solara/expandcollapse";
129
+
130
+ export function Example() {
131
+ return (
132
+ <ExpandCollapseGroup defaultOpen={[0]} showBottomBorder showToggleAllButton>
133
+ <ExpandCollapse title="Overview">
134
+ <p style={{ margin: 0 }}>High-level summary and status go here.</p>
135
+ </ExpandCollapse>
136
+ <ExpandCollapse title="Metrics">
137
+ <p style={{ margin: 0 }}>Show charts, KPIs, or tables in this section.</p>
138
+ </ExpandCollapse>
139
+ <ExpandCollapse title="Timeline">
140
+ <p style={{ margin: 0 }}>List milestones or upcoming events.</p>
141
+ </ExpandCollapse>
142
+ </ExpandCollapseGroup>
143
+ );
144
+ }
145
+ `,
146
+ },
147
+ toggleAll: {
148
+ title: "Toggle All",
149
+ description: "Show a bottom ghost button to expand/collapse all items.",
150
+ showProps: true,
151
+ render: (props) => (
152
+ <ExpandCollapseGroup
153
+ showToggleAllButton={props.showToggleAllButton as boolean}
154
+ defaultExpandAll={props.defaultExpandAll as boolean}
155
+ showBottomBorder={props.showBottomBorder as boolean}
156
+ showAllLabel={(props.showAllLabel as string) ?? "Show all"}
157
+ collapseAllLabel={(props.collapseAllLabel as string) ?? "Collapse all"}>
158
+ <ExpandCollapse title="Overview">
159
+ <p style={{ margin: 0 }}>High-level summary and status go here.</p>
160
+ </ExpandCollapse>
161
+ <ExpandCollapse title="Metrics">
162
+ <p style={{ margin: 0 }}>Show charts, KPIs, or tables in this section.</p>
163
+ </ExpandCollapse>
164
+ <ExpandCollapse title="Timeline">
165
+ <p style={{ margin: 0 }}>List milestones or upcoming events.</p>
166
+ </ExpandCollapse>
167
+ </ExpandCollapseGroup>
168
+ ),
169
+ controls: [
170
+ { name: "showBottomBorder", label: "Show Bottom Border", type: "boolean" },
171
+ { name: "showToggleAllButton", label: "Show Toggle All Button", type: "boolean" },
172
+ { name: "defaultExpandAll", label: "Default Expand All", type: "boolean" },
173
+ { name: "showAllLabel", label: "Show All Label", type: "text" },
174
+ { name: "collapseAllLabel", label: "Collapse All Label", type: "text" },
175
+ ],
176
+ initialProps: {
177
+ showBottomBorder: true,
178
+ showToggleAllButton: true,
179
+ defaultExpandAll: false,
180
+ showAllLabel: "Show all",
181
+ collapseAllLabel: "Collapse all",
182
+ },
183
+ code: `import { ExpandCollapse, ExpandCollapseGroup } from "@solara/expandcollapse";
184
+
185
+ export function Example() {
186
+ return (
187
+ <ExpandCollapseGroup showToggleAllButton defaultExpandAll={false} showBottomBorder>
188
+ <ExpandCollapse title="Overview">
189
+ <p style={{ margin: 0 }}>High-level summary and status go here.</p>
190
+ </ExpandCollapse>
191
+ <ExpandCollapse title="Metrics">
192
+ <p style={{ margin: 0 }}>Show charts, KPIs, or tables in this section.</p>
193
+ </ExpandCollapse>
194
+ <ExpandCollapse title="Timeline">
195
+ <p style={{ margin: 0 }}>List milestones or upcoming events.</p>
196
+ </ExpandCollapse>
197
+ </ExpandCollapseGroup>
198
+ );
199
+ }
200
+ `,
201
+ },
202
+ metadata: {
203
+ title: "Metadata + Actions",
204
+ description: "Show related context in the header while keeping the details collapsed by default.",
205
+ showProps: false,
206
+ render: () => (
207
+ <ExpandCollapseGroup defaultOpen={[1]}>
208
+ <ExpandCollapse
209
+ title="Client summary"
210
+ rightContent={<span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>3 items</span>}
211
+ >
212
+ <p style={{ margin: 0 }}>Keep the overview short so the toggle remains scannable.</p>
213
+ </ExpandCollapse>
214
+ <ExpandCollapse
215
+ title="Next steps"
216
+ rightContent={
217
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>Due Friday</span>
218
+ }
219
+ >
220
+ <div style={{ display: "grid", gap: "0.5rem" }}>
221
+ <p style={{ margin: 0 }}>Schedule the follow-up call.</p>
222
+ <p style={{ margin: 0 }}>Share the revised estimate.</p>
223
+ </div>
224
+ </ExpandCollapse>
225
+ <ExpandCollapse title="Dependencies">
226
+ <p style={{ margin: 0 }}>List external blockers or required approvals here.</p>
227
+ </ExpandCollapse>
228
+ </ExpandCollapseGroup>
229
+ ),
230
+ code: `import { ExpandCollapse, ExpandCollapseGroup } from "@solara/expandcollapse";
231
+
232
+ export function Example() {
233
+ return (
234
+ <ExpandCollapseGroup defaultOpen={[1]}>
235
+ <ExpandCollapse
236
+ title="Client summary"
237
+ rightContent={<span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>3 items</span>}
238
+ >
239
+ <p style={{ margin: 0 }}>Keep the overview short so the toggle remains scannable.</p>
240
+ </ExpandCollapse>
241
+ <ExpandCollapse
242
+ title="Next steps"
243
+ rightContent={<span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>Due Friday</span>}
244
+ >
245
+ <div style={{ display: "grid", gap: "0.5rem" }}>
246
+ <p style={{ margin: 0 }}>Schedule the follow-up call.</p>
247
+ <p style={{ margin: 0 }}>Share the revised estimate.</p>
248
+ </div>
249
+ </ExpandCollapse>
250
+ <ExpandCollapse title="Dependencies">
251
+ <p style={{ margin: 0 }}>List external blockers or required approvals here.</p>
252
+ </ExpandCollapse>
253
+ </ExpandCollapseGroup>
254
+ );
255
+ }
256
+ `,
257
+ },
258
+ icons: {
259
+ title: "Icons",
260
+ description: "Add leading icons for extra context.",
261
+ showProps: false,
262
+ render: () => (
263
+ <ExpandCollapseGroup defaultOpen={[0]}>
264
+ <ExpandCollapse title="Overview" iconLeft="data_spreadsheet_search_24">
265
+ <p style={{ margin: 0 }}>Use icons to reinforce section meaning.</p>
266
+ </ExpandCollapse>
267
+ <ExpandCollapse title="Security" iconLeft="arrow_line_up_16">
268
+ <p style={{ margin: 0 }}>Add compliance and audit details here.</p>
269
+ </ExpandCollapse>
270
+ </ExpandCollapseGroup>
271
+ ),
272
+ code: `import { ExpandCollapse, ExpandCollapseGroup } from "@solara/expandcollapse";
273
+
274
+ export function Example() {
275
+ return (
276
+ <ExpandCollapseGroup defaultOpen={[0]}>
277
+ <ExpandCollapse title="Overview" iconLeft="data_spreadsheet_search_24">
278
+ <p style={{ margin: 0 }}>Use icons to reinforce section meaning.</p>
279
+ </ExpandCollapse>
280
+ <ExpandCollapse title="Security" iconLeft="arrow_line_up_16">
281
+ <p style={{ margin: 0 }}>Add compliance and audit details here.</p>
282
+ </ExpandCollapse>
283
+ </ExpandCollapseGroup>
284
+ );
285
+ }
286
+ `,
287
+ },
288
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@varialkit/expandcollapse",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples/index.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/button": "0.1.1",
13
+ "@varialkit/icons": "0.1.1"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "docs.md",
18
+ "examples",
19
+ "examples.tsx"
20
+ ],
21
+ "peerDependencies": {
22
+ "react": "^19.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "19.0.10",
26
+ "react": "19.0.0"
27
+ }
28
+ }
@@ -0,0 +1,147 @@
1
+ .solara-expand-collapse {
2
+ overflow: hidden;
3
+ font-family: var(--font-body);
4
+ color: var(--color-text-primary);
5
+ border-radius: var(--radius-2);
6
+ }
7
+
8
+ .solara-expand-collapse--bottom-border {
9
+ border-bottom: 1px solid var(--color-divider-secondary);
10
+ }
11
+
12
+ .solara-expand-collapse__header {
13
+ display: flex;
14
+ justify-content: space-between;
15
+ align-items: center;
16
+ width: 100%;
17
+ background-color: transparent;
18
+ border: none;
19
+ padding: calc(var(--space-2) * var(--spacing-multiplier))
20
+ calc(var(--expand-collapse-px, var(--space-3)) * var(--spacing-multiplier));
21
+ cursor: pointer;
22
+ text-align: left;
23
+ font-size: var(--font-size-caption-scaled);
24
+ line-height: var(--line-height-caption-scaled);
25
+ border-radius: inherit;
26
+ transition: background-color 0.2s ease;
27
+ box-sizing: border-box;
28
+
29
+ &:hover {
30
+ background-color: var(--color-surface-100);
31
+ }
32
+
33
+ &:focus-visible {
34
+ outline: none;
35
+ box-shadow: 0 0 0 3px var(--color-focus-halo);
36
+ }
37
+ }
38
+
39
+ .solara-expand-collapse__title {
40
+ font-weight: 500;
41
+ flex-grow: 1;
42
+ color: var(--color-text-primary);
43
+ }
44
+
45
+ .solara-expand-collapse__leading {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ color: currentColor;
50
+ margin-right: calc(var(--space-2) * var(--spacing-multiplier));
51
+ }
52
+
53
+ .solara-expand-collapse__leading .solara-icon [stroke]:not([stroke="none"]) {
54
+ stroke: currentColor;
55
+ }
56
+
57
+ .solara-expand-collapse__leading .solara-icon [fill]:not([fill="none"]) {
58
+ fill: currentColor;
59
+ }
60
+
61
+ .solara-expand-collapse__right {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
65
+ margin-left: calc(var(--space-2) * var(--spacing-multiplier));
66
+ }
67
+
68
+ .solara-expand-collapse__icon {
69
+ transition: transform 0.2s ease-in-out;
70
+ flex-shrink: 0;
71
+ color: var(--color-text-secondary);
72
+ }
73
+
74
+ .solara-expand-collapse__icon--open {
75
+ transform: rotate(90deg);
76
+ }
77
+
78
+ .solara-expand-collapse__header--icon-left {
79
+ justify-content: flex-start;
80
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
81
+ }
82
+
83
+ .solara-expand-collapse__header--icon-left .solara-expand-collapse__leading {
84
+ margin-right: 0;
85
+ }
86
+
87
+ .solara-expand-collapse__content {
88
+ padding: calc(var(--space-2) * var(--spacing-multiplier))
89
+ calc(var(--expand-collapse-px, var(--space-3)) * var(--spacing-multiplier));
90
+ border-top: 1px solid var(--color-divider-secondary);
91
+ color: var(--color-text-secondary);
92
+ }
93
+
94
+ .solara-expand-collapse__panel {
95
+ overflow: hidden;
96
+ transition:
97
+ height 240ms cubic-bezier(0.22, 1, 0.36, 1),
98
+ opacity 180ms ease;
99
+ will-change: height, opacity;
100
+ }
101
+
102
+ .solara-expand-collapse--size-sm .solara-expand-collapse__header {
103
+ padding: calc(var(--space-1) * var(--spacing-multiplier))
104
+ calc(var(--expand-collapse-px, var(--space-2)) * var(--spacing-multiplier));
105
+ font-size: var(--font-size-footnote-scaled);
106
+ line-height: var(--line-height-footnote-scaled);
107
+ }
108
+
109
+ .solara-expand-collapse--size-sm .solara-expand-collapse__content {
110
+ padding: calc(var(--space-2) * var(--spacing-multiplier))
111
+ calc(var(--expand-collapse-px, var(--space-2)) * var(--spacing-multiplier));
112
+ }
113
+
114
+ .solara-expand-collapse--size-lg .solara-expand-collapse__header {
115
+ padding: calc(var(--space-3) * var(--spacing-multiplier))
116
+ calc(var(--expand-collapse-px, var(--space-4)) * var(--spacing-multiplier));
117
+ font-size: var(--font-size-body-scaled);
118
+ line-height: var(--line-height-body-scaled);
119
+ }
120
+
121
+ .solara-expand-collapse--size-lg .solara-expand-collapse__content {
122
+ padding: calc(var(--space-3) * var(--spacing-multiplier))
123
+ calc(var(--expand-collapse-px, var(--space-4)) * var(--spacing-multiplier));
124
+ }
125
+
126
+ .solara-expand-collapse--flush {
127
+ border-radius: 0;
128
+ }
129
+
130
+ .solara-expand-collapse--flush .solara-expand-collapse__header,
131
+ .solara-expand-collapse--flush .solara-expand-collapse__content {
132
+ padding-left: 0;
133
+ padding-right: 0;
134
+ }
135
+
136
+ .solara-expand-collapse-group__toggle-all {
137
+ display: flex;
138
+ justify-content: flex-start;
139
+ margin-top: calc(var(--space-1) * var(--spacing-multiplier));
140
+ }
141
+
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .solara-expand-collapse__panel,
144
+ .solara-expand-collapse__icon {
145
+ transition: none;
146
+ }
147
+ }
@@ -0,0 +1,255 @@
1
+ import React, { Children, cloneElement, useLayoutEffect, useRef, useState } from "react";
2
+ import { Button } from "@solara/button";
3
+ import { Icon } from "@solara/icons";
4
+ import type { IconProps } from "@solara/icons";
5
+ import type { ExpandCollapseProps, ExpandCollapseGroupProps } from "./ExpandCollapse.types";
6
+ import "./ExpandCollapse.scss";
7
+
8
+ type ExpandCollapseIcon = IconProps | IconProps["name"];
9
+
10
+ const normalizeIconProps = (icon: ExpandCollapseIcon): IconProps =>
11
+ typeof icon === "string" ? { name: icon } : icon;
12
+
13
+ const resolveIconProps = (icon: ExpandCollapseIcon): IconProps => {
14
+ const iconProps = normalizeIconProps(icon);
15
+
16
+ return {
17
+ ...iconProps,
18
+ style: {
19
+ ...iconProps.style,
20
+ color: "currentColor",
21
+ },
22
+ };
23
+ };
24
+
25
+ const ChevronIcon = ({ open }: { open: boolean }) => (
26
+ <svg
27
+ viewBox="0 0 20 20"
28
+ width="16"
29
+ height="16"
30
+ className={[
31
+ "solara-expand-collapse__icon",
32
+ open ? "solara-expand-collapse__icon--open" : null,
33
+ ]
34
+ .filter(Boolean)
35
+ .join(" ")}
36
+ aria-hidden="true">
37
+ <path
38
+ d="M7 4.5l6 5.5-6 5.5"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ strokeWidth="1.8"
42
+ strokeLinecap="round"
43
+ strokeLinejoin="round"
44
+ />
45
+ </svg>
46
+ );
47
+
48
+ export const ExpandCollapse: React.FC<ExpandCollapseProps> = ({
49
+ title,
50
+ children,
51
+ isOpen: controlledIsOpen,
52
+ onToggle,
53
+ size = "md",
54
+ className,
55
+ iconPosition = "right",
56
+ iconLeft,
57
+ rightContent,
58
+ paddingX,
59
+ flush = false,
60
+ showBottomBorder = false,
61
+ }) => {
62
+ const [internalIsOpen, setInternalIsOpen] = useState(false);
63
+ const isOpen = controlledIsOpen ?? internalIsOpen;
64
+ const contentPanelRef = useRef<HTMLDivElement>(null);
65
+ const [panelHeight, setPanelHeight] = useState<number | "auto">(isOpen ? "auto" : 0);
66
+ const [isAnimating, setIsAnimating] = useState(false);
67
+
68
+ const handleToggle = () => {
69
+ const next = !isOpen;
70
+ if (controlledIsOpen === undefined) {
71
+ setInternalIsOpen(next);
72
+ }
73
+ onToggle?.(next);
74
+ };
75
+
76
+ useLayoutEffect(() => {
77
+ const panel = contentPanelRef.current;
78
+ if (!panel) {
79
+ return;
80
+ }
81
+
82
+ const nextHeight = panel.scrollHeight;
83
+
84
+ if (isOpen) {
85
+ // Opening: animate to measured height, then switch to auto after transition.
86
+ if (panelHeight !== "auto") {
87
+ setIsAnimating(true);
88
+ setPanelHeight(nextHeight);
89
+ }
90
+ return;
91
+ }
92
+
93
+ if (panelHeight === 0) {
94
+ return;
95
+ }
96
+
97
+ // Closing from auto requires one frame at a fixed height before collapsing.
98
+ if (panelHeight === "auto") {
99
+ setPanelHeight(nextHeight);
100
+ requestAnimationFrame(() => {
101
+ setIsAnimating(true);
102
+ setPanelHeight(0);
103
+ });
104
+ return;
105
+ }
106
+
107
+ setIsAnimating(true);
108
+ setPanelHeight(0);
109
+ }, [isOpen, panelHeight, children]);
110
+
111
+ const handlePanelTransitionEnd: React.TransitionEventHandler<HTMLDivElement> = (event) => {
112
+ if (event.propertyName !== "height") {
113
+ return;
114
+ }
115
+
116
+ if (isOpen) {
117
+ setPanelHeight("auto");
118
+ }
119
+ setIsAnimating(false);
120
+ };
121
+
122
+ const headerStyle =
123
+ paddingX !== undefined && !flush
124
+ ? {
125
+ "--expand-collapse-px":
126
+ typeof paddingX === "number" ? `${paddingX}px` : paddingX,
127
+ }
128
+ : undefined;
129
+
130
+ const rootClasses = [
131
+ "solara-expand-collapse",
132
+ `solara-expand-collapse--size-${size}`,
133
+ flush ? "solara-expand-collapse--flush" : null,
134
+ showBottomBorder ? "solara-expand-collapse--bottom-border" : null,
135
+ className,
136
+ ]
137
+ .filter(Boolean)
138
+ .join(" ");
139
+
140
+ const headerClasses = [
141
+ "solara-expand-collapse__header",
142
+ iconPosition === "left" ? "solara-expand-collapse__header--icon-left" : null,
143
+ ]
144
+ .filter(Boolean)
145
+ .join(" ");
146
+
147
+ const leadingIcon = iconLeft ? (
148
+ <span className="solara-expand-collapse__leading" aria-hidden="true">
149
+ <Icon {...resolveIconProps(iconLeft)} />
150
+ </span>
151
+ ) : null;
152
+
153
+ const panelStyle = {
154
+ height: panelHeight === "auto" ? "auto" : `${panelHeight}px`,
155
+ opacity: isOpen ? 1 : 0,
156
+ };
157
+
158
+ return (
159
+ <div className={rootClasses}>
160
+ <button
161
+ type="button"
162
+ className={headerClasses}
163
+ style={headerStyle}
164
+ onClick={handleToggle}
165
+ aria-expanded={isOpen}>
166
+ {iconPosition === "left" ? <ChevronIcon open={isOpen} /> : null}
167
+ {leadingIcon}
168
+ <span className="solara-expand-collapse__title">{title}</span>
169
+ <span className="solara-expand-collapse__right">
170
+ {rightContent}
171
+ {iconPosition === "right" ? <ChevronIcon open={isOpen} /> : null}
172
+ </span>
173
+ </button>
174
+ <div
175
+ ref={contentPanelRef}
176
+ className="solara-expand-collapse__panel"
177
+ style={panelStyle}
178
+ onTransitionEnd={handlePanelTransitionEnd}
179
+ aria-hidden={!isOpen && !isAnimating}>
180
+ <div className="solara-expand-collapse__content">{children}</div>
181
+ </div>
182
+ </div>
183
+ );
184
+ };
185
+
186
+ ExpandCollapse.displayName = "ExpandCollapse";
187
+
188
+ export const ExpandCollapseGroup: React.FC<ExpandCollapseGroupProps> = ({
189
+ children,
190
+ allowMultiple = false,
191
+ defaultOpen = [],
192
+ className,
193
+ paddingX,
194
+ flush,
195
+ showBottomBorder = false,
196
+ showToggleAllButton = false,
197
+ defaultExpandAll = false,
198
+ showAllLabel = "Show all",
199
+ collapseAllLabel = "Collapse all",
200
+ }) => {
201
+ const childrenArray = Children.toArray(children);
202
+ const allIndexes = childrenArray.map((_, index) => index);
203
+
204
+ const [openIndexes, setOpenIndexes] = useState<number[]>(
205
+ defaultExpandAll ? allIndexes : defaultOpen,
206
+ );
207
+
208
+ const handleToggle = (index: number) => {
209
+ setOpenIndexes((prevOpenIndexes) => {
210
+ if (allowMultiple) {
211
+ return prevOpenIndexes.includes(index)
212
+ ? prevOpenIndexes.filter((item) => item !== index)
213
+ : [...prevOpenIndexes, index];
214
+ }
215
+ return prevOpenIndexes.includes(index) ? [] : [index];
216
+ });
217
+ };
218
+
219
+ const isAllExpanded = allIndexes.length > 0 && openIndexes.length === allIndexes.length;
220
+
221
+ const handleToggleAll = () => {
222
+ // Global toggle uses current group state to either open every section or collapse all.
223
+ setOpenIndexes(isAllExpanded ? [] : allIndexes);
224
+ };
225
+
226
+ const items = Children.map(childrenArray, (child, index) => {
227
+ if (React.isValidElement<ExpandCollapseProps>(child)) {
228
+ return cloneElement(child, {
229
+ isOpen: openIndexes.includes(index),
230
+ onToggle: () => handleToggle(index),
231
+ paddingX: child.props.paddingX ?? paddingX,
232
+ flush: child.props.flush ?? flush,
233
+ showBottomBorder: child.props.showBottomBorder ?? showBottomBorder,
234
+ });
235
+ }
236
+ return child;
237
+ });
238
+
239
+ return (
240
+ <div className={className}>
241
+ {items}
242
+ {showToggleAllButton ? (
243
+ <div className="solara-expand-collapse-group__toggle-all">
244
+ <Button
245
+ type="button"
246
+ variant="ghost"
247
+ size="small"
248
+ label={isAllExpanded ? collapseAllLabel : showAllLabel}
249
+ onClick={handleToggleAll}
250
+ />
251
+ </div>
252
+ ) : null}
253
+ </div>
254
+ );
255
+ };
@@ -0,0 +1,56 @@
1
+ import type React from "react";
2
+ import type { IconProps } from "@solara/icons";
3
+
4
+ export type ExpandCollapseSize = "sm" | "md" | "lg";
5
+
6
+ export interface ExpandCollapseProps {
7
+ /** Title content for the trigger row. */
8
+ title: React.ReactNode;
9
+ /** Collapsible body content. */
10
+ children: React.ReactNode;
11
+ /** Controlled open state. */
12
+ isOpen?: boolean;
13
+ /** Called when the open state changes. */
14
+ onToggle?: (isOpen: boolean) => void;
15
+ /** Size of the trigger row. */
16
+ size?: ExpandCollapseSize;
17
+ /** Root class name. */
18
+ className?: string;
19
+ /** Icon placement. */
20
+ iconPosition?: "left" | "right";
21
+ /** Optional leading icon displayed before the title. */
22
+ iconLeft?: IconProps | IconProps["name"];
23
+ /** Optional content on the right side of the header. */
24
+ rightContent?: React.ReactNode;
25
+ /** Horizontal padding override (number is px). */
26
+ paddingX?: string | number;
27
+ /** Remove horizontal padding + border radius. */
28
+ flush?: boolean;
29
+ /** Show a divider on the bottom edge of this item. */
30
+ showBottomBorder?: boolean;
31
+ }
32
+
33
+ export interface ExpandCollapseGroupProps {
34
+ /** ExpandCollapse children. */
35
+ children: React.ReactNode;
36
+ /** Allow multiple sections open simultaneously. */
37
+ allowMultiple?: boolean;
38
+ /** Indexes of sections that start open. */
39
+ defaultOpen?: number[];
40
+ /** Group class name. */
41
+ className?: string;
42
+ /** Shared horizontal padding override. */
43
+ paddingX?: string | number;
44
+ /** Shared flush setting. */
45
+ flush?: boolean;
46
+ /** Show a border under each item. */
47
+ showBottomBorder?: boolean;
48
+ /** Render a bottom button that toggles all sections open/closed. */
49
+ showToggleAllButton?: boolean;
50
+ /** Start with all sections expanded. */
51
+ defaultExpandAll?: boolean;
52
+ /** Label used when all sections are collapsed. */
53
+ showAllLabel?: string;
54
+ /** Label used when all sections are expanded. */
55
+ collapseAllLabel?: string;
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { ExpandCollapse, ExpandCollapseGroup } from "./ExpandCollapse";
2
+ export type {
3
+ ExpandCollapseProps,
4
+ ExpandCollapseGroupProps,
5
+ ExpandCollapseSize,
6
+ } from "./ExpandCollapse.types";