mirror of
https://github.com/facebook/docusaurus.git
synced 2025-07-19 01:28:38 +02:00
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:
parent
798f634007
commit
dc4664b489
13 changed files with 378 additions and 21 deletions
|
@ -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} />;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue