mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
refactor(opal): one Fold animation for every folding section (#15699)
This commit is contained in:
1 parent
cb0509d748
commit
b52ed50ce9
18 files changed
+258
-214
No files matched your search
@@ -150,7 +150,7 @@ Everything from plain mode, **plus**:
|
||||
- **Rounding adapts automatically.** When `expanded && expandedContent !== undefined`, the header's bottom corners flatten and the content's top corners flatten so they meet seamlessly. When collapsed (or when `expandedContent` is undefined), the header is fully rounded.
|
||||
- **Content background is always transparent.** The `color` prop applies to the header only; the content slot never fills its own background so the page shows through and keeps the two regions visually distinct.
|
||||
- **Content has no intrinsic padding.** The `padding` prop applies to the header only. Callers own any padding inside whatever they pass to `expandedContent` — wrap it in a `<div className="p-4">` (or whatever) if you want spacing.
|
||||
- **Animation.** Content uses a pure CSS grid `0fr ↔ 1fr` animation with an opacity fade (~200ms ease-out). No `@radix-ui/react-collapsible` dependency. The fold lives in `cards/fold/` as `CardFold` and is shared with [`SelectCard`](../select-card/README.md), which has the same `expandable` API on an interactive header.
|
||||
- **Animation.** Content uses a pure CSS grid `0fr ↔ 1fr` animation and a fade. Opening grows the body and squares the header's bottom corners together (150ms), then fades the content in (100ms). Closing fades the content out (100ms), then shrinks the body and rounds the corners together (150ms). No `@radix-ui/react-collapsible` dependency. The fold lives in `cards/fold/` as `CardFold` and is shared with [`SelectCard`](../select-card/README.md), which has the same `expandable` API on an interactive header.
|
||||
|
||||
### Accessibility
|
||||
|
||||
|
||||
@@ -243,13 +243,6 @@ function Card(props: CardProps) {
|
||||
|
||||
const paddingStyle = { padding: spacingToRem(paddingProp) };
|
||||
const radius = roundingToRem(roundingProp);
|
||||
// Expanded, the header rounds only at the top and the body only at the
|
||||
// bottom, so the two halves read as one card rather than two.
|
||||
const topRadius = {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
};
|
||||
|
||||
// Plain mode — unchanged behavior
|
||||
if (!props.expandable) {
|
||||
return (
|
||||
@@ -276,7 +269,13 @@ function Card(props: CardProps) {
|
||||
expandableContentHeight = 80,
|
||||
} = props;
|
||||
const showContent = expanded && expandedContent !== undefined;
|
||||
const headerRadius = showContent ? topRadius : { borderRadius: radius };
|
||||
// The stylesheet rounds the header, and squares its bottom corners while
|
||||
// the fold has any height, so the two halves stay joined as it closes.
|
||||
const headerStyle: React.CSSProperties &
|
||||
Record<"--opal-card-radius", string> = {
|
||||
...paddingStyle,
|
||||
"--opal-card-radius": radius,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -288,7 +287,7 @@ function Card(props: CardProps) {
|
||||
>
|
||||
<div
|
||||
className="opal-card-expandable-header"
|
||||
style={{ ...paddingStyle, ...headerRadius }}
|
||||
style={headerStyle}
|
||||
data-color={color}
|
||||
data-border={border}
|
||||
data-opal-status-border={borderColor}
|
||||
|
||||
@@ -87,7 +87,19 @@
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.opal-card-expandable-header {
|
||||
@apply w-full overflow-clip transition-[border-radius] duration-200 ease-out;
|
||||
@apply w-full overflow-clip;
|
||||
border-radius: var(--opal-card-radius);
|
||||
transition: border-radius 150ms ease-out 100ms;
|
||||
}
|
||||
|
||||
/* While the fold is open, the header's bottom corners are square so it
|
||||
joins the fold. The corners move with the fold's height (see
|
||||
`components/fold/styles.css`): they square as it expands, and round as it
|
||||
collapses, after the content fades out. */
|
||||
.opal-card-expandable-header:has(+ .opal-fold[data-open="true"]) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
transition: border-radius 150ms ease-out;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-color="transparent"] {
|
||||
@@ -138,7 +150,7 @@
|
||||
header and body. It should always remain the default border color,
|
||||
regardless of any status borderColor applied to the card. */
|
||||
|
||||
.opal-card-expandable-header:has(+ .opal-fold[data-open="true"]) {
|
||||
.opal-card-expandable-header:has(+ .opal-fold[data-mounted="true"]) {
|
||||
@apply border-b-border-01;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[`Card`](../card/README.md) and [`SelectCard`](../select-card/README.md).
|
||||
|
||||
The animating body of an expandable card, built on [`Fold`](../../fold/README.md).
|
||||
A grid row moves between `0fr` and `1fr` with an opacity fade over 200ms, so the fold opens and closes on a pure
|
||||
A grid row moves between `0fr` and `1fr` and the content fades after it (see `Fold` for the timing), so the fold opens and closes on a pure
|
||||
CSS clock: no measured height, no state machine, no Radix, and the children
|
||||
are dropped once it closes.
|
||||
|
||||
|
||||
@@ -71,20 +71,31 @@ function CardFold({
|
||||
contentHeight = 80,
|
||||
children,
|
||||
}: CardFoldProps) {
|
||||
const frameStyle: React.CSSProperties & Record<"--opal-card-radius", string> =
|
||||
{ "--opal-card-radius": radius };
|
||||
|
||||
return (
|
||||
<Fold open={expanded}>
|
||||
<div
|
||||
className="opal-card-fold-body"
|
||||
style={{
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
}}
|
||||
data-border={border}
|
||||
data-opal-status-border={borderColor}
|
||||
data-content-height={contentHeight}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
// The bordered body is the fold's frame: it grows with the height and
|
||||
// stays visible, and only the content inside it fades.
|
||||
<Fold
|
||||
open={expanded}
|
||||
frame={(content) => (
|
||||
<div
|
||||
className="opal-card-fold-body"
|
||||
style={frameStyle}
|
||||
data-border={border}
|
||||
data-opal-status-border={borderColor}
|
||||
>
|
||||
<div
|
||||
className="opal-card-fold-scroll"
|
||||
data-content-height={contentHeight}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Fold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
Structure:
|
||||
.opal-fold (the animation; see components/fold/)
|
||||
.opal-fold-inner
|
||||
.opal-card-fold-body (border minus its top edge, bottom rounding)
|
||||
.opal-card-fold-body (the frame: the edge, bottom rounding)
|
||||
.opal-card-fold-scroll (the height cap)
|
||||
.opal-fold-grid
|
||||
|
||||
Shared by Card and SelectCard.
|
||||
============================================================================ */
|
||||
@@ -17,21 +18,52 @@
|
||||
@apply w-full bg-transparent;
|
||||
}
|
||||
|
||||
.opal-card-fold-body[data-content-height="80"] {
|
||||
/* Scrolls inside the body, so the scroll box does not clip the edge. */
|
||||
.opal-card-fold-scroll {
|
||||
border-bottom-left-radius: var(--opal-card-radius);
|
||||
border-bottom-right-radius: var(--opal-card-radius);
|
||||
}
|
||||
|
||||
.opal-card-fold-scroll[data-content-height="80"] {
|
||||
@apply max-h-80 overflow-y-auto;
|
||||
}
|
||||
|
||||
/* "full" = no constraint; natural height, no scroll. */
|
||||
|
||||
/* The top edge is the header's bottom border, so the body never draws one. */
|
||||
.opal-card-fold-body[data-border="none"] {
|
||||
border: none;
|
||||
/* The edge is drawn by `::before`, not the body's own border. It reaches up
|
||||
by one corner radius over the header, so its bottom corners keep their
|
||||
round shape at any height. A short box would flatten them: at zero height
|
||||
it lines up with the header's own corners instead of drawing a flat line
|
||||
under them. The body pads in by the border width, so the content keeps its
|
||||
place, and moves up by it, so a closed fold adds no height. `relative`
|
||||
paints the edge above the header. */
|
||||
.opal-card-fold-body {
|
||||
@apply relative;
|
||||
border-bottom-left-radius: var(--opal-card-radius);
|
||||
border-bottom-right-radius: var(--opal-card-radius);
|
||||
}
|
||||
|
||||
.opal-card-fold-body[data-border="dashed"] {
|
||||
@apply border border-t-0 border-dashed;
|
||||
.opal-card-fold-body:not([data-border="none"]) {
|
||||
margin-top: -1px;
|
||||
padding: 0 1px 1px;
|
||||
}
|
||||
|
||||
.opal-card-fold-body[data-border="solid"] {
|
||||
@apply border border-t-0;
|
||||
.opal-card-fold-body:not([data-border="none"])::before {
|
||||
@apply pointer-events-none absolute inset-x-0 bottom-0 border border-t-0;
|
||||
content: "";
|
||||
top: calc(1px - var(--opal-card-radius));
|
||||
border-color: inherit;
|
||||
border-bottom-left-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
}
|
||||
|
||||
.opal-card-fold-body[data-border="dashed"]::before {
|
||||
@apply border-dashed;
|
||||
}
|
||||
|
||||
/* The top edge is the header's bottom border, so the edge never draws one.
|
||||
A fully closed fold shows no edge. `!important` wins over the status and
|
||||
selected border colours, which the edge inherits. */
|
||||
.opal-fold[data-mounted="false"] > .opal-card-fold-body {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { spacingToRem } from "@opal/shared";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Card } from "@opal/components/cards/card/components";
|
||||
import { Fold } from "@opal/components/fold/components";
|
||||
import { Button, Divider } from "@opal/components";
|
||||
import {
|
||||
SvgAlertCircle,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
} from "@opal/icons";
|
||||
import { useState } from "react";
|
||||
import { useOpalStrings } from "@opal/strings";
|
||||
import usePresence from "@opal/hooks/usePresence";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -207,15 +207,6 @@ function MessageCard({
|
||||
const strings = useOpalStrings();
|
||||
// Falsey content (`condition && <X />`) counts as absent, as it always has.
|
||||
const expanded = Boolean(bottomChildren);
|
||||
const presence = usePresence(expanded, 200);
|
||||
// Animate only once the section has come or gone, so a card that mounts
|
||||
// with it does not play the opening on page load.
|
||||
const [toggled, setToggled] = useState(false);
|
||||
const [prevExpanded, setPrevExpanded] = useState(expanded);
|
||||
if (expanded !== prevExpanded) {
|
||||
setPrevExpanded(expanded);
|
||||
setToggled(true);
|
||||
}
|
||||
// The last section shown, kept so it can animate out after the caller
|
||||
// drops it.
|
||||
const [shownBottom, setShownBottom] = useState(bottomChildren);
|
||||
@@ -266,21 +257,14 @@ function MessageCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{presence.mounted && (
|
||||
<div
|
||||
className="opal-message-card-bottom"
|
||||
data-state={presence.state}
|
||||
data-animate={toggled || undefined}
|
||||
onAnimationEnd={presence.onAnimationEnd}
|
||||
>
|
||||
<div className="opal-message-card-bottom-inner">
|
||||
<div className="opal-message-card-bottom-content">
|
||||
<Divider paddingParallel={3} paddingPerpendicular={0} />
|
||||
{expanded ? bottomChildren : shownBottom}
|
||||
</div>
|
||||
</div>
|
||||
{/* Fold animates only a change, so a card that mounts with the
|
||||
section does not play the opening on page load. */}
|
||||
<Fold open={expanded}>
|
||||
<div className="opal-message-card-bottom-content">
|
||||
<Divider paddingParallel={3} paddingPerpendicular={0} />
|
||||
{expanded ? bottomChildren : shownBottom}
|
||||
</div>
|
||||
)}
|
||||
</Fold>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -12,56 +12,8 @@
|
||||
@apply flex flex-col w-full;
|
||||
}
|
||||
|
||||
/* The bottom section animates its height open and closed. The 0.25rem above
|
||||
it lives inside, so it collapses with the section instead of jumping. */
|
||||
.opal-message-card-bottom {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.opal-message-card-bottom[data-state="closed"] {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
/* No padding on the clipped box: a grid row at 0fr shrinks an item only down
|
||||
to its padding, so the space sits one level in. */
|
||||
.opal-message-card-bottom-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The 0.25rem above the bottom section lives inside its fold, so it closes
|
||||
with the section. */
|
||||
.opal-message-card-bottom-content {
|
||||
@apply pt-1;
|
||||
}
|
||||
|
||||
.opal-message-card-bottom[data-animate][data-state="open"] {
|
||||
animation: opal-message-card-bottom-open 200ms ease-out;
|
||||
}
|
||||
|
||||
.opal-message-card-bottom[data-animate][data-state="closed"] {
|
||||
animation: opal-message-card-bottom-close 200ms ease-in;
|
||||
}
|
||||
|
||||
@keyframes opal-message-card-bottom-open {
|
||||
from {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
to {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes opal-message-card-bottom-close {
|
||||
from {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
to {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opal-message-card-bottom[data-animate] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ Everything above, **plus**:
|
||||
|
||||
- **Only the header is interactive.** `Interactive.Stateful` wraps the header alone, so `onClick`, hover and the state colours stay there. A form inside `expandedContent` keeps its own clicks instead of toggling the card.
|
||||
- **Always controlled.** `expanded` is a one-way visual prop. There is no `defaultExpanded` and no `onExpandChange` — the caller owns the state and the trigger, exactly as with [`Card`](../card/README.md).
|
||||
- **Rounding adapts.** Expanded, the header rounds only at the top and the fold only at the bottom, so they read as one card. The corners animate over 200ms.
|
||||
- **Rounding adapts.** Expanded, the header rounds only at the top and the fold only at the bottom, so they read as one card. The corners square as the fold expands and round as it collapses, over the same 150ms.
|
||||
- **`state` is the caller's, open or closed.** The card paints whatever state it is given and never rewrites it. A card that should stop looking selected once it opens does that at the call site, with `state={open ? "filled" : "selected"}` — which is also what decides whether a `Content` inside it, set to `color="interactive"`, picks up the selection colour.
|
||||
- **The separator keeps its resting colour.** The header's bottom border stays `border-01` while expanded, even on a selected card, so it reads as a divider rather than an edge. The fold's own border does follow the selection.
|
||||
- **`padding` applies to the header only.** The fold has no intrinsic padding; pad whatever you pass to `expandedContent`.
|
||||
|
||||
@@ -192,7 +192,7 @@ type SelectCardShellProps = SelectCardBaseProps & {
|
||||
expandable?: boolean;
|
||||
/** The fold's content; `undefined` renders no fold at all. */
|
||||
fold?: React.ReactNode;
|
||||
/** Whether the fold is open, which flattens the header's bottom corners. */
|
||||
/** Whether the fold is open. */
|
||||
foldOpen?: boolean;
|
||||
foldHeight?: CardFoldHeight;
|
||||
};
|
||||
@@ -214,11 +214,13 @@ function SelectCardShell({
|
||||
}: SelectCardShellProps) {
|
||||
const paddingStyle = { padding: spacingToRem(paddingProp) };
|
||||
const radius = roundingToRem(roundingProp);
|
||||
// Expanded, the header rounds only at the top and the fold only at the
|
||||
// bottom, so the two halves read as one card rather than two.
|
||||
const headerRadius = foldOpen
|
||||
? { borderTopLeftRadius: radius, borderTopRightRadius: radius }
|
||||
: { borderRadius: radius };
|
||||
// Expandable, the stylesheet rounds the header, and squares its bottom
|
||||
// corners while the fold has any height, so the two halves stay joined as
|
||||
// it closes.
|
||||
const headerStyle: React.CSSProperties &
|
||||
Partial<Record<"--opal-card-radius", string>> = expandable
|
||||
? { ...paddingStyle, "--opal-card-radius": radius }
|
||||
: { ...paddingStyle, borderRadius: radius };
|
||||
|
||||
// A caller with its own tab index (e.g. a roving radio group) keeps it.
|
||||
const isControl = !!onClick && !disabled;
|
||||
@@ -248,7 +250,7 @@ function SelectCardShell({
|
||||
<div
|
||||
ref={expandable ? undefined : ref}
|
||||
className="opal-select-card"
|
||||
style={{ ...paddingStyle, ...headerRadius }}
|
||||
style={headerStyle}
|
||||
data-border={border}
|
||||
data-opal-status-border="default"
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
@apply w-full flex flex-col;
|
||||
}
|
||||
|
||||
/* The corners animate as the fold opens. The list restates the one in
|
||||
/* The corners round as the fold collapses. The list restates the one in
|
||||
`core/interactive/shared.css`, because `transition` is a shorthand and a
|
||||
second declaration here would otherwise drop the colour transitions. */
|
||||
.opal-select-card-expandable > .opal-select-card {
|
||||
@@ -56,12 +56,30 @@
|
||||
var(--interactive-easing),
|
||||
--interactive-foreground-icon var(--interactive-duration)
|
||||
var(--interactive-easing),
|
||||
border-radius 200ms ease-out;
|
||||
border-radius 150ms ease-out 100ms;
|
||||
border-radius: var(--opal-card-radius);
|
||||
}
|
||||
|
||||
/* While the fold is open, the header's bottom corners are square so it
|
||||
joins the fold. The corners move with the fold's height (see
|
||||
`components/fold/styles.css`): they square as it expands, and round as it
|
||||
collapses, after the content fades out. */
|
||||
.opal-select-card-expandable
|
||||
> .opal-select-card:has(+ .opal-fold[data-open="true"]) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
transition:
|
||||
background-color var(--interactive-duration) var(--interactive-easing),
|
||||
--interactive-foreground var(--interactive-duration)
|
||||
var(--interactive-easing),
|
||||
--interactive-foreground-icon var(--interactive-duration)
|
||||
var(--interactive-easing),
|
||||
border-radius 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Expanded, the header's bottom border is the separator between the two
|
||||
halves, so it keeps the resting colour whatever state the card is in. */
|
||||
.opal-select-card:has(+ .opal-fold[data-open="true"]) {
|
||||
.opal-select-card:has(+ .opal-fold[data-mounted="true"]) {
|
||||
@apply border-b-border-01;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useId, useState } from "react";
|
||||
import type { RichStr } from "@opal/types";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button, type TagProps } from "@opal/components";
|
||||
import { Fold } from "@opal/components/fold/components";
|
||||
import { SvgExpand, SvgFold } from "@opal/icons";
|
||||
import { useOpalStrings } from "@opal/strings";
|
||||
import { toPlainString } from "@opal/components/text/InlineMarkdown";
|
||||
@@ -101,13 +102,11 @@ function Collapsible({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div id={bodyId} className="opal-collapsible-body" inert={!open}>
|
||||
<div className="opal-collapsible-body-inner">
|
||||
<div className="opal-collapsible-body-content">
|
||||
{hasOpened && children}
|
||||
</div>
|
||||
<Fold open={open} keepMounted id={bodyId}>
|
||||
<div className="opal-collapsible-body-content">
|
||||
{hasOpened && children}
|
||||
</div>
|
||||
</div>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,30 +21,8 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The body's grid row animates between 0fr and 1fr. The clipped box has no
|
||||
padding of its own, so it can close all the way. */
|
||||
.opal-collapsible-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 200ms ease-out;
|
||||
}
|
||||
|
||||
.opal-collapsible[data-state="open"] .opal-collapsible-body {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.opal-collapsible-body-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The gap under the header lives one level in, so it closes with the body. */
|
||||
/* The gap under the header lives inside the fold, so it closes with the
|
||||
body. */
|
||||
.opal-collapsible-body-content {
|
||||
@apply pt-3;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opal-collapsible-body {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import "@opal/components/divider/styles.css";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Fold } from "@opal/components/fold/components";
|
||||
import type { OrientationVariants, RichStr } from "@opal/types";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { SvgChevronRight } from "@opal/icons";
|
||||
@@ -247,16 +248,11 @@ function FoldableDivider({
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
{/* The content stays mounted so the fold can close as smoothly as it
|
||||
{/* The content stays mounted, so the fold closes as smoothly as it
|
||||
opens; closed, it is inert and hidden from assistive tech. */}
|
||||
<div
|
||||
className="opal-divider-fold"
|
||||
data-open={isOpen}
|
||||
aria-hidden={!isOpen || undefined}
|
||||
inert={!isOpen || undefined}
|
||||
>
|
||||
<div className="opal-divider-fold-inner">{children}</div>
|
||||
</div>
|
||||
<Fold open={isOpen} keepMounted>
|
||||
{children}
|
||||
</Fold>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,30 +59,3 @@
|
||||
.opal-divider-chevron[data-open="true"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ── Fold ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Grid rows animate between 0fr and 1fr without a measured height, so the
|
||||
fold closes as smoothly as it opens and never needs a mount or unmount.
|
||||
The chevron above turns on the same clock. */
|
||||
.opal-divider-fold {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows 200ms ease-in-out,
|
||||
opacity 200ms ease-in-out;
|
||||
}
|
||||
.opal-divider-fold[data-open="true"] {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
.opal-divider-fold-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opal-divider-fold {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -8,21 +8,27 @@ part of whatever surrounds it: rows folding inside a card stay in the card.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ----------- | ------- | ------------------------ |
|
||||
| `open` | `boolean` | — | Whether the fold is open |
|
||||
| `children` | `ReactNode` | — | The folded content |
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `open` | `boolean` | — | Whether the fold is open |
|
||||
| `keepMounted` | `boolean` | `false` | Keep the children while closed (e.g. to keep form state) |
|
||||
| `id` | `string` | — | For a control that points at the fold with `aria-controls` |
|
||||
| `frame` | `(content) => ReactNode` | — | Wraps the content in a frame (e.g. a bordered box) that grows with the height and stays visible; only the content fades |
|
||||
| `children` | `ReactNode` | — | The folded content |
|
||||
|
||||
## Behaviour
|
||||
|
||||
- A CSS grid row moves between `0fr` and `1fr` with an opacity fade over
|
||||
200ms; no height is measured. Reduced motion turns the animation off.
|
||||
- A CSS grid row moves between `0fr` and `1fr`; no height is measured. Opening
|
||||
expands the height fully (150ms), then fades the content in (100ms). Closing
|
||||
fades it out first, then collapses. Reduced motion turns the animation off.
|
||||
- Closed, the fold takes no space. In a flex parent with a `gap`, put the
|
||||
spacing inside the fold's content, or the gap stays around the closed fold.
|
||||
- Children stay mounted through the closing animation, then drop, so a closed
|
||||
fold holds nothing.
|
||||
fold holds nothing, unless `keepMounted`.
|
||||
- While closed or closing, the fold is `inert` and `aria-hidden`.
|
||||
- `Card` and `SelectCard` build their expandable body on it (`CardFold`).
|
||||
- `CardFold` (`Card` and `SelectCard`) passes its bordered body as the `frame`.
|
||||
- `Card`, `SelectCard`, `Collapsible`, `Divider` (foldable) and `MessageCard`
|
||||
(its bottom section) all fold with it.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
import "@opal/components/fold/styles.css";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Matches the transition in `styles.css`. */
|
||||
const FOLD_DURATION_MS = 200;
|
||||
/** The whole close in `styles.css`: the fade, then the collapse. */
|
||||
const FOLD_CLOSE_MS: number = 250;
|
||||
|
||||
type FoldProps = {
|
||||
/** Whether the fold is open. */
|
||||
open: boolean;
|
||||
/**
|
||||
* Keep the children while closed, e.g. so form fields inside keep their
|
||||
* state. Off, a closed fold drops them once it finishes closing.
|
||||
*/
|
||||
keepMounted?: boolean;
|
||||
/** The fold's id, for a control that points at it with `aria-controls`. */
|
||||
id?: string;
|
||||
/**
|
||||
* Wraps the fold in a frame, such as a bordered box. The frame's height
|
||||
* follows the animation, so its edges move with it, and it stays visible;
|
||||
* only the content inside fades. A fully closed fold's frame has no height,
|
||||
* and should show no edge then (see `data-mounted` on the root).
|
||||
*/
|
||||
frame?: (content: React.ReactNode) => React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -18,33 +32,79 @@ type FoldProps = {
|
||||
* no measured height, no state machine. It paints nothing of its own, so the
|
||||
* content stays part of whatever surrounds it.
|
||||
*
|
||||
* A closed fold takes no space and holds nothing: children stay through the
|
||||
* closing animation, so it has something to collapse, then drop. While closed
|
||||
* or closing it is inert and hidden from assistive tech.
|
||||
* A closed fold takes no space. Its children stay through the closing
|
||||
* animation, so it has something to collapse, then drop, unless
|
||||
* `keepMounted`. While closed or closing it is inert and hidden from
|
||||
* assistive tech.
|
||||
*/
|
||||
function Fold({ open, children }: FoldProps) {
|
||||
function Fold({ open, keepMounted = false, id, frame, children }: FoldProps) {
|
||||
// True from the moment the fold opens until its closing animation ends,
|
||||
// the window where the children must stay mounted though `open` is false.
|
||||
const [closing, setClosing] = useState(false);
|
||||
const mounted = open || closing;
|
||||
const [closing, setClosing] = useState<boolean>(false);
|
||||
const mounted: boolean = keepMounted || open || closing;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setClosing(true);
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => setClosing(false), FOLD_DURATION_MS);
|
||||
const timeout: ReturnType<typeof setTimeout> = setTimeout(
|
||||
() => setClosing(false),
|
||||
FOLD_CLOSE_MS
|
||||
);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [open]);
|
||||
|
||||
// Whether the content shows. A transition runs only on an element that
|
||||
// already exists, so opening mounts the content hidden and shows it a frame
|
||||
// later, which lets it fade in after the height. Open from the start, it
|
||||
// shows at once, with no fade on page load.
|
||||
const [shown, setShown] = useState<boolean>(open);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setShown(false);
|
||||
return;
|
||||
}
|
||||
let second: number = 0;
|
||||
const first: number = requestAnimationFrame(() => {
|
||||
second = requestAnimationFrame(() => setShown(true));
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(first);
|
||||
cancelAnimationFrame(second);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// The fade is on the content alone, so a frame around it stays visible.
|
||||
const content = (
|
||||
<div
|
||||
className="opal-fold-content"
|
||||
data-open={open && shown ? "true" : "false"}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// The grid animates the height. A frame wraps the grid rather than sitting
|
||||
// inside its clip, so the frame's own height follows the animation and its
|
||||
// bottom edge, border and all, rides the moving edge.
|
||||
const grid = (
|
||||
<div className="opal-fold-grid" data-open={open ? "true" : "false"}>
|
||||
<div className="opal-fold-inner">{mounted ? content : null}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="opal-fold"
|
||||
data-open={open ? "true" : "false"}
|
||||
// Fully closed: nothing inside, and a frame shows no edge.
|
||||
data-mounted={mounted ? "true" : "false"}
|
||||
aria-hidden={!open || undefined}
|
||||
inert={!open || undefined}
|
||||
>
|
||||
<div className="opal-fold-inner">{mounted ? children : null}</div>
|
||||
{frame ? frame(grid) : grid}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,24 +3,43 @@
|
||||
/* ============================================================================
|
||||
Fold — content that opens and closes by animating its height.
|
||||
|
||||
.opal-fold grid 0fr ↔ 1fr, opacity fade
|
||||
.opal-fold-inner min-height: 0, clips the grid child
|
||||
.opal-fold root: open state, inert while closed
|
||||
(frame) optional, e.g. a border; its height follows
|
||||
.opal-fold-grid grid 0fr ↔ 1fr
|
||||
.opal-fold-inner min-height: 0, clips the grid child
|
||||
.opal-fold-content the fade
|
||||
|
||||
Pure CSS. Paints nothing, so the content stays part of its surroundings.
|
||||
============================================================================ */
|
||||
|
||||
.opal-fold {
|
||||
/* The height and the fade run one after the other, so content is never
|
||||
half-faded while the edge sweeps over it. A transition takes the timings of
|
||||
the state it moves to, so each rule times the move into its own state.
|
||||
Keep the total close time in sync with FOLD_CLOSE_MS in components.tsx. */
|
||||
|
||||
/* Closing: the content fades out first, then the height collapses. */
|
||||
.opal-fold-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows 200ms ease-out,
|
||||
opacity 200ms ease-out;
|
||||
transition: grid-template-rows 150ms ease-out 100ms;
|
||||
}
|
||||
|
||||
.opal-fold[data-open="true"] {
|
||||
.opal-fold-content {
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in;
|
||||
}
|
||||
|
||||
/* Opening: the height expands fully first, then the content fades in. A
|
||||
frame around the content (see `frame`) is not faded, so it is seen
|
||||
growing with the height. */
|
||||
.opal-fold-grid[data-open="true"] {
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 150ms ease-out;
|
||||
}
|
||||
|
||||
.opal-fold-content[data-open="true"] {
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease-out 150ms;
|
||||
}
|
||||
|
||||
/* Clips the grid child so it collapses to zero cleanly. */
|
||||
@@ -30,7 +49,10 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opal-fold {
|
||||
.opal-fold-grid,
|
||||
.opal-fold-grid[data-open="true"],
|
||||
.opal-fold-content,
|
||||
.opal-fold-content[data-open="true"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user