@varialkit/expandcollapse 0.1.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/docs.md +99 -0
- package/examples/index.tsx +1 -0
- package/examples.tsx +288 -0
- package/package.json +28 -0
- package/src/ExpandCollapse.scss +147 -0
- package/src/ExpandCollapse.tsx +255 -0
- package/src/ExpandCollapse.types.ts +56 -0
- package/src/index.ts +6 -0
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.0",
|
|
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/icons": "0.1.0",
|
|
13
|
+
"@varialkit/button": "0.1.0"
|
|
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
|
+
}
|