@khanacademy/wonder-blocks-accordion 1.3.6 → 1.3.8

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.
@@ -1,271 +0,0 @@
1
- import * as React from "react";
2
- import {StyleSheet} from "aphrodite";
3
- import caretDown from "@phosphor-icons/core/bold/caret-down-bold.svg";
4
-
5
- import Clickable from "@khanacademy/wonder-blocks-clickable";
6
- import {View} from "@khanacademy/wonder-blocks-core";
7
- import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
8
- import {HeadingSmall} from "@khanacademy/wonder-blocks-typography";
9
- import * as tokens from "@khanacademy/wonder-blocks-tokens";
10
- import type {StyleType} from "@khanacademy/wonder-blocks-core";
11
-
12
- import type {AccordionCornerKindType} from "./accordion";
13
- import type {TagType} from "./accordion-section";
14
- import {getRoundedValuesForHeader} from "../utils";
15
-
16
- type Props = {
17
- // Unique ID for this section's button.
18
- id: string;
19
- // Header content.
20
- header: string | React.ReactElement;
21
- // Whether the caret shows up at the start or end of the header block.
22
- caretPosition: "start" | "end";
23
- // Corner roundedness type.
24
- cornerKind: AccordionCornerKindType;
25
- // Whether the section is collapsible or not. If false, the header will
26
- // not be clickable, and the section will stay expanded at all times.
27
- collapsible?: boolean;
28
- // Whether the section is expanded or not.
29
- expanded: boolean;
30
- // Whether to include animation on the header. This should be false
31
- // if the user has `prefers-reduced-motion` opted in. Defaults to false.
32
- animated: boolean;
33
- // Called on header click.
34
- onClick?: () => void;
35
- // The ID for the content that the header's `aria-controls` should
36
- // point to.
37
- sectionContentUniqueId: string;
38
- // Custom styles for the header container.
39
- headerStyle?: StyleType;
40
- // The semantic tag for this clickable header (e.g. "h1", "h2", etc.)
41
- // Please use this to ensure that the header is hierarchically correct.
42
- tag?: TagType;
43
- // The test ID used for e2e testing.
44
- testId?: string;
45
- // Whether this section is the first section in the accordion.
46
- // For internal use only.
47
- isFirstSection: boolean;
48
- // Whether this section is the last section in the accordion.
49
- // For internal use only.
50
- isLastSection: boolean;
51
- };
52
-
53
- const AccordionSectionHeader = React.forwardRef(function AccordionSectionHeader(
54
- props: Props,
55
- ref: React.ForwardedRef<HTMLButtonElement>,
56
- ) {
57
- const {
58
- id,
59
- header,
60
- caretPosition,
61
- cornerKind,
62
- collapsible = true,
63
- expanded,
64
- animated,
65
- onClick,
66
- sectionContentUniqueId,
67
- headerStyle,
68
- tag = "h2",
69
- testId,
70
- isFirstSection,
71
- isLastSection,
72
- } = props;
73
-
74
- const headerIsString = typeof header === "string";
75
-
76
- const {roundedTop, roundedBottom} = getRoundedValuesForHeader(
77
- cornerKind,
78
- isFirstSection,
79
- isLastSection,
80
- expanded,
81
- );
82
-
83
- return (
84
- <HeadingSmall tag={tag} style={styles.heading}>
85
- <Clickable
86
- id={id}
87
- aria-expanded={expanded}
88
- aria-controls={sectionContentUniqueId}
89
- onClick={onClick}
90
- disabled={!collapsible}
91
- testId={testId ? `${testId}-header` : undefined}
92
- style={[
93
- styles.headerWrapper,
94
- animated && styles.headerWrapperWithAnimation,
95
- caretPosition === "start" && styles.headerWrapperCaretStart,
96
- roundedTop && styles.roundedTop,
97
- roundedBottom && styles.roundedBottom,
98
- headerStyle,
99
- !collapsible && styles.disabled,
100
- ]}
101
- ref={ref}
102
- >
103
- {() => (
104
- <>
105
- <View
106
- style={[
107
- styles.headerContent,
108
- headerIsString && styles.headerString,
109
- ]}
110
- >
111
- {headerIsString ? (
112
- <View
113
- style={[
114
- caretPosition === "end"
115
- ? styles.headerStringCaretEnd
116
- : styles.headerStringCaretStart,
117
- ]}
118
- >
119
- {header}
120
- </View>
121
- ) : (
122
- header
123
- )}
124
- </View>
125
- {collapsible && (
126
- <PhosphorIcon
127
- icon={caretDown}
128
- color={tokens.color.offBlack64}
129
- size="small"
130
- style={[
131
- animated && styles.iconWithAnimation,
132
- caretPosition === "start"
133
- ? styles.iconStart
134
- : styles.iconEnd,
135
- expanded && styles.iconExpanded,
136
- ]}
137
- testId={
138
- testId ? `${testId}-caret-icon` : undefined
139
- }
140
- />
141
- )}
142
- </>
143
- )}
144
- </Clickable>
145
- </HeadingSmall>
146
- );
147
- });
148
-
149
- // The AccordionSection border radius for rounded corners is 12px.
150
- // If we set the inner radius to the same value, there ends up being
151
- // a 1px gap between the border and the outline. To fix this, we
152
- // subtract 1 from the border radius.
153
- const INNER_BORDER_RADIUS = tokens.spacing.small_12 - 1;
154
- const ANIMATION_LENGTH = "300ms";
155
-
156
- const styles = StyleSheet.create({
157
- heading: {
158
- // As this is a grid item, it has a default minWidth of auto,
159
- // which means it would grow to fit its content. minWidth 0 is
160
- // necessary here to stop a custom header from overflowing out of
161
- // it container when its content is too long (See AccordionSection's
162
- // "React Element in Header" story).
163
- minWidth: 0,
164
- marginTop: 0,
165
- },
166
- headerWrapper: {
167
- display: "flex",
168
- flexDirection: "row",
169
- alignItems: "center",
170
- overflow: "hidden",
171
- minWidth: "auto",
172
- width: "100%",
173
- // Always make the header's outline show up in front of
174
- // the content panel.
175
- position: "relative",
176
- zIndex: 1,
177
-
178
- ":active": {
179
- outline: `2px solid ${tokens.color.activeBlue}`,
180
- },
181
-
182
- ":hover": {
183
- outline: `2px solid ${tokens.color.blue}`,
184
- },
185
-
186
- // Provide basic, default focus styles on older browsers (e.g.
187
- // Safari 14)
188
- ":focus": {
189
- boxShadow: `0 0 0 2px ${tokens.color.blue}`,
190
- },
191
-
192
- // Remove default focus styles for mouse users ONLY if
193
- // :focus-visible is supported on this platform.
194
- ":focus:not(:focus-visible)": {
195
- boxShadow: "none",
196
- },
197
-
198
- ":focus-visible": {
199
- outline: `2px solid ${tokens.color.blue}`,
200
- },
201
- },
202
- headerWrapperWithAnimation: {
203
- transition: `border-radius ${ANIMATION_LENGTH}`,
204
- },
205
- headerWrapperCaretStart: {
206
- flexDirection: "row-reverse",
207
- },
208
- // Even though the border radius is already set on the AccordionSection,
209
- // the hover/focus outline is on the header. We need to have the same
210
- // border radius here to round out the outline so it looks right over
211
- // the border.
212
- roundedTop: {
213
- borderStartStartRadius: INNER_BORDER_RADIUS,
214
- borderStartEndRadius: INNER_BORDER_RADIUS,
215
- },
216
- roundedBottom: {
217
- borderEndStartRadius: INNER_BORDER_RADIUS,
218
- borderEndEndRadius: INNER_BORDER_RADIUS,
219
- },
220
- headerContent: {
221
- flexGrow: 1,
222
- textAlign: "start",
223
- },
224
- headerString: {
225
- paddingTop: tokens.spacing.medium_16,
226
- paddingBottom: tokens.spacing.medium_16,
227
- },
228
- headerStringCaretEnd: {
229
- paddingInlineEnd: tokens.spacing.small_12,
230
- paddingInlineStart: tokens.spacing.medium_16,
231
- },
232
- headerStringCaretStart: {
233
- paddingInlineEnd: tokens.spacing.medium_16,
234
- paddingInlineStart: tokens.spacing.small_12,
235
- },
236
- iconWithAnimation: {
237
- transition: `transform ${ANIMATION_LENGTH}`,
238
- },
239
- iconExpanded: {
240
- // Turn the caret upside down
241
- transform: "rotate(180deg)",
242
- },
243
- iconStart: {
244
- marginInlineStart: tokens.spacing.medium_16,
245
- },
246
- iconEnd: {
247
- marginInlineEnd: tokens.spacing.medium_16,
248
- },
249
- disabled: {
250
- pointerEvents: "none",
251
- color: "inherit",
252
-
253
- // Provide basic, default focus styles on older browsers (e.g.
254
- // Safari 14)
255
- ":focus": {
256
- boxShadow: `0 0 0 2px ${tokens.color.offBlack32}`,
257
- },
258
-
259
- // Remove default focus styles for mouse users ONLY if
260
- // :focus-visible is supported on this platform.
261
- ":focus:not(:focus-visible)": {
262
- boxShadow: "none",
263
- },
264
-
265
- ":focus-visible": {
266
- outline: `2px solid ${tokens.color.offBlack32}`,
267
- },
268
- },
269
- });
270
-
271
- export default AccordionSectionHeader;
@@ -1,432 +0,0 @@
1
- import * as React from "react";
2
- import {StyleSheet} from "aphrodite";
3
- import type {StyleDeclaration} from "aphrodite";
4
-
5
- import {useUniqueIdWithMock, View} from "@khanacademy/wonder-blocks-core";
6
- import * as tokens from "@khanacademy/wonder-blocks-tokens";
7
- import {Body} from "@khanacademy/wonder-blocks-typography";
8
- import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core";
9
-
10
- import type {AccordionCornerKindType} from "./accordion";
11
- import AccordionSectionHeader from "./accordion-section-header";
12
-
13
- export type TagType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
14
-
15
- type Props = AriaProps & {
16
- /**
17
- * The unique identifier for the accordion section.
18
- */
19
- id?: string;
20
- /**
21
- * The content to display when this section is shown. If a string is
22
- * passed in, it will automatically be given Body typography from
23
- * Wonder Blocks Typography.
24
- */
25
- children: string | React.ReactElement;
26
- /**
27
- * The header for this section. If a string is passed in, it will
28
- * automatically be given Body typography from Wonder Blocks Typography.
29
- */
30
- header: string | React.ReactElement;
31
- /**
32
- * Whether to put the caret at the start or end of the header block
33
- * in this section. "start" means it’s on the left of a left-to-right
34
- * language (and on the right of a right-to-left language), and "end"
35
- * means it’s on the right of a left-to-right language
36
- * (and on the left of a right-to-left language).
37
- * Defaults to "end".
38
- *
39
- * If this prop is specified both here in the AccordionSection and
40
- * within a parent Accordion component, the AccordionSection’s caretPosition
41
- * value is prioritized.
42
- */
43
- caretPosition?: "start" | "end";
44
- /**
45
- * The preset styles for the corners of this accordion.
46
- * `square` - corners have no border radius.
47
- * `rounded` - the overall container's corners are rounded.
48
- * `rounded-per-section` - each section's corners are rounded, and there
49
- * is white space between each section.
50
- *
51
- * If this prop is specified both here in the AccordionSection and
52
- * within a parent Accordion component, the AccordionSection’s cornerKind
53
- * value is prioritized.
54
- */
55
- cornerKind?: AccordionCornerKindType;
56
- /**
57
- * Whether this section is collapsible. If false, the header will not be
58
- * clickable, and the section will stay expanded at all times.
59
- */
60
- collapsible?: boolean;
61
- /**
62
- * Whether this section is expanded or closed.
63
- *
64
- * NOTE: This prop is NOT used when this AccordionSection is rendered
65
- * within an Accordion component. In that case, the Accordion component
66
- * manages the expanded state of the AccordionSection.
67
- */
68
- expanded?: boolean;
69
- /**
70
- * Whether to include animation on the header. This should be false
71
- * if the user has `prefers-reduced-motion` opted in. Defaults to false.
72
- *
73
- * If this prop is specified both here in the AccordionSection and
74
- * within a parent Accordion component, the AccordionSection’s animated
75
- * value is prioritized.
76
- */
77
- animated?: boolean;
78
- /**
79
- * Called when the header is clicked.
80
- * Takes the new expanded state as an argument. This way, the function
81
- * returned from React.useState can be passed in directly.
82
- */
83
- onToggle?: (newExpandedState: boolean) => unknown;
84
- /**
85
- * Custom styles for the overall accordion section container.
86
- */
87
- style?: StyleType;
88
- /**
89
- * Custom styles for the header.
90
- */
91
- headerStyle?: StyleType;
92
- /**
93
- * The semantic tag for this clickable header (e.g. "h1", "h2", etc).
94
- * Please use this to ensure that the header is hierarchically correct.
95
- * Defaults to "h2".
96
- * */
97
- tag?: TagType;
98
- /**
99
- * The test ID used to locate this component in automated tests.
100
- */
101
- testId?: string;
102
- /**
103
- * Whether this section is the first section in the accordion.
104
- * For internal use only.
105
- * @ignore
106
- */
107
- isFirstSection?: boolean;
108
- /**
109
- * Whether this section is the last section in the accordion.
110
- * For internal use only.
111
- * @ignore
112
- */
113
- isLastSection?: boolean;
114
- /**
115
- * Whether this section should have role="region". True by default.
116
- * According to W3, the panel container should have role region except
117
- * when there are more than six panels in an accordion, in which case
118
- * we should set this prop to false.
119
- * For internal use only.
120
- * @ignore
121
- */
122
- isRegion?: boolean;
123
- };
124
-
125
- /**
126
- * An AccordionSection displays a section of content that can be shown or
127
- * hidden by clicking its header. This is generally used within the Accordion
128
- * component, but it can also be used on its own if you need only one
129
- * collapsible section.
130
- *
131
- * ### Usage
132
- *
133
- * ```jsx
134
- * import {
135
- * Accordion,
136
- * AccordionSection
137
- * } from "@khanacademy/wonder-blocks-accordion";
138
- *
139
- * // Within an Accordion
140
- * <Accordion>
141
- * <AccordionSection header="First section">
142
- * This is the information present in the first section
143
- * </AccordionSection>
144
- * <AccordionSection header="Second section">
145
- * This is the information present in the second section
146
- * </AccordionSection>
147
- * <AccordionSection header="Third section">
148
- * This is the information present in the third section
149
- * </AccordionSection>
150
- * </Accordion>
151
- *
152
- * // On its own, controlled
153
- * const [expanded, setExpanded] = React.useState(false);
154
- * <AccordionSection
155
- * header="A standalone section"
156
- * expanded={expanded}
157
- * onToggle={setExpanded}
158
- * >
159
- * This is the information present in the standalone section
160
- * </AccordionSection>
161
- *
162
- * // On its own, uncontrolled
163
- * <AccordionSection header="A standalone section">
164
- * This is the information present in the standalone section
165
- * </AccordionSection>
166
- * ```
167
- */
168
- const AccordionSection = React.forwardRef(function AccordionSection(
169
- props: Props,
170
- // Using a button ref here beacuse the ref is pointing to the
171
- // section header, which is a button.
172
- ref: React.ForwardedRef<HTMLButtonElement>,
173
- ) {
174
- const {
175
- children,
176
- id,
177
- header,
178
- collapsible,
179
- expanded,
180
- animated = false,
181
- onToggle,
182
- caretPosition = "end",
183
- cornerKind = "rounded",
184
- style,
185
- headerStyle,
186
- tag,
187
- testId,
188
- // Assume it's the first section and last section by default
189
- // in case this component is being used standalone. If it's part
190
- // of an accordion, these will be overridden by the Accordion
191
- // parent component.
192
- isFirstSection = true,
193
- isLastSection = true,
194
- // Assume it's a region by default. Override this to be false
195
- // if we know there are more than six panels in an accordion.
196
- isRegion = true,
197
- ...ariaProps
198
- } = props;
199
-
200
- const [internalExpanded, setInternalExpanded] = React.useState(
201
- expanded ?? false,
202
- );
203
-
204
- const controlledMode = expanded !== undefined && onToggle;
205
-
206
- const ids = useUniqueIdWithMock();
207
- const sectionId = id ?? ids.get("accordion-section");
208
- // We need an ID for the header so that the content section's
209
- // aria-labelledby attribute can point to it.
210
- const headerId = id ? `${id}-header` : ids.get("accordion-section-header");
211
- // We need an ID for the content section so that the opener's
212
- // aria-controls attribute can point to it.
213
- const sectionContentUniqueId = ids.get("accordion-section-content");
214
-
215
- const sectionStyles = _generateStyles(
216
- cornerKind,
217
- isFirstSection,
218
- isLastSection,
219
- );
220
-
221
- const handleClick = () => {
222
- // Controlled mode
223
- if (controlledMode) {
224
- onToggle(!expanded);
225
- } else {
226
- // Uncontrolled mode
227
- setInternalExpanded(!internalExpanded);
228
- if (onToggle) {
229
- onToggle(!internalExpanded);
230
- }
231
- }
232
- };
233
-
234
- let expandedState;
235
- if (collapsible === false) {
236
- // If the section is disabled (not collapsible), it should
237
- // always be expanded.
238
- expandedState = true;
239
- // If the expanded prop is undefined, we're in uncontrolled mode and
240
- // should use the internal state to determine the expanded state.
241
- // Otherwise, we're in controlled mode and should use the expanded prop
242
- // that's passed in to determine the expanded state.
243
- } else {
244
- expandedState = controlledMode ? expanded : internalExpanded;
245
- }
246
-
247
- return (
248
- <View
249
- id={sectionId}
250
- style={[
251
- styles.wrapper,
252
- animated && styles.wrapperWithAnimation,
253
- sectionStyles.wrapper,
254
- expandedState
255
- ? styles.wrapperExpanded
256
- : styles.wrapperCollapsed,
257
- style,
258
- ]}
259
- testId={testId}
260
- {...ariaProps}
261
- >
262
- <AccordionSectionHeader
263
- id={headerId}
264
- header={header}
265
- caretPosition={caretPosition}
266
- cornerKind={cornerKind}
267
- collapsible={collapsible}
268
- expanded={expandedState}
269
- animated={animated}
270
- onClick={handleClick}
271
- sectionContentUniqueId={sectionContentUniqueId}
272
- headerStyle={headerStyle}
273
- tag={tag}
274
- testId={testId}
275
- isFirstSection={isFirstSection}
276
- isLastSection={isLastSection}
277
- ref={ref}
278
- />
279
- <View
280
- id={sectionContentUniqueId}
281
- role={isRegion ? "region" : undefined}
282
- aria-labelledby={headerId}
283
- style={[
284
- styles.contentWrapper,
285
- expandedState
286
- ? styles.contentWrapperExpanded
287
- : styles.conentWrapperCollapsed,
288
- sectionStyles.contentWrapper,
289
- ]}
290
- testId={testId ? `${testId}-content-panel` : undefined}
291
- >
292
- {typeof children === "string" ? (
293
- <Body style={styles.stringContent}>{children}</Body>
294
- ) : (
295
- children
296
- )}
297
- </View>
298
- </View>
299
- );
300
- });
301
-
302
- const styles = StyleSheet.create({
303
- wrapper: {
304
- // Use grid layout for clean animations.
305
- display: "grid",
306
- // Remove the View's default relative position because it creates
307
- // overlap issues with the outline. In this case, it's safe to
308
- // remove the stacking context beacuse accordion sections are always
309
- // vertically stacked.
310
- position: "static",
311
- boxSizing: "border-box",
312
- backgroundColor: tokens.color.white,
313
- },
314
- wrapperWithAnimation: {
315
- transition: "grid-template-rows 300ms",
316
- },
317
- wrapperCollapsed: {
318
- gridTemplateRows: "min-content 0fr",
319
- },
320
- wrapperExpanded: {
321
- gridTemplateRows: "min-content 1fr",
322
- },
323
- contentWrapper: {
324
- overflow: "hidden",
325
- },
326
- conentWrapperCollapsed: {
327
- // Make sure screen readers don't read the content when it's
328
- // collapsed.
329
- visibility: "hidden",
330
- },
331
- contentWrapperExpanded: {
332
- visibility: "visible",
333
- },
334
- stringContent: {
335
- padding: tokens.spacing.medium_16,
336
- },
337
- });
338
-
339
- const cornerStyles: Record<string, any> = {};
340
-
341
- const _generateStyles = (
342
- cornerKind: AccordionCornerKindType,
343
- isFirstSection: boolean,
344
- isLastSection: boolean,
345
- ) => {
346
- const sectionType = `${cornerKind}-${isFirstSection.toString()}-${isLastSection.toString()}`;
347
- if (cornerStyles[sectionType]) {
348
- return cornerStyles[sectionType];
349
- }
350
-
351
- let wrapperStyle: StyleType = Object.freeze({});
352
- let contentWrapperStyle: StyleType = Object.freeze({});
353
- let firstSectionStyle: StyleType = Object.freeze({});
354
- let lastSectionStyle: StyleType = Object.freeze({});
355
-
356
- if (cornerKind === "square") {
357
- wrapperStyle = {
358
- border: `1px solid ${tokens.color.offBlack16}`,
359
- borderBottom: "none",
360
- borderRadius: 0,
361
- };
362
-
363
- if (isLastSection) {
364
- lastSectionStyle = {
365
- borderBottom: `1px solid ${tokens.color.offBlack16}`,
366
- };
367
- }
368
- }
369
-
370
- if (cornerKind === "rounded") {
371
- wrapperStyle = {
372
- border: `1px solid ${tokens.color.offBlack16}`,
373
- borderBottom: "none",
374
- };
375
-
376
- if (isFirstSection) {
377
- firstSectionStyle = {
378
- borderStartStartRadius: tokens.spacing.small_12,
379
- borderStartEndRadius: tokens.spacing.small_12,
380
- };
381
- }
382
-
383
- if (isLastSection) {
384
- lastSectionStyle = {
385
- borderBottom: `1px solid ${tokens.color.offBlack16}`,
386
- borderEndStartRadius: tokens.spacing.small_12,
387
- borderEndEndRadius: tokens.spacing.small_12,
388
- };
389
-
390
- contentWrapperStyle = {
391
- // Give the last section's content wrapper the same bottom
392
- // border radius as the wrapper so that the content doesn't
393
- // overflow out the corners. This issue can't be solved by
394
- // putting `overflow: "hidden"` on the overall container
395
- // because that cuts off the header's focus outline.
396
- borderEndEndRadius: tokens.spacing.small_12,
397
- borderEndStartRadius: tokens.spacing.small_12,
398
- };
399
- }
400
- }
401
-
402
- if (cornerKind === "rounded-per-section") {
403
- wrapperStyle = {
404
- border: `1px solid ${tokens.color.offBlack16}`,
405
- borderRadius: tokens.spacing.small_12,
406
- marginBottom: tokens.spacing.medium_16,
407
- };
408
-
409
- contentWrapperStyle = {
410
- // Give the content wrapper the same border radius as the wrapper
411
- // so that the content doesn't overflow out the corners. We
412
- // can't put `overflow: "hidden"` on the overall container
413
- // because it cuts off the header's focus outline.
414
- borderEndEndRadius: tokens.spacing.small_12,
415
- borderEndStartRadius: tokens.spacing.small_12,
416
- };
417
- }
418
-
419
- const newStyles: StyleDeclaration = {
420
- wrapper: {
421
- ...wrapperStyle,
422
- ...firstSectionStyle,
423
- ...lastSectionStyle,
424
- },
425
- contentWrapper: contentWrapperStyle,
426
- };
427
-
428
- cornerStyles[sectionType] = StyleSheet.create(newStyles);
429
- return cornerStyles[sectionType];
430
- };
431
-
432
- export default AccordionSection;