# How the accordion works

This walks through what actually happens to a panel's `max-height` from the
moment it opens to the moment it closes, plus the accessibility and usability
behaviors layered on top — several of which exist specifically to keep
collapsed content reachable via the browser's native find-in-page (Ctrl+F /
Cmd+F) rather than hiding it away entirely.

## The problem the height animation works around

CSS can animate between two fixed numbers (`0px` to `400px`) just fine, but
not between a fixed number and an intrinsic size like `auto` or `none` —
there's no way to interpolate towards "however tall the content turns out to
be." Since this component's resting states are "closed = `0`" and "open =
`none`," a real pixel number has to stand in for the intrinsic size just
long enough to animate, then hand back control once the animation is done.
That hand-off is the core mechanic behind everything below.

Browsers are closing this gap — Chrome now supports animating directly to
intrinsic sizes via `interpolate-size` / `calc-size()`
([details](https://developer.chrome.com/docs/css-ui/animate-to-height-auto)).
Once that's broadly supported, most of this file's height-tracking machinery
could likely be replaced with a plain CSS transition to `height: auto`.

## Opening, step by step

**1. Get a target number to animate to.** There are two ways:

- **First time this panel has ever been opened:** its height isn't known yet,
  so it gets measured. `measureNaturalHeight()` briefly sets
  `max-height: none` (so the content can size itself normally), reads
  `offsetHeight` to get a real pixel number, and that becomes the target.
- **This panel was opened before, then closed:** a number is already saved
  from last time (`dataset.contentHeight`, written when it was closed — more
  on that below), so measuring is skipped and that saved number is reused.
  This is the fast path — `pinAndAnimate` handles it in one shot.

**2. Animate from `0` to that target number.** A real CSS transition,
`0px → 400px` for example, taking 300ms per the stylesheet.

**3. Once that transition finishes, let go — `max-height` goes back to
`none`.** This is what stops images and embedded apps from getting clipped.
Without this step, `max-height` would stay pinned at whatever number was
measured in step 1 — forever, or at least until the panel closes. If an image
was still downloading, or an embedded app hadn't rendered its content yet,
when step 1 measured the height, that measurement comes out too small, and
since nothing ever releases the pin, the panel stays capped at that
too-small number permanently. Anything that shows up later gets sliced off
by `overflow: hidden`.

Releasing back to `none` fixes this not by recalculating anything, but by
removing the need to. Once `max-height` is `none` again, the box isn't a
fixed size anymore — it's back to "auto-size to whatever content is inside,"
which is ordinary, continuous browser behavior. No JavaScript has to notice
when an image finishes loading or when an app mounts new DOM; the browser
reflows automatically, the same way any ordinary uncapped element would,
whether that content shows up 10ms later or 10 seconds later.

**Nothing keeps re-checking the height while a panel sits open.** Once
`max-height` is `none`, the box is correctly sized at all times simply
because that's what `none` means — there's nothing to keep in sync, because
it's never out of sync to begin with.

**How the code knows the transition in step 2 is finished, so it knows when
to let go:** two signals, whichever happens first:

- The browser reports it directly — a `transitionend` event fires the
  instant the CSS animation completes (~300ms).
- A 350ms backup timer, in case `transitionend` doesn't fire for some reason
  (the one known case: the measured height turned out to be `0`, so there
  was no real size change to animate, and a transition with no change never
  fires `transitionend`).

Whichever fires first wins, and the other is cancelled. This applies the
same way regardless of how fast or slow the panel's own content loads —
that's the whole point of releasing the cap instead of leaving it pinned.

## Closing, step by step

The only moment a panel's height gets measured again after it opens is when
it **closes**:

**1. Cancel any leftover cleanup from a very recent open.** If a panel was
opened moments ago and hasn't reached the "let go" step yet (only matters
when closing within ~350ms of opening), that pending step is cancelled
first — otherwise it could fire mid-close and cause a visible stutter.

**2. Measure the panel's real, current height**, using the same
`measureNaturalHeight()` used on open. Since the panel has been resting at
`max-height: none` the whole time it was open, this measurement reflects
however tall the content actually is right now — including anything that
loaded late. There's no way for it to be stale, because nothing was capping
the box in between.

**3. Save that number** into `dataset.contentHeight` on the element — the
number the next open reuses via the fast path, so reopening the same panel
later animates straight to the correct, already-known height instead of
re-measuring from scratch.

**4. Animate from that real number down to `0`**, using the same two-frame
"settle" technique as opening (`settleAndAnimate`) rather than the fast
`pinAndAnimate` path — going from `none` to a real pixel number is a genuine
change, not a no-op, so a single reflow isn't reliably enough to establish it
as a real starting point; it needs an actual painted frame first.

**5. Once that transition ends, clean up**: clear the inline `max-height`
(so the closed state is driven by the stylesheet's `max-height: 0` rule, not
a leftover inline style) and restore `hidden="until-found"` so the collapsed
content is still findable via browser Ctrl+F / Cmd+F.

## The timeline app special case

Separately from the height mechanics above — if the panel being opened
contains the `.tcu-timeline-app` widget, the same "let go" moment (step 3 of
opening) also fires a `resize` event on the page. That widget's scroll-reveal
animations use a library (GSAP ScrollTrigger) that caches position data based
on the page layout the moment it loads — often while the panel is still
collapsed. The resize event nudges it to recalculate using the panel's real,
now-open layout. `resizeOnOpenSelectors` in the code is the list of selectors
that trigger this nudge, in case another embedded app needs the same
treatment later.

## Usability and accessibility behaviors

The height animation is one piece of a larger set of behaviors this
component handles:

- **Deep-linking.** Every panel's heading has an id, and the accordion opens
  the matching panel automatically if the page loads with that id in the URL
  hash (`#accXXX-...`). Clicking a heading also updates the URL hash via
  `history.replaceState`, so the current panel can be bookmarked, shared, or
  linked to directly — closing a panel removes the hash again. A
  `hashchange` listener keeps this in sync if the hash changes without a
  full page reload.

- **Keyboard and screen reader support.** Headings get `aria-expanded`
  (`true`/`false`) so assistive technology announces open/closed state, and
  the content panel gets `aria-hidden` in step with it. The little icon next
  to each heading has its own `aria-label` ("Open Accordion" / "Close
  Accordion") that updates as the panel toggles.

- **Browser find-in-page (Ctrl+F / Cmd+F).** Closed panels use
  `hidden="until-found"` instead of `display:none`, which keeps their text
  searchable by the browser's native find feature. If a search match lands
  inside a closed panel, the browser's `beforematch` event opens that panel
  automatically before scrolling to the match, so the found text is actually
  visible.

- **Print.** Printing a page temporarily removes `hidden="until-found"` from
  every panel regardless of its open/closed state, so a printed copy shows
  all the content, not just whatever happened to be open on screen — then
  restores the collapsed state afterward for anyone still viewing on screen.

- **Window resize.** Resizing the viewport clears any cached heights and
  removes the pixel cap from whatever panel is currently open, so a
  significant layout change (like rotating a device, or a responsive
  breakpoint shifting) can't leave a panel capped at a now-incorrect height.

- **Backward compatibility.** Older accordion markup that predates the
  current heading structure gets automatically upgraded in place
  (`updateHeading`) so existing content on older pages doesn't break when
  the component itself is updated.

- **Only one panel open per container.** Opening a panel closes any other
  panel that's currently open in the same accordion group, keeping the
  interaction pattern predictable — one topic expanded at a time.
