@layer cinder.tokens, cinder.foundation, cinder.components, cinder.utilities;

@layer cinder.components {
  .cinder-virtual-list {
    display: block;
    block-size: var(--cinder-virtual-list-height, 20rem);
    inline-size: 100%;
    overflow: auto;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    contain: layout paint;

    /*
     * A list that owns its scroll container owns its scroll anchor too: the
     * rendered window is positioned from the container's scroll offset, so
     * whatever the browser picks as its own anchor moves only because the
     * component moved it.
     *
     * Native anchoring can then only fight it. It once did: under reduced motion
     * the window's inset landed a frame after a pinning sticky row left flow, and
     * the browser scrolled the list back a row to hold its anchor, undoing a
     * keyboard press that had just landed (COR-1523). The geometry no longer lags
     * (see the `transition-property` rule below, COR-1535), but nothing inside
     * this container needs the browser's anchor, so any later shift of the same
     * kind cannot turn into the list scrolling itself.
     *
     * `windowScroll` gives that back below: there the page scrolls, not the list.
     */
    overflow-anchor: none;
  }

  /*
   * `windowScroll`: the list virtualizes against the window's own scroll
   * position instead of owning a scrolling container, so it renders no
   * scrolling box of its own here — `height` plays no part. It takes its full
   * virtual size in normal document flow, like any other block element, and
   * the page's own scrollbar covers it.
   *
   * `horizontal` never combines with this attribute: the component itself
   * resolves that combination to plain vertical windowing (see `isHorizontal`
   * in the script), so `data-cinder-orientation` is never present alongside
   * `data-cinder-window-scroll`, and this rule does not need to out-specificity
   * the horizontal-mode one below.
   *
   * `contain: layout paint` on the base rule is intentionally left in place —
   * it still holds for this element's own subtree even though it no longer
   * establishes the scroll container that subtree's offsets are positioned
   * within a document sense of "contained," and dropping it here would cost
   * nothing this mode needs.
   *
   * Native anchoring stays available to the page. The document's scroll
   * position is not this component's: when content above the list changes
   * height, the browser's anchor is what keeps the reader on the same rows, and
   * the component's `documentOffset` is only re-measured on a window scroll or
   * resize, so excluding the list's rows from anchor selection could jump the
   * viewport with nothing here to notice. Dynamic sizing still opts out below.
   */
  .cinder-virtual-list[data-cinder-window-scroll='true'] {
    block-size: auto;
    overflow: visible;
    overflow-anchor: auto;
  }

  /*
   * Dynamic sizing anchors the viewport itself, in either mode: when a measured
   * row above the fold turns out to differ from its estimate, the component adds
   * that delta to the scroll offset before paint. A browser anchoring natively
   * would apply the same delta on top, over-scrolling by the row's size
   * difference every time an image loads or a line wraps. Only one of the two can
   * own the anchor, and this component owns it.
   *
   * Written after the `windowScroll` rule at the same specificity, so a
   * window-scrolled list with dynamic sizing still opts out.
   */
  .cinder-virtual-list[data-cinder-dynamic-size='true'] {
    overflow-anchor: none;

    /*
     * Reserve the scrollbar gutter so the width available to rows does not depend
     * on whether a scrollbar is currently showing.
     *
     * Without this, dynamic sizing oscillates on platforms with non-overlay
     * scrollbars: measured rows overflow the estimated total, a scrollbar appears,
     * the narrower content width invalidates every measurement, the estimated
     * spacer shrinks below the viewport, the scrollbar disappears, the width grows
     * back, and the cycle repeats — burning layout work and tripping
     * ResizeObserver loop errors. A stable gutter removes the input to that loop
     * instead of damping its output.
     */
    scrollbar-gutter: stable;
  }

  /*
   * Horizontal mode swaps the scroll axis. `--cinder-virtual-list-height` keeps its
   * name and drives inline-size here rather than block-size, so a consumer's
   * existing theme override survives turning `horizontal` on — see the prop's
   * JSDoc for why the name is reused instead of a second token being introduced.
   */
  .cinder-virtual-list[data-cinder-orientation='horizontal'] {
    block-size: auto;
    inline-size: var(--cinder-virtual-list-height, 20rem);
    overflow-x: auto;
    overflow-y: hidden;
  }

  /*
   * The spacer carries the scroll extent on the inline axis instead of the block
   * axis, so the base rule's `min-block-size: 100%` / `inline-size: 100%` pair is
   * transposed. The inline size itself comes from the element's style attribute,
   * which the component writes as a logical property.
   */
  .cinder-virtual-list[data-cinder-orientation='horizontal'] .cinder-virtual-list__spacer {
    min-block-size: 0;
    min-inline-size: 100%;
  }

  /*
   * In flow, unlike the vertical case. A vertical list takes its block-size from
   * the `height` prop, so an out-of-flow window costs nothing; horizontal
   * reinterprets that prop as the inline size and leaves block-size `auto`, so an
   * absolutely positioned window would leave the container with no content of any
   * height and collapse it to zero. Relative positioning keeps the rows
   * contributing their height while still shifting the window to the scroll offset.
   *
   * `inset-inline-end: auto` is load-bearing: the base rule pins both inline edges
   * to stretch the window across a vertical list. Left in place, an over-constrained
   * relative box drops whichever edge is the end one for the writing direction —
   * which silently means the START edge under RTL, and the window would not move.
   */
  .cinder-virtual-list[data-cinder-orientation='horizontal'] .cinder-virtual-list__window {
    position: relative;
    display: flex;
    inset-inline-end: auto;
  }

  /* Rows are sized along the inline axis; flex must not shrink them to fit. */
  .cinder-virtual-list[data-cinder-orientation='horizontal'] .cinder-virtual-list__row {
    flex: 0 0 auto;
  }

  /*
   * The elements whose geometry the component writes inline never transition it.
   *
   * Reduced motion (`foundation.css`) sets every element's `transition-duration` to
   * 0.01ms and leaves `transition-property` at its initial `all`, so an element that
   * declared no transition animates every property change anyway — and a running
   * transition holds the old computed value until the next frame samples it. Here
   * that held the window's inset, the pinned row's inset and the spacer's size a
   * frame behind each scroll, while `position`, which cannot animate, changed at
   * once: as the active sticky row pinned and left flow, every row sat a header
   * higher for a frame, and the page's native scroll anchoring scrolled back by
   * that much to compensate (COR-1535). Every window advance painted a row off,
   * sticky rows or not.
   *
   * Only `transition-property` is set. The duration stays the reduced-motion rule's
   * to force; with no property to apply it to, nothing here animates.
   */
  .cinder-virtual-list__spacer,
  .cinder-virtual-list__window,
  .cinder-virtual-list__row {
    transition-property: none;
  }

  .cinder-virtual-list__spacer {
    position: relative;
    min-block-size: 100%;
    inline-size: 100%;
  }

  .cinder-virtual-list__window {
    position: absolute;
    inset-inline: 0;
  }

  .cinder-virtual-list__row {
    overflow: hidden;
  }

  /*
   * The active sticky row once its own index has left the window.
   *
   * Absolute, so it does not displace the rows the window is laying out in flow —
   * that displacement is why it cannot simply stay in place — and offset inline to
   * track the viewport's leading edge, which is what `position: sticky` would have
   * done for it had it still had a flow box nearby.
   *
   * It stays in the keyed `{#each}` rather than moving to an element of its own:
   * Svelte cannot carry identity across that boundary, so a row with local state or
   * a focused control would be destroyed and rebuilt every time it crossed.
   *
   * Both attributes are in the selector on purpose. A pinned row is always also the
   * active one, so the sticky rule below matches it too — and carrying two attributes
   * to that rule's one settles the cascade on specificity rather than on which is
   * written first, which is how `position: sticky` quietly took this back once.
   *
   * It stays above the rows sliding under it by the `z-index: 1` the active rule
   * supplies — a pinned row is always also the active one — rather than by DOM order.
   * That combination is what the primitive-composition record describes.
   */
  .cinder-virtual-list__row[data-cinder-sticky='true'][data-cinder-sticky-pinned='true'] {
    position: absolute;
    inset-inline: 0;
  }

  /*
   * Only the start edge, deliberately not `inset-block: 0`. Pinning both edges sizes
   * the row to the window's block-size — which, now that the row is out of flow, is
   * computed from the OTHER rows. A sticky row taller than its neighbours was squashed
   * to their height and its content clipped, at the moment it crossed the window
   * boundary and became pinned. Anchoring one edge leaves its own height alone.
   */
  .cinder-virtual-list[data-cinder-orientation='horizontal']
    .cinder-virtual-list__row[data-cinder-sticky='true'][data-cinder-sticky-pinned='true'] {
    inset-inline: auto;
    inset-block-start: 0;
  }

  /*
   * Only the ACTIVE row is held at the leading edge, and only it is raised.
   *
   * `z-index` was on every sticky row, on the reasoning that it is inert unless the
   * element is positioned. That is true in block layout and false here: under
   * `horizontal` the window is a flex container, and `z-index` applies to flex items
   * whether or not they are positioned. An inactive header approaching the edge
   * therefore entered the same stacking level as the held one and, being later in DOM
   * order, painted straight over it — before it had become active.
   *
   * The pinned row is covered by this rule too, since it is always also the active
   * one, and it is the `z-index` here that keeps it above the rows sliding under it
   * rather than DOM order — which is what leaves the element free to sit in index
   * order for assistive technology. Sticking every sticky row in
   * the window put each header the reader had already passed at the same inset,
   * stacked on top of one another — and a taller earlier header protruded below the
   * current one, with anything focusable inside it still reachable while obscured.
   * The overscanned window can hold several headers at once, so this is not rare.
   *
   * Offsets are relative to the scroll container, which is the element that scrolls,
   * so the inset is 0 on the axis being scrolled.
   */
  .cinder-virtual-list__row[data-cinder-sticky-active='true'] {
    position: sticky;
    inset-block-start: 0;
    z-index: 1;
  }

  .cinder-virtual-list[data-cinder-orientation='horizontal']
    .cinder-virtual-list__row[data-cinder-sticky-active='true'] {
    inset-block-start: auto;
    inset-inline-start: 0;
  }
}
