Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions lib/Accordion/Accordion.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@
}
}

/* Applied once a closed accordion's collapse transition has fully finished (see
* Accordion.js), letting the browser skip layout/paint of the collapsed subtree until
* it's reopened. Guarded by @supports so browsers without it keep today's behavior,
* which already fully hides this content via max-height/overflow above.
*/
@supports (content-visibility: hidden) {
.content-wrap.cvHidden {
content-visibility: hidden;
}
}

/**
* Content - hidden by default
*/
Expand All @@ -106,6 +117,26 @@
transform: translateY(0);
transition: transform 0.15s ease-in-out 0.2s, opacity 0.15s ease-in-out;
margin-bottom: 1rem;

&:empty {
height: 5px;
content: "";
background: linear-gradient(to left, var(--color-border-p2), var(--bg), var(--color-border-p2));
background-size: 200%;
margin-bottom: 0;
animation: sheen 2s ease infinite;
border-radius: 4px;
}
}
}

@keyframes sheen {
0% {
background-position: -100% 50%;
}

100% {
background-position: 100% 50%;
}
}

Expand Down
83 changes: 79 additions & 4 deletions lib/Accordion/Accordion.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useLayoutEffect, useTransition } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
Expand All @@ -23,6 +23,7 @@
contentHeight: PropTypes.string,
contentId: PropTypes.string,
contentRef: PropTypes.func,
disableContentVisibility: PropTypes.bool,
disabled: PropTypes.bool,
displayWhenClosed: PropTypes.element, // eslint-disable-line react/no-unused-prop-types
displayWhenOpen: PropTypes.element, // eslint-disable-line react/no-unused-prop-types
Expand Down Expand Up @@ -57,13 +58,34 @@
);
}

function getWrapClass(open) {
function getWrapClass(open, cvHidden) {
return classNames(
css['content-wrap'],
{ [`${css.expanded}`]: open },
{ [`${css.cvHidden}`]: cvHidden },
);
}

const supportsContentVisibility = typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('content-visibility', 'hidden');

// Longest delay+duration among an element's own transitions, in ms - used to know when
// the content-wrap/content-region close animation has fully finished before applying
// content-visibility:hidden (applying it mid-transition would freeze the animation).
function getMaxTransitionMs(el) {
const style = getComputedStyle(el);
const durations = style.transitionDuration.split(',');
const delays = style.transitionDelay.split(',');
let max = 0;
for (let i = 0; i < durations.length; i++) {
const duration = (parseFloat(durations[i]) || 0) * 1000;

Check warning on line 82 in lib/Accordion/Accordion.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&issues=AaABVYmk2QyeWRrnzxkQ&open=AaABVYmk2QyeWRrnzxkQ&pullRequest=2582
const delay = (parseFloat(delays[i] ?? delays[delays.length - 1]) || 0) * 1000;

Check warning on line 83 in lib/Accordion/Accordion.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&issues=AaABVYmk2QyeWRrnzxkR&open=AaABVYmk2QyeWRrnzxkR&pullRequest=2582

Check warning on line 83 in lib/Accordion/Accordion.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.at(…)` over `[….length - index]`.

See more on https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&issues=AaABVYmk2QyeWRrnzxkS&open=AaABVYmk2QyeWRrnzxkS&pullRequest=2582
if (duration + delay > max) max = duration + delay;
}
return max;
}

/* The z-index requirements for accordions:
* Accordions should not overlap any overlays/dropdowns from previous/next accordions.
* Accordions/overlays should not be overlapped if focus left the accordion or went to another pane...
Expand Down Expand Up @@ -97,6 +119,7 @@
contentId: contentIdProp,
contentRef,
disabled,
disableContentVisibility = false,
header = DefaultAccordionHeader,
headerProps = { headingLevel: 3 },
id,
Expand All @@ -111,6 +134,7 @@

const toggle = useRef(null);
const content = useRef(null);
const wrap = useRef(null);
const setContentRef = useRef((ref) => {
content.current = ref;
if (typeof contentRef === 'function') {
Expand All @@ -127,6 +151,14 @@
const [registered, updateRegistered] = useState(!accordionSet);
const [zIndex, updateZIndex] = useState(1);
const [focused, updateFocused] = useState(false);
const cvEnabled = !disableContentVisibility && supportsContentVisibility;
const [cvHidden, updateCvHidden] = useState(() => cvEnabled && !(open || !closedByDefault));

Check warning on line 155 in lib/Accordion/Accordion.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

useState call is not destructured into value + setter pair

See more on https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&issues=AaABVYmk2QyeWRrnzxkT&open=AaABVYmk2QyeWRrnzxkT&pullRequest=2582
// Gates only renderChildren(), separately from cvHidden. Starts in sync with cvHidden's
// initial value (no deferral on first mount), but its opening transition is scheduled via
// startTransition so mounting expensive children can't block the paint that starts the
// open animation.
const [contentMounted, updateContentMounted] = useState(() => !(cvEnabled && !(open || !closedByDefault)));

Check warning on line 160 in lib/Accordion/Accordion.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

useState call is not destructured into value + setter pair

See more on https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&issues=AaABVYmk2QyeWRrnzxkU&open=AaABVYmk2QyeWRrnzxkU&pullRequest=2582
const [, startTransition] = useTransition();

const uncontrolledToggle = useRef(() => {
updateOpen(current => !current);
Expand Down Expand Up @@ -172,6 +204,44 @@
}
}, [open]);

// Clearing content-visibility must happen synchronously, before the browser paints the
// opening frame - otherwise the max-height/opacity transition would animate against a
// subtree the browser has skipped, growing from a stale/zero intrinsic size.
useLayoutEffect(() => {
if (cvEnabled && isOpen) {
updateCvHidden(false);
}
}, [cvEnabled, isOpen]);

// Mounting children is the expensive part of opening a cvHidden accordion. Do it as a
// low-priority, interruptible transition so it can't block the browser from painting the
// class toggle above, which is what actually starts the CSS open animation.
useEffect(() => {
if (cvEnabled && isOpen && !contentMounted) {
startTransition(() => {
updateContentMounted(true);
});
}
}, [cvEnabled, isOpen, contentMounted, startTransition]);

// Only apply content-visibility once the close transition has fully finished, so the
// collapse animation isn't interrupted by the subtree being skipped mid-flight. A rapid
// re-open before the timer fires cancels it via the cleanup below.
useEffect(() => {
if (!cvEnabled || isOpen) return undefined;
const wrapNode = wrap.current;
const regionNode = content.current;
if (!wrapNode || !regionNode) return undefined;

const delay = Math.max(getMaxTransitionMs(wrapNode), getMaxTransitionMs(regionNode));
const timer = setTimeout(() => {
updateCvHidden(true);
updateContentMounted(false); // re-arm the deferred-mount gate for the next open
}, delay);

return () => clearTimeout(timer);
}, [cvEnabled, isOpen]);

// At registration, accordions are assigned a z-index that, in most cases,
// will render in reverse order.
useEffect(() => { // eslint-disable-line
Expand Down Expand Up @@ -203,6 +273,11 @@
const headerElement = React.createElement(header, accordionHeaderProps);

if (!registered) return null;

const renderChildren = () => (
typeof children === 'function' ? children(isOpen) : children
);

return (
<section
id={trackingId}
Expand All @@ -222,7 +297,7 @@
{headerElement}
</div>
</HotKeys>
<div className={getWrapClass(isOpen)} style={{ zIndex }}>
<div ref={wrap} className={getWrapClass(isOpen, cvHidden)} style={{ zIndex }}>
<div
role="region"
className={getContentClass(isOpen)}
Expand All @@ -232,7 +307,7 @@
style={contentHeight ? { height: contentHeight } : null}
data-test-accordion-wrapper
>
{typeof children === 'function' ? children(isOpen) : children}
{!cvHidden && contentMounted && renderChildren()}
</div>
</div>
</section>
Expand Down
144 changes: 85 additions & 59 deletions lib/Accordion/AccordionStatus.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,87 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { useState, useCallback, useMemo, useTransition } from 'react';

Check warning on line 1 in lib/Accordion/AccordionStatus.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'useTransition'.

See more on https://sonarcloud.io/project/issues?id=org.folio%3Astripes-components&issues=AaABVYo82QyeWRrnzxkV&open=AaABVYo82QyeWRrnzxkV&pullRequest=2582
import { AccordionStatusContext } from './AccordionStatusContext';
const AccordionStatus = ({
accordionStatus,
children,
initialStatus
}) => {
const [status, setStatus] = useState(accordionStatus || initialStatus || {});

export default class AccordionStatus extends React.Component {
static propTypes = {
accordionStatus: PropTypes.object,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
initialStatus: PropTypes.object
};

constructor(props) {
super(props);

this.state = {
status: props.accordionStatus || props.initialStatus || {}
};
}

static getDerivedStateFromProps(props) {
return props.accordionStatus ? { status: props.accordionStatus } : null;
}

setStatus = fn => {
if (typeof fn === 'function') {
this.setState(cur => fn(cur));
} else {
this.setState({ status: fn });
}
};

onToggle = ({ id }) => {
this.setStatus(current => {
const newState = {
status: {
...current.status,
[id]: !current.status[id]
}
};
return newState;
});
};

render() {
const { children } = this.props;
const provided = {
status: this.state.status,
setStatus: this.setStatus,
onToggle: this.onToggle
};
return (
<AccordionStatusContext.Provider
value={provided}
>
{typeof children === 'function'
? children(provided)
: children}
</AccordionStatusContext.Provider>
);
}
}
const onToggle = useCallback(({ id }) => {
setStatus(current => ({
...current,
[id]: !current[id]
}));
}, []);

const provided = useMemo(() => ({
status,
setStatus,
onToggle
}), [status, setStatus, onToggle]);

return (
<AccordionStatusContext.Provider value={provided}>
{typeof children === 'function' ? children(provided) : children}
</AccordionStatusContext.Provider>
);
};

export default AccordionStatus;
// export default class AccordionStatus extends React.Component {
// static propTypes = {
// accordionStatus: PropTypes.object,
// children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
// initialStatus: PropTypes.object
// };

// constructor(props) {
// super(props);

// this.state = {
// status: props.accordionStatus || props.initialStatus || {}
// };
// }

// static getDerivedStateFromProps(props) {
// return props.accordionStatus ? { status: props.accordionStatus } : null;
// }

// setStatus = fn => {
// if (typeof fn === 'function') {
// this.setState(cur => fn(cur));
// } else {
// this.setState({ status: fn });
// }
// };

// onToggle = ({ id }) => {
// this.setStatus(current => {
// const newState = {
// status: {
// ...current.status,
// [id]: !current.status[id]
// }
// };
// return newState;
// });
// };

// render() {
// const { children } = this.props;
// const provided = {
// status: this.state.status,
// setStatus: this.setStatus,
// onToggle: this.onToggle
// };
// return (
// <AccordionStatusContext.Provider
// value={provided}
// >
// {typeof children === 'function'
// ? children(provided)
// : children}
// </AccordionStatusContext.Provider>
// );
// }
// }
Loading