atelier

Catalog / Recipe

Listing reflow

Rows travel to their new places when a list is sorted or filtered: a same-document view transition in which every row is named as itself.

Family
page transition
Status
lab
Used for
ListingCard grid
Size
1,330 of 1,400 bytes, gzipped
Frame
Hold at
Skin
Version
order
tag
mount(element)

The third way a thing travels: to its new place in the same list. The rows are named as themselves (match-element: a name the browser derives from the element’s identity, so no two can clash), the list is reordered or filtered inside a transition, and each row goes where it now belongs. A row that is filtered out fades; one that comes back fades in.

Use

import { mount } from './listing-reflow';

const handle = mount(document.querySelector('.cards'));
handle.update({ order: 1, descending: true });
handle.update({ tag: 2 });
handle.change(() => list.append(row));

The list says what it can be ordered and filtered by, in attributes. Each row may carry data-sort-1, data-sort-2 and so on, and data-tag; the list carries data-tags with the tags in order. Keys that are both numbers compare as numbers, anything else as words, and a row without the key goes last. Any other change to the list goes through change, which runs it inside a transition where there is one.

Options

Option Default Meaning
order 0 As written, or by data-sort-n
descending false The order reversed
tag 0 All rows, or those whose data-tag is the n-th of the list’s data-tags
duration 320 How long a row takes to travel, in ms

Versions

Version What it is
Reflow The defaults: as written
By year The second key, newest first
Games only The first tag

Where it must not go

  • A table of data. A row that travels is a row the reader loses their place in; sort a table in place.
  • Prose. Nothing in a reading column is a list to reorder.

What it does when things are missing

Without startViewTransition, or under reduced motion, the rows change in place. Rows filtered out are hidden with the hidden attribute and never removed, so nothing the page had is lost. A change asked for while one is in flight waits for it. destroy() restores the written order, shows every row and removes the style.

Learned while building it

  • match-element is the whole engine. A per-row name would have let rows be staggered and would have had to be unique by hand; a name the browser derives from the element cannot clash and costs nothing to assign. There is no stagger, by that choice.
listing-reflow.ts190 lines · 1,330 bytes gzipped · no imports
/**
 * listing-reflow
 *
 * Rows travel to their new places when a list is sorted or filtered: a
 * same-document view transition in which every row is named as itself.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './listing-reflow';
 *     const handle = mount(document.querySelector('.cards'));
 *     handle.update({ order: 1 });            // by data-sort-1
 *     handle.update({ tag: 2 });              // only rows whose data-tag is the 2nd of data-tags
 *     handle.change(() => list.append(row));  // any other change, inside a transition
 *     handle.destroy();                       // the written order, every row shown
 *
 * The list says what it can be ordered and filtered by, in attributes;
 * the recipe moves the rows. Each child may carry `data-sort-1`,
 * `data-sort-2` and so on as keys, and `data-tag`; the list carries
 * `data-tags` with the tags in order. Rows that are filtered out are
 * hidden, never removed, and come back.
 */

export interface Options {
  /** Which key the rows are ordered by: 0 as written, n the rows' data-sort-n attribute. */
  order: number;
  /** The order reversed. */
  descending: boolean;
  /** Which rows are shown: 0 all, n the rows whose data-tag is the n-th word of the list's data-tags. */
  tag: number;
  /** How long a row takes to travel, in ms. */
  duration: number;
}

export const DEFAULTS: Options = {
  order: 0,
  descending: false,
  tag: 0,
  duration: 320,
};

/** Named versions. The first is the defaults. */
export const PRESETS: Record<string, Partial<Options>> = {
  Reflow: {},
  'By year': { order: 2, descending: true },
  'Games only': { tag: 1 },
};

export interface Handle {
  update(options: Partial<Options>): void;
  /** Runs any change to the list inside a transition where there is one, with every row named as itself. */
  change(mutate: () => void): void;
  destroy(): void;
}

const NOOP: Handle = { update() {}, change(mutate) { mutate(); }, destroy() {} };

const clampNumber = (value: unknown, low: number, high: number, fallback: number): number =>
  typeof value === 'number' && Number.isFinite(value) ? Math.min(high, Math.max(low, Math.round(value))) : fallback;

export function mergeOptions(base: Options, explicit: Partial<Options>): Options {
  return {
    order: clampNumber(explicit.order, 0, 99, base.order),
    descending: typeof explicit.descending === 'boolean' ? explicit.descending : base.descending,
    tag: clampNumber(explicit.tag, 0, 99, base.tag),
    duration: clampNumber(explicit.duration, 0, 3000, base.duration),
  };
}

/** Numbers as numbers, words as words, and a row without the key last. */
export function compareKeys(a: string | null, b: string | null): number {
  if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1;
  const x = Number(a);
  const y = Number(b);
  if (a.trim() !== '' && b.trim() !== '' && Number.isFinite(x) && Number.isFinite(y)) return x - y;
  return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}

/** The order the rows go in, by their written index, and which are hidden. Stable: equal keys keep their written order. */
export function arrangement(
  rows: readonly { keys: readonly (string | null)[]; tag: string | null }[],
  order: number,
  descending: boolean,
  tag: string | null,
): { order: number[]; hidden: boolean[] } {
  const indices = rows.map((_, index) => index);
  if (order > 0) indices.sort((a, b) => compareKeys(rows[a]?.keys[order - 1] ?? null, rows[b]?.keys[order - 1] ?? null) || a - b);
  if (descending) indices.reverse();
  return { order: indices, hidden: rows.map((row) => tag !== null && row.tag !== tag) };
}

/** One mount per list. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();

export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
  if (!element) return NOOP;
  const doc = element.ownerDocument;
  const win = doc.defaultView;
  if (!win) return NOOP;
  mounted.get(element)?.destroy();

  let current = mergeOptions(DEFAULTS, options);
  // The one place motion is decided, so a host can override matchMedia.
  const reduce = (): boolean => typeof win.matchMedia === 'function' && win.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const rows = Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement);
  const style = doc.createElement('style');
  style.dataset.listingReflow = '';
  style.textContent = `::view-transition-group(.reflow) { animation-duration: ${current.duration}ms; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }`;
  doc.head.appendChild(style);

  let busy = false;
  let queued: (() => void) | null = null;
  const named: HTMLElement[] = [];
  const clear = (): void => {
    for (const row of named.splice(0)) {
      row.style.removeProperty('view-transition-name');
      row.style.removeProperty('view-transition-class');
    }
  };
  const change = (mutate: () => void): void => {
    if (busy) {
      // One at a time: the last change asked for runs when this one lands.
      queued = mutate;
      return;
    }
    if (reduce() || typeof doc.startViewTransition !== 'function') {
      mutate();
      return;
    }
    busy = true;
    for (const row of rows) {
      row.style.setProperty('view-transition-name', 'match-element');
      row.style.setProperty('view-transition-class', 'reflow');
      named.push(row);
    }
    const done = (): void => {
      clear();
      busy = false;
      const next = queued;
      queued = null;
      if (next) change(next);
    };
    try {
      doc.startViewTransition(mutate).finished.then(done, done);
    } catch {
      mutate();
      done();
    }
  };
  const apply = (): void => {
    const tags = (element.dataset.tags ?? '').split(/\s+/).filter(Boolean);
    const tag = current.tag > 0 ? (tags[current.tag - 1] ?? null) : null;
    const keys = Math.max(current.order, 0);
    const laid = arrangement(
      rows.map((row) => ({ keys: Array.from({ length: keys }, (_, index) => row.getAttribute(`data-sort-${index + 1}`)), tag: row.dataset.tag ?? null })),
      current.order,
      current.descending,
      tag,
    );
    change(() => {
      laid.hidden.forEach((hidden, index) => {
        const row = rows[index];
        if (row) row.hidden = hidden;
      });
      element.append(...laid.order.map((index) => rows[index]).filter((row): row is HTMLElement => row !== undefined));
    });
  };

  let destroyed = false;
  const handle: Handle = {
    update(next) {
      current = mergeOptions(current, next);
      style.textContent = `::view-transition-group(.reflow) { animation-duration: ${current.duration}ms; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }`;
      apply();
    },
    change,
    destroy() {
      if (destroyed) return;
      destroyed = true;
      queued = null;
      clear();
      for (const row of rows) row.hidden = false;
      element.append(...rows);
      style.remove();
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);
  if (current.order !== DEFAULTS.order || current.tag !== DEFAULTS.tag || current.descending) apply();
  return handle;
}