diff --git a/web/lib/opal/src/components/cards/card/README.md b/web/lib/opal/src/components/cards/card/README.md index f1552b5dca..728ec18c96 100644 --- a/web/lib/opal/src/components/cards/card/README.md +++ b/web/lib/opal/src/components/cards/card/README.md @@ -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 `
` (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 diff --git a/web/lib/opal/src/components/cards/card/components.tsx b/web/lib/opal/src/components/cards/card/components.tsx index 541b6bf898..5ad56b5329 100644 --- a/web/lib/opal/src/components/cards/card/components.tsx +++ b/web/lib/opal/src/components/cards/card/components.tsx @@ -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 (
= + { "--opal-card-radius": radius }; + return ( - -
- {children} -
+ // The bordered body is the fold's frame: it grows with the height and + // stays visible, and only the content inside it fades. + ( +
+
+ {content} +
+
+ )} + > + {children}
); } diff --git a/web/lib/opal/src/components/cards/fold/styles.css b/web/lib/opal/src/components/cards/fold/styles.css index b2efbc9891..9cfc076f8e 100644 --- a/web/lib/opal/src/components/cards/fold/styles.css +++ b/web/lib/opal/src/components/cards/fold/styles.css @@ -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; } diff --git a/web/lib/opal/src/components/cards/message-card/components.tsx b/web/lib/opal/src/components/cards/message-card/components.tsx index 5beff376d0..1ece9fecab 100644 --- a/web/lib/opal/src/components/cards/message-card/components.tsx +++ b/web/lib/opal/src/components/cards/message-card/components.tsx @@ -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 && `) 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({ />
- {presence.mounted && ( -
-
-
- - {expanded ? bottomChildren : shownBottom} -
-
+ {/* Fold animates only a change, so a card that mounts with the + section does not play the opening on page load. */} + +
+ + {expanded ? bottomChildren : shownBottom}
- )} +
diff --git a/web/lib/opal/src/components/cards/message-card/styles.css b/web/lib/opal/src/components/cards/message-card/styles.css index 00bcb0b52a..4d6ce651e7 100644 --- a/web/lib/opal/src/components/cards/message-card/styles.css +++ b/web/lib/opal/src/components/cards/message-card/styles.css @@ -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; - } -} diff --git a/web/lib/opal/src/components/cards/select-card/README.md b/web/lib/opal/src/components/cards/select-card/README.md index 1ad2678d3f..a1980a1266 100644 --- a/web/lib/opal/src/components/cards/select-card/README.md +++ b/web/lib/opal/src/components/cards/select-card/README.md @@ -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`. diff --git a/web/lib/opal/src/components/cards/select-card/components.tsx b/web/lib/opal/src/components/cards/select-card/components.tsx index 8e95a79102..478c485dcc 100644 --- a/web/lib/opal/src/components/cards/select-card/components.tsx +++ b/web/lib/opal/src/components/cards/select-card/components.tsx @@ -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> = 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({
diff --git a/web/lib/opal/src/components/cards/select-card/styles.css b/web/lib/opal/src/components/cards/select-card/styles.css index 527b2b736b..3260b09222 100644 --- a/web/lib/opal/src/components/cards/select-card/styles.css +++ b/web/lib/opal/src/components/cards/select-card/styles.css @@ -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; } diff --git a/web/lib/opal/src/components/collapsible/components.tsx b/web/lib/opal/src/components/collapsible/components.tsx index a2f5b8b247..b60fac4500 100644 --- a/web/lib/opal/src/components/collapsible/components.tsx +++ b/web/lib/opal/src/components/collapsible/components.tsx @@ -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({ } />
-
-
-
- {hasOpened && children} -
+ +
+ {hasOpened && children}
-
+
); } diff --git a/web/lib/opal/src/components/collapsible/styles.css b/web/lib/opal/src/components/collapsible/styles.css index dde3c179c8..42dba96055 100644 --- a/web/lib/opal/src/components/collapsible/styles.css +++ b/web/lib/opal/src/components/collapsible/styles.css @@ -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; - } -} diff --git a/web/lib/opal/src/components/divider/components.tsx b/web/lib/opal/src/components/divider/components.tsx index e235e8fc1b..ceb8f69558 100644 --- a/web/lib/opal/src/components/divider/components.tsx +++ b/web/lib/opal/src/components/divider/components.tsx @@ -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({
- {/* 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. */} -
-
{children}
-
+ + {children} + ); } diff --git a/web/lib/opal/src/components/divider/styles.css b/web/lib/opal/src/components/divider/styles.css index ae953f3ae0..a825bc9e04 100644 --- a/web/lib/opal/src/components/divider/styles.css +++ b/web/lib/opal/src/components/divider/styles.css @@ -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; - } -} diff --git a/web/lib/opal/src/components/fold/README.md b/web/lib/opal/src/components/fold/README.md index a28a1f7dba..dad0c45e09 100644 --- a/web/lib/opal/src/components/fold/README.md +++ b/web/lib/opal/src/components/fold/README.md @@ -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 diff --git a/web/lib/opal/src/components/fold/components.tsx b/web/lib/opal/src/components/fold/components.tsx index 18840bcd18..8f55dd26c1 100644 --- a/web/lib/opal/src/components/fold/components.tsx +++ b/web/lib/opal/src/components/fold/components.tsx @@ -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(false); + const mounted: boolean = keepMounted || open || closing; useEffect(() => { if (open) { setClosing(true); return; } - const timeout = setTimeout(() => setClosing(false), FOLD_DURATION_MS); + const timeout: ReturnType = 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(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 = ( +
+ {children} +
+ ); + + // 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 = ( +
+
{mounted ? content : null}
+
+ ); + return (
-
{mounted ? children : null}
+ {frame ? frame(grid) : grid}
); } diff --git a/web/lib/opal/src/components/fold/styles.css b/web/lib/opal/src/components/fold/styles.css index 67d678b4fe..6a6deee883 100644 --- a/web/lib/opal/src/components/fold/styles.css +++ b/web/lib/opal/src/components/fold/styles.css @@ -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; } }