feat: details/summary theme / MDX component (#5216)

* Details component

* polish arrow animation

* fix text selection bug

* fix some edge cases + polish

* example of overriding baseClassName

* Move Details component to theme-common

* make component work even when JS is disabled or failed to load

* update arrow transform

* Details component: better handling of no-JS fallback mode: avoid delaying arrow navigation when JS (see review)

* prefix css vars with --docusaurus

* improve css arrow styling

* slightly change details/summary design

* better md doc + include quotes and details in doc
This commit is contained in:
Sébastien Lorber 2021-07-27 18:45:12 +02:00 committed by GitHub
parent 798f634007
commit dc4664b489
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 378 additions and 21 deletions

View file

@ -0,0 +1,254 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import React, {
useState,
useEffect,
useRef,
useCallback,
RefObject,
Dispatch,
SetStateAction,
ReactNode,
useLayoutEffect,
} from 'react';
const DefaultAnimationEasing = 'ease-in-out';
export type UseCollapsibleConfig = {
initialState: boolean | (() => boolean);
};
export type UseCollapsibleReturns = {
collapsed: boolean;
setCollapsed: Dispatch<SetStateAction<boolean>>;
toggleCollapsed: () => void;
};
// This hook just define the state
export function useCollapsible({
initialState,
}: UseCollapsibleConfig): UseCollapsibleReturns {
const [collapsed, setCollapsed] = useState(initialState ?? false);
const toggleCollapsed = useCallback(() => {
setCollapsed((expanded) => !expanded);
}, []);
return {
collapsed,
setCollapsed,
toggleCollapsed,
};
}
const CollapsedStyles = {
display: 'none',
overflow: 'hidden',
height: '0px',
} as const;
const ExpandedStyles = {
display: 'block',
overflow: 'visible',
height: 'auto',
} as const;
function applyCollapsedStyle(el: HTMLElement, collapsed: boolean) {
const collapsedStyles = collapsed ? CollapsedStyles : ExpandedStyles;
el.style.display = collapsedStyles.display;
el.style.overflow = collapsedStyles.overflow;
el.style.height = collapsedStyles.height;
}
/*
Lex111: Dynamic transition duration is used in Material design, this technique is good for a large number of items.
https://material.io/archive/guidelines/motion/duration-easing.html#duration-easing-dynamic-durations
https://github.com/mui-org/material-ui/blob/e724d98eba018e55e1a684236a2037e24bcf050c/packages/material-ui/src/styles/createTransitions.js#L40-L43
*/
function getAutoHeightDuration(height: number) {
const constant = height / 36;
return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10);
}
type CollapsibleAnimationConfig = {
duration?: number;
easing?: string;
};
function useCollapseAnimation({
collapsibleRef,
collapsed,
animation,
}: {
collapsibleRef: RefObject<HTMLElement>;
collapsed: boolean;
animation?: CollapsibleAnimationConfig;
}) {
const mounted = useRef(false);
useEffect(() => {
const el = collapsibleRef.current!;
function getTransitionStyles() {
const height = el.scrollHeight;
const duration = animation?.duration ?? getAutoHeightDuration(height);
const easing = animation?.easing ?? DefaultAnimationEasing;
return {
transition: `height ${duration}ms ${easing}`,
height: `${height}px`,
};
}
function applyTransitionStyles() {
const transitionStyles = getTransitionStyles();
el.style.transition = transitionStyles.transition;
el.style.height = transitionStyles.height;
}
// On mount, we just apply styles, no animated transition
if (!mounted.current) {
applyCollapsedStyle(el, collapsed);
mounted.current = true;
return undefined;
}
el.style.willChange = 'height';
function startAnimation(): () => void {
const animationFrame = requestAnimationFrame(() => {
// When collapsing
if (collapsed) {
applyTransitionStyles();
requestAnimationFrame(() => {
el.style.height = CollapsedStyles.height;
el.style.overflow = CollapsedStyles.overflow;
});
}
// When expanding
else {
el.style.display = 'block';
requestAnimationFrame(() => {
applyTransitionStyles();
});
}
});
return () => cancelAnimationFrame(animationFrame);
}
return startAnimation();
}, [collapsibleRef, collapsed, animation]);
}
type CollapsibleElementType = React.ElementType<
Pick<React.HTMLAttributes<unknown>, 'className' | 'onTransitionEnd' | 'style'>
>;
// Prevent hydration layout shift before anims are handled imperatively with JS
function getSSRStyle(collapsed: boolean) {
if (ExecutionEnvironment.canUseDOM) {
return undefined;
}
return collapsed ? CollapsedStyles : ExpandedStyles;
}
type CollapsibleBaseProps = {
as?: CollapsibleElementType;
collapsed: boolean;
children: ReactNode;
animation?: CollapsibleAnimationConfig;
onCollapseTransitionEnd?: (collapsed: boolean) => void;
className?: string;
// This is mostly useful for details/summary component where ssrStyle is not needed (as details are hidden natively)
// and can mess-up with the default native behavior of the browser when JS fails to load or is disabled
disableSSRStyle?: boolean;
};
function CollapsibleBase({
as: As = 'div',
collapsed,
children,
animation,
onCollapseTransitionEnd,
className,
disableSSRStyle,
}: CollapsibleBaseProps) {
// any because TS is a pain for HTML element refs, see https://twitter.com/sebastienlorber/status/1412784677795110914
const collapsibleRef = useRef<any>(null);
useCollapseAnimation({collapsibleRef, collapsed, animation});
return (
<As
// @ts-expect-error: see https://twitter.com/sebastienlorber/status/1412784677795110914
ref={collapsibleRef}
style={disableSSRStyle ? undefined : getSSRStyle(collapsed)}
onTransitionEnd={(e) => {
if (e.propertyName !== 'height') {
return;
}
const el = collapsibleRef.current!;
const currentCollapsibleElementHeight = el.style.height;
if (
!collapsed &&
parseInt(currentCollapsibleElementHeight, 10) === el.scrollHeight
) {
applyCollapsedStyle(el, false);
onCollapseTransitionEnd?.(false);
}
if (currentCollapsibleElementHeight === CollapsedStyles.height) {
applyCollapsedStyle(el, true);
onCollapseTransitionEnd?.(true);
}
}}
className={className}>
{children}
</As>
);
}
function CollapsibleLazy({collapsed, ...props}: CollapsibleBaseProps) {
const [mounted, setMounted] = useState(!collapsed);
useLayoutEffect(() => {
if (!collapsed) {
setMounted(true);
}
}, [collapsed]);
// lazyCollapsed updated in effect so that the first expansion transition can work
const [lazyCollapsed, setLazyCollapsed] = useState(collapsed);
useLayoutEffect(() => {
if (mounted) {
setLazyCollapsed(collapsed);
}
}, [mounted, collapsed]);
return mounted ? (
<CollapsibleBase {...props} collapsed={lazyCollapsed} />
) : null;
}
type CollapsibleProps = CollapsibleBaseProps & {
// Lazy allows to delay the rendering when collapsed => it will render children only after hydration, on first expansion
// Required prop: it forces to think if content should be server-rendered or not!
// This has perf impact on the SSR output and html file sizes
// See https://github.com/facebook/docusaurus/issues/4753
lazy: boolean;
};
export function Collapsible({lazy, ...props}: CollapsibleProps): JSX.Element {
const Comp = lazy ? CollapsibleLazy : CollapsibleBase;
return <Comp {...props} />;
}

View file

@ -0,0 +1,94 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {ComponentProps, ReactElement, useRef, useState} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import clsx from 'clsx';
import {useCollapsible, Collapsible} from '../Collapsible';
import styles from './styles.module.css';
function isInSummary(node: HTMLElement | null): boolean {
if (!node) {
return false;
}
return node.tagName === 'SUMMARY' || isInSummary(node.parentElement);
}
function hasParent(node: HTMLElement | null, parent: HTMLElement): boolean {
if (!node) {
return false;
}
return node === parent || hasParent(node.parentElement, parent);
}
export type DetailsProps = {
summary?: ReactElement;
} & ComponentProps<'details'>;
const Details = ({summary, children, ...props}: DetailsProps): JSX.Element => {
const {isClient} = useDocusaurusContext();
const detailsRef = useRef<HTMLDetailsElement>(null);
const {collapsed, setCollapsed} = useCollapsible({
initialState: !props.open,
});
// We use a separate prop because it must be set only after animation completes
// Otherwise close anim won't work
const [open, setOpen] = useState(props.open);
return (
<details
{...props}
ref={detailsRef}
open={open}
data-collapsed={collapsed}
className={clsx(
styles.details,
{[styles.isClient]: isClient},
props.className,
)}
onMouseDown={(e) => {
const target = e.target as HTMLElement;
// Prevent a double-click to highlight summary text
if (isInSummary(target) && e.detail > 1) {
e.preventDefault();
}
}}
onClick={(e) => {
e.stopPropagation(); // For isolation of multiple nested details/summary
const target = e.target as HTMLElement;
const shouldToggle =
isInSummary(target) && hasParent(target, detailsRef.current!);
if (!shouldToggle) {
return;
}
e.preventDefault();
if (collapsed) {
setCollapsed(false);
setOpen(true);
} else {
setCollapsed(true);
// setOpen(false); // Don't do this, it breaks close animation!
}
}}>
{summary}
<Collapsible
lazy={false} // Content might matter for SEO in this case
collapsed={collapsed}
disableSSRStyle // Allows component to work fine even with JS disabled!
onCollapseTransitionEnd={(newCollapsed) => {
setCollapsed(newCollapsed);
setOpen(!newCollapsed);
}}>
<div className={styles.collapsibleContent}>{children}</div>
</Collapsible>
</details>
);
};
export default Details;

View file

@ -0,0 +1,59 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/*
CSS variables, meant to be overriden by final theme
*/
.details {
--docusaurus-details-summary-arrow-size: 0.38rem;
--docusaurus-details-transition: transform 200ms ease;
--docusaurus-details-decoration-color: grey;
}
.details > summary {
position: relative;
cursor: pointer;
list-style: none;
margin-left: 1.8rem;
}
.details > summary::-webkit-details-marker {
display: none;
}
.details > summary:before {
position: absolute;
top: 0.45rem;
left: -1.2rem;
/* CSS-only Arrow */
content: '';
width: 0;
height: 0;
border-top: var(--docusaurus-details-summary-arrow-size) solid transparent;
border-bottom: var(--docusaurus-details-summary-arrow-size) solid transparent;
border-left: var(--docusaurus-details-summary-arrow-size) solid
var(--docusaurus-details-decoration-color);
/* Arrow rotation anim */
transform: rotate(0deg);
transition: var(--docusaurus-details-transition);
transform-origin: calc(var(--docusaurus-details-summary-arrow-size) / 2) 50%;
}
/* When JS disabled/failed to load: we use the open property for arrow animation: */
.details[open]:not(.isClient) > summary:before,
/* When JS works: we use the data-attribute for arrow animation */
.details[data-collapsed='false'].isClient > summary:before {
transform: rotate(90deg);
}
.collapsibleContent {
margin-top: 1rem;
border-top: 1px solid var(--docusaurus-details-decoration-color);
padding-top: 1rem;
}