diff --git a/.changeset/bones-boundary-element.md b/.changeset/bones-boundary-element.md new file mode 100644 index 0000000..530ec2c --- /dev/null +++ b/.changeset/bones-boundary-element.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": minor +--- + +Add `@camp.dev/bones/element`, a `` custom element that owns the loading state of its subtree. Set `busy` and the element sets `aria-busy="true"` and `inert` after a `delay` (200 ms), keeps them for at least `min-duration` (400 ms), and removes them inside a view transition where the browser supports one. It fires `bones:show` and `bones:hide`, honors `force` for demos, and adopts server-rendered `aria-busy` on upgrade. `@camp.dev/bones/react` gains a hook-free `` wrapper. diff --git a/README.md b/README.md index 62da87b..305480c 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,13 @@ import "@camp.dev/bones/css"; ## Entry points -| Import | Contents | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` | -| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. | -| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. | -| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. | +| Import | Contents | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` | +| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. | +| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. | +| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. | +| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. | React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it. @@ -129,6 +130,25 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. +## Without React + +`` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available. + +```html + + + +

Title

+

Body copy.

+
+``` + +`@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](apps/docs/content/docs/api/bones-boundary.mdx). + +Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `` from `@camp.dev/bones/react`. + ## Development ```bash diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx new file mode 100644 index 0000000..6b05e46 --- /dev/null +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -0,0 +1,130 @@ +--- +title: bones-boundary +description: A custom element that manages the loading state of its subtree. +--- + +```ts +import "@camp.dev/bones/element"; +``` + +`` sets `aria-busy="true"` and `inert` on itself while its subtree is loading, and removes them when it is not. It draws nothing. `@camp.dev/bones/css` styles `data-bone` markup and `@camp.dev/bones/auto.css` styles unmarked leaves, and both key on `aria-busy="true"`. + +Set `busy` when loading starts and remove it when loading ends. The element waits `delay` before it shows bones, so a fast response never flashes a skeleton, and it keeps bones for at least `min-duration`, so a slow one never strobes. + +## Usage + +```html + + + + +

Loading profile

+

Three lines of copy become three bones while busy.

+
+ + +``` + +The second script waits for the definition instead of trusting script order. A classic ` + + +

Title

+

Body copy.

+
+``` + +`@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](https://github.com/campdotdev/bones/blob/main/apps/docs/content/docs/api/bones-boundary.mdx). + +Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `` from `@camp.dev/bones/react`. + ## Development ```bash diff --git a/packages/bones/package.json b/packages/bones/package.json index a9bb593..cd8485d 100644 --- a/packages/bones/package.json +++ b/packages/bones/package.json @@ -18,10 +18,12 @@ ], "type": "module", "sideEffects": [ - "*.css" + "*.css", + "./dist/element/index.mjs" ], "exports": { ".": "./dist/index.mjs", + "./element": "./dist/element/index.mjs", "./react": "./dist/react/index.mjs", "./package.json": "./package.json", "./css": { diff --git a/packages/bones/sandbox/boundary.html b/packages/bones/sandbox/boundary.html new file mode 100644 index 0000000..0c62c57 --- /dev/null +++ b/packages/bones/sandbox/boundary.html @@ -0,0 +1,91 @@ + + + + + + bones-boundary sandbox + + + + + + +
+ + + + + +
+ + +

Article title

+

First paragraph with a couple of lines of copy to show the bar geometry.

+

Second paragraph, shorter.

+ avatar + +
+ +

+
+    
+  
+
diff --git a/packages/bones/src/element/boundary.ts b/packages/bones/src/element/boundary.ts
new file mode 100644
index 0000000..f7e5e9c
--- /dev/null
+++ b/packages/bones/src/element/boundary.ts
@@ -0,0 +1,299 @@
+// ---------------------------------------------------------------------------
+//  — owns the loading state of its subtree
+//
+// The element draws nothing. bones.css and auto.css key on aria-busy="true",
+// so the element's whole job is to set aria-busy and inert at the right time:
+// after `delay` so fast loads never flash a skeleton, and for at least
+// `min-duration` so slow ones never strobe. `busy` and `force` are the inputs
+// an author controls; aria-busy and inert are outputs the element controls.
+// ---------------------------------------------------------------------------
+
+export const DEFAULT_DELAY = 200;
+export const DEFAULT_MIN_DURATION = 400;
+
+const UPGRADE_PROPERTIES = ["busy", "force", "delay", "minDuration", "transition"] as const;
+type UpgradeProperty = (typeof UPGRADE_PROPERTIES)[number];
+
+// "hiding" is the window a view transition leaves open: #hide has decided to
+// remove the output attributes, but the browser runs the update callback on a
+// later frame. Bones are still on screen, and busy or force arriving in that
+// window must cancel the hide rather than race it.
+type State = "idle" | "pending" | "showing" | "draining" | "hiding";
+
+const swallow = (): void => {};
+
+function parseMs(value: string | null, fallback: number): number {
+  if (value === null || value.trim() === "") return fallback;
+  const ms = Number(value);
+  return Number.isFinite(ms) && ms >= 0 ? ms : fallback;
+}
+
+// `extends HTMLElement` evaluates HTMLElement when the module loads. Node has
+// none, and a Next.js client module still runs on the server during SSR, so
+// the base class falls back to an empty class there. Registration is skipped
+// in that case anyway (see index.ts).
+const Base: typeof HTMLElement =
+  typeof HTMLElement === "undefined" ? (class {} as unknown as typeof HTMLElement) : HTMLElement;
+
+export class BonesBoundary extends Base {
+  // aria-busy and inert are outputs, observed only so the element can put
+  // them back when something else strips them. See attributeChangedCallback.
+  static readonly observedAttributes = ["busy", "force", "aria-busy", "inert"];
+
+  #state: State = "idle";
+  #timer: ReturnType | undefined;
+  #connected = false;
+  #shownAt = 0;
+  #hideToken = 0;
+  #writingOutput = false;
+
+  get busy(): boolean {
+    return this.hasAttribute("busy");
+  }
+
+  set busy(value: boolean) {
+    this.toggleAttribute("busy", Boolean(value));
+  }
+
+  get force(): boolean {
+    return this.hasAttribute("force");
+  }
+
+  set force(value: boolean) {
+    this.toggleAttribute("force", Boolean(value));
+  }
+
+  get delay(): number {
+    return parseMs(this.getAttribute("delay"), DEFAULT_DELAY);
+  }
+
+  set delay(value: number) {
+    if (value === null || value === undefined) this.removeAttribute("delay");
+    else this.setAttribute("delay", String(value));
+  }
+
+  get minDuration(): number {
+    return parseMs(this.getAttribute("min-duration"), DEFAULT_MIN_DURATION);
+  }
+
+  set minDuration(value: number) {
+    if (value === null || value === undefined) this.removeAttribute("min-duration");
+    else this.setAttribute("min-duration", String(value));
+  }
+
+  get transition(): "auto" | "none" {
+    return this.getAttribute("transition") === "none" ? "none" : "auto";
+  }
+
+  set transition(value: "auto" | "none") {
+    if (value === "none") this.setAttribute("transition", "none");
+    else this.removeAttribute("transition");
+  }
+
+  get showing(): boolean {
+    return this.#state === "showing" || this.#state === "draining" || this.#state === "hiding";
+  }
+
+  connectedCallback(): void {
+    for (const name of UPGRADE_PROPERTIES) this.#upgradeProperty(name);
+    this.#connected = true;
+    // Server-rendered markup can carry aria-busy="true" so the CSS paints
+    // bones before this script runs. Adopt that as "showing" rather than
+    // re-running the delay; min-duration counts from now.
+    if (this.getAttribute("aria-busy") === "true" && !this.showing) {
+      this.#show();
+      if (!this.busy && !this.force) this.#evaluate();
+      return;
+    }
+    this.#evaluate();
+  }
+
+  disconnectedCallback(): void {
+    this.#connected = false;
+    this.#clearTimer();
+    // A pending element that gets reparented (moved with appendChild, busy
+    // unchanged) must restart its delay rather than get stuck: without this,
+    // connectedCallback's #evaluate sees busy=true and state="pending" (not
+    // idle or draining) and does nothing. A showing or draining element keeps
+    // its state (and its aria-busy/inert attributes) across the move: it
+    // already has a live skeleton on screen and must keep its original
+    // min-duration clock rather than re-fire bones:show and restart the
+    // window from the reconnect time. A hiding element rolls back to showing:
+    // its attributes are still on, the queued update bails because the state
+    // is no longer "hiding" (a removed element fires no events), and a moved
+    // element re-evaluates from its original #shownAt instead of taking the
+    // adoption path and firing a second bones:show.
+    if (this.#state === "pending") this.#state = "idle";
+    else if (this.#state === "hiding") this.#state = "showing";
+  }
+
+  attributeChangedCallback(name: string): void {
+    // Attributes parsed from markup fire this before connectedCallback; the
+    // connect path evaluates once for all of them.
+    if (!this.#connected) return;
+    if (name === "aria-busy" || name === "inert") {
+      this.#defendOutput();
+      return;
+    }
+    this.#evaluate();
+  }
+
+  // React 19 owns every prop it renders, so a  that drops
+  // `force` makes React delete the aria-busy and inert it wrote alongside it,
+  // mid-update, while the element still means to show bones. Put them back.
+  // Only in the two states that mean "bones are on screen and staying there":
+  // #hide leaves "showing" and "draining" for "hiding" before it removes
+  // anything, so the element never fights its own hide, and #writingOutput
+  // keeps the reaction from its own setAttribute out of here entirely. An
+  // idle element leaves both attributes alone; they are the author's then.
+  #defendOutput(): void {
+    if (this.#writingOutput) return;
+    if (this.#state !== "showing" && this.#state !== "draining") return;
+    if (this.getAttribute("aria-busy") === "true" && this.hasAttribute("inert")) return;
+    this.#writeOutput(true);
+  }
+
+  // A page that assigns boundary.busy = false before this module loads writes
+  // an own data property onto the element. Upgrading does not remove it, and
+  // it shadows the prototype accessor from then on, so the setter never runs
+  // and the element never reads the attribute again. Replay the value through
+  // the accessor and delete the shadow.
+  #upgradeProperty(name: UpgradeProperty): void {
+    if (!Object.prototype.hasOwnProperty.call(this, name)) return;
+    const self = this as unknown as Partial>;
+    const value = self[name];
+    delete self[name];
+    self[name] = value;
+  }
+
+  #clearTimer(): void {
+    if (this.#timer !== undefined) clearTimeout(this.#timer);
+    this.#timer = undefined;
+  }
+
+  #evaluate(): void {
+    if (this.force) {
+      if (this.#state === "draining" || this.#state === "hiding") {
+        this.#resume();
+      } else if (this.#state !== "showing") {
+        this.#show();
+      }
+      return;
+    }
+
+    if (this.busy) {
+      if (this.#state === "idle") {
+        const delay = this.delay;
+        if (delay === 0) {
+          this.#show();
+        } else {
+          this.#state = "pending";
+          this.#timer = setTimeout(() => this.#show(), delay);
+        }
+      } else if (this.#state === "draining" || this.#state === "hiding") {
+        this.#resume();
+      }
+      return;
+    }
+
+    if (this.#state === "pending") {
+      this.#clearTimer();
+      this.#state = "idle";
+    } else if (this.#state === "showing" || this.#state === "draining") {
+      // A reconnected draining element (its timer was cleared by
+      // disconnectedCallback) reschedules the remainder of its original
+      // min-duration window rather than a fresh one. "hiding" is left out of
+      // this branch on purpose: that hide is already in flight.
+      this.#clearTimer();
+      const remaining = this.#shownAt + this.minDuration - Date.now();
+      if (remaining <= 0) {
+        this.#hide();
+      } else {
+        this.#state = "draining";
+        this.#timer = setTimeout(() => this.#hide(), remaining);
+      }
+    }
+  }
+
+  // Bones never left the screen, so this is not a fresh show: no bones:show,
+  // and shownAt keeps its original value so min-duration still counts from
+  // the first show. The output attributes are written again because whatever
+  // stripped them (React, see attributeChangedCallback) can have run while
+  // the state was "hiding", where the element does not defend them.
+  #resume(): void {
+    this.#clearTimer();
+    this.#state = "showing";
+    this.#writeOutput(true);
+  }
+
+  #show(): void {
+    this.#clearTimer();
+    this.#state = "showing";
+    this.#shownAt = Date.now();
+    this.#writeOutput(true);
+    this.dispatchEvent(new CustomEvent("bones:show", { bubbles: true, composed: true }));
+  }
+
+  #hide(): void {
+    this.#clearTimer();
+    this.#state = "hiding";
+    const token = ++this.#hideToken;
+    const update = (): void => {
+      // A real startViewTransition runs this a frame or more later. Anything
+      // that happened in between (busy set again, the element removed, a
+      // newer hide started) has already moved the state off "hiding" or
+      // advanced the token, and this stale update must not undo it.
+      if (this.#state !== "hiding" || token !== this.#hideToken) return;
+      this.#state = "idle";
+      this.#writeOutput(false);
+      this.dispatchEvent(new CustomEvent("bones:hide", { bubbles: true, composed: true }));
+    };
+    if (this.#canTransition()) {
+      const transition = document.startViewTransition(update) as
+        | Partial
+        | undefined;
+      // A transition that a newer one supersedes rejects `ready` with
+      // AbortError, and that is the normal case when two boundaries hide in
+      // the same frame. Nothing here can act on it, so swallow the rejections
+      // rather than log one per overlapping hide.
+      transition?.ready?.catch(swallow);
+      transition?.updateCallbackDone?.catch(swallow);
+      transition?.finished?.catch(swallow);
+    } else {
+      update();
+    }
+  }
+
+  // The element's own writes to its output attributes, flagged while they run
+  // so the attributeChangedCallback that defends those attributes ignores
+  // them and cannot recurse into itself.
+  #writeOutput(shown: boolean): void {
+    this.#writingOutput = true;
+    try {
+      if (shown) {
+        this.setAttribute("aria-busy", "true");
+        this.toggleAttribute("inert", true);
+      } else {
+        this.removeAttribute("aria-busy");
+        this.removeAttribute("inert");
+      }
+    } finally {
+      this.#writingOutput = false;
+    }
+  }
+
+  // Showing never animates: a crossfade into a skeleton only delays the
+  // loading state. Hiding animates when the page allows it.
+  #canTransition(): boolean {
+    if (this.transition === "none") return false;
+    if (typeof document.startViewTransition !== "function") return false;
+    if (
+      typeof window !== "undefined" &&
+      typeof window.matchMedia === "function" &&
+      window.matchMedia("(prefers-reduced-motion: reduce)").matches
+    ) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/packages/bones/src/element/index.ts b/packages/bones/src/element/index.ts
new file mode 100644
index 0000000..eb083ff
--- /dev/null
+++ b/packages/bones/src/element/index.ts
@@ -0,0 +1,31 @@
+import { BonesBoundary } from "./boundary.ts";
+
+export { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION } from "./boundary.ts";
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "bones-boundary": BonesBoundary;
+  }
+  interface HTMLElementEventMap {
+    "bones:show": CustomEvent;
+    "bones:hide": CustomEvent;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Registration on import
+//
+// `import "@camp.dev/bones/element"` is the whole setup. The guard keeps the
+// module importable in Node (no customElements) and safe to import twice.
+// A tag that is already taken means two copies of this package on the page,
+// or someone else's element under the same name, and the boundaries in the
+// losing copy silently do nothing. That is worth a line in the console.
+// ---------------------------------------------------------------------------
+
+if (typeof customElements !== "undefined") {
+  if (customElements.get("bones-boundary")) {
+    console.warn("bones-boundary is already defined; @camp.dev/bones/element did not register");
+  } else {
+    customElements.define("bones-boundary", BonesBoundary);
+  }
+}
diff --git a/packages/bones/src/react/boundary.ts b/packages/bones/src/react/boundary.ts
new file mode 100644
index 0000000..2d45d8b
--- /dev/null
+++ b/packages/bones/src/react/boundary.ts
@@ -0,0 +1,82 @@
+import { createElement, type HTMLAttributes, type ReactNode, type Ref } from "react";
+// A type-only import: it is erased at build time, so the rule that src/react
+// never depends on src/element at runtime still holds. The element module is
+// what registers the tag, and this file must not pull it into a bundle.
+import type { BonesBoundary as BonesBoundaryElement } from "../element/boundary.ts";
+
+// ---------------------------------------------------------------------------
+//  — typed wrapper for the  element
+//
+// No hooks, so it renders inside server components like  does. The
+// attribute names are spelled out because React 19 serializes unknown props
+// on a custom element by their prop name, and the element observes
+// `min-duration`, not `minDuration`. Event props use React 19's rule for
+// custom elements: an `on*` prop becomes addEventListener with the rest of
+// the name as the event type. The element is registered by importing
+// `@camp.dev/bones/element` once in a client entry; this file does not do it.
+// ---------------------------------------------------------------------------
+
+// Everything a plain 
accepts, plus the element's own attributes. A +// boundary wraps real content, so `role`, `hidden`, `tabIndex`, `onClick`, and +// the rest of the aria-* set all have reasons to appear on it. +interface ElementAttributes extends HTMLAttributes { + busy?: boolean; + force?: boolean; + delay?: number; + "min-duration"?: number; + transition?: "auto" | "none"; + // React maps `className` to class on custom elements too, but a raw + // written by hand reads better with the HTML name. + class?: string; + ref?: Ref; + [dataAttribute: `data-${string}`]: string | number | boolean | undefined; +} + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "bones-boundary": ElementAttributes; + } + } +} + +export interface BonesBoundaryProps extends Omit< + ElementAttributes, + "min-duration" | "inert" | "aria-busy" | "class" +> { + minDuration?: number; + onShow?: (event: CustomEvent) => void; + onHide?: (event: CustomEvent) => void; +} + +export function BonesBoundary({ + busy, + force, + delay, + minDuration, + transition, + onShow, + onHide, + children, + ...rest +}: BonesBoundaryProps): ReactNode { + return createElement( + "bones-boundary", + { + ...rest, + busy: busy ? true : undefined, + force: force ? true : undefined, + delay, + "min-duration": minDuration, + transition, + ...(force ? { "aria-busy": "true", inert: true } : {}), + // The element writes aria-busy and inert itself, and it can have done so + // before hydration reaches this subtree. React must not treat those + // attributes as a mismatch against the server HTML and warn about them. + suppressHydrationWarning: true, + "onbones:show": onShow, + "onbones:hide": onHide, + }, + children, + ); +} diff --git a/packages/bones/src/react/index.ts b/packages/bones/src/react/index.ts index 69026b6..a072296 100644 --- a/packages/bones/src/react/index.ts +++ b/packages/bones/src/react/index.ts @@ -1,5 +1,7 @@ export { createBones, readPromise, forceBones, minMax, isMinMax } from "./create-bones.ts"; export { Bones, BonesForce } from "./bones.ts"; +export { BonesBoundary } from "./boundary.ts"; +export type { BonesBoundaryProps } from "./boundary.ts"; export type { BoneType, BoneOptions, diff --git a/packages/bones/tests/boundary-react.test.tsx b/packages/bones/tests/boundary-react.test.tsx new file mode 100644 index 0000000..7a17bd6 --- /dev/null +++ b/packages/bones/tests/boundary-react.test.tsx @@ -0,0 +1,152 @@ +import { act, cleanup, render } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import "../src/element/index.ts"; +import { BonesBoundary } from "../src/react/index.ts"; + +// --------------------------------------------------------------------------- +// is a hook-free wrapper. It maps camelCase props to the +// element's attribute names so server output drives the element before +// hydration, and relies on React 19's custom-element handling for the rest. +// --------------------------------------------------------------------------- + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("server rendering", () => { + test("maps props to attribute names", () => { + const html = renderToString( + +

copy

+
, + ); + expect(html).toContain("copy

"); + }); + + test("busy=false emits no busy attribute", () => { + const html = renderToString(x); + expect(html).not.toContain("busy"); + }); + + test("force emits aria-busy and inert so the server paints bones", () => { + const html = renderToString(x); + expect(html).toContain('force=""'); + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('inert=""'); + }); + + test("className and data attributes pass through", () => { + const html = renderToString( + + x + , + ); + expect(html).toContain('class="card"'); + expect(html).toContain('data-testid="b"'); + }); + + test("standard HTML and ARIA props type-check and reach the element", () => { + const html = renderToString( + {}}> + x + , + ); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-label="Loading"'); + }); + + test("suppressHydrationWarning never reaches the markup", () => { + const html = renderToString(x); + expect(html.toLowerCase()).not.toContain("suppresshydrationwarning"); + }); + + test("raw type-checks with the augmented intrinsic element", () => { + const html = renderToString( + + x + , + ); + expect(html).toContain('min-duration="0"'); + }); +}); + +describe("client rendering", () => { + test("busy drives the element and onShow/onHide receive the events", () => { + vi.useFakeTimers(); + const onShow = vi.fn(); + const onHide = vi.fn(); + const { container, rerender } = render( + +

copy

+
, + ); + const el = container.querySelector("bones-boundary")!; + expect(el.busy).toBe(true); + expect(el.getAttribute("aria-busy")).toBeNull(); + act(() => { + vi.advanceTimersByTime(10); + }); + expect(el.getAttribute("aria-busy")).toBe("true"); + expect(onShow).toHaveBeenCalledTimes(1); + expect(onShow.mock.calls[0][0]).toBeInstanceOf(CustomEvent); + rerender( + +

copy

+
, + ); + expect(el.getAttribute("aria-busy")).toBeNull(); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + test("the output attributes survive a force to busy rerender", () => { + vi.useFakeTimers(); + const onHide = vi.fn(); + const { container, rerender } = render( + +

copy

+
, + ); + const el = container.querySelector("bones-boundary")!; + expect(el.getAttribute("aria-busy")).toBe("true"); + expect(el.hasAttribute("inert")).toBe(true); + // React deletes the aria-busy and inert it rendered for `force` before it + // applies `busy`, so the element has to put them back. + rerender( + +

copy

+
, + ); + expect(el.getAttribute("aria-busy")).toBe("true"); + expect(el.hasAttribute("inert")).toBe(true); + expect(el.showing).toBe(true); + expect(onHide).not.toHaveBeenCalled(); + rerender( + +

copy

+
, + ); + act(() => { + vi.advanceTimersByTime(400); + }); + expect(el.getAttribute("aria-busy")).toBeNull(); + expect(el.hasAttribute("inert")).toBe(false); + expect(el.showing).toBe(false); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + test("minDuration reaches the element as the min-duration attribute", () => { + const { container } = render(x); + const el = container.querySelector("bones-boundary")!; + expect(el.getAttribute("min-duration")).toBe("25"); + expect(el.minDuration).toBe(25); + }); +}); diff --git a/packages/bones/tests/boundary.test.ts b/packages/bones/tests/boundary.test.ts new file mode 100644 index 0000000..5cede20 --- /dev/null +++ b/packages/bones/tests/boundary.test.ts @@ -0,0 +1,639 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { BonesBoundary } from "../src/element/index.ts"; + +// --------------------------------------------------------------------------- +// state machine +// +// The element sets aria-busy and inert as output; busy and force are input. +// Fake timers drive delay and min-duration, and Date.now() is faked with them, +// so shownAt math is deterministic. +// --------------------------------------------------------------------------- + +function mount(attrs: Record = {}): BonesBoundary { + const el = document.createElement("bones-boundary"); + for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value); + el.innerHTML = "

copy

"; + document.body.append(el); + return el; +} + +function events(el: HTMLElement): string[] { + const log: string[] = []; + el.addEventListener("bones:show", () => log.push("show")); + el.addEventListener("bones:hide", () => log.push("hide")); + return log; +} + +function shown(el: HTMLElement): boolean { + return el.getAttribute("aria-busy") === "true" && el.hasAttribute("inert"); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + document.body.innerHTML = ""; + vi.useRealTimers(); +}); + +describe("registration and reflection", () => { + test("importing the module registers bones-boundary", () => { + expect(customElements.get("bones-boundary")).toBe(BonesBoundary); + expect(mount()).toBeInstanceOf(BonesBoundary); + }); + + test("boolean properties reflect to attributes", () => { + const el = mount(); + el.busy = true; + expect(el.hasAttribute("busy")).toBe(true); + el.busy = false; + expect(el.hasAttribute("busy")).toBe(false); + el.force = true; + expect(el.hasAttribute("force")).toBe(true); + el.force = false; + expect(el.hasAttribute("force")).toBe(false); + // React 19 assigns null when a prop is removed. + el.busy = null as unknown as boolean; + expect(el.hasAttribute("busy")).toBe(false); + }); + + test("numeric properties parse attributes and fall back to defaults", () => { + const el = mount(); + expect(el.delay).toBe(200); + expect(el.minDuration).toBe(400); + el.setAttribute("delay", "50"); + expect(el.delay).toBe(50); + el.minDuration = 10; + expect(el.getAttribute("min-duration")).toBe("10"); + expect(el.minDuration).toBe(10); + for (const bad of ["abc", "-1", "Infinity", ""]) { + el.setAttribute("delay", bad); + expect(el.delay).toBe(200); + } + el.delay = null as unknown as number; + expect(el.hasAttribute("delay")).toBe(false); + expect(el.delay).toBe(200); + }); + + test("a property assigned before upgrade is replayed through the setter", () => { + const el = document.createElement("bones-boundary"); + // What `boundary.busy = true` leaves behind when the page runs it before + // the element module loads: an own data property that shadows the + // prototype accessor for good once the element upgrades. + Object.defineProperty(el, "busy", { + value: true, + writable: true, + configurable: true, + enumerable: true, + }); + document.body.append(el); + expect(Object.prototype.hasOwnProperty.call(el, "busy")).toBe(false); + expect(el.hasAttribute("busy")).toBe(true); + expect(el.busy).toBe(true); + vi.advanceTimersByTime(200); + expect(shown(el)).toBe(true); + }); + + test("transition reads auto unless the attribute is none", () => { + const el = mount(); + expect(el.transition).toBe("auto"); + el.setAttribute("transition", "anything"); + expect(el.transition).toBe("auto"); + el.transition = "none"; + expect(el.getAttribute("transition")).toBe("none"); + el.transition = "auto"; + expect(el.hasAttribute("transition")).toBe(false); + }); +}); + +describe("delay", () => { + test("busy shows bones after the delay with aria-busy, inert, and one bones:show", () => { + const el = mount(); + const log = events(el); + el.busy = true; + expect(shown(el)).toBe(false); + expect(el.showing).toBe(false); + vi.advanceTimersByTime(199); + expect(shown(el)).toBe(false); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(true); + expect(el.showing).toBe(true); + expect(log).toEqual(["show"]); + }); + + test("busy cleared during the delay never shows and fires nothing", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(100); + el.busy = false; + vi.advanceTimersByTime(1000); + expect(shown(el)).toBe(false); + expect(log).toEqual([]); + }); + + test("a busy attribute present at connect starts the delay from connect", () => { + const el = mount({ busy: "" }); + vi.advanceTimersByTime(199); + expect(shown(el)).toBe(false); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(true); + }); + + test("a custom delay attribute is honored", () => { + const el = mount({ delay: "50" }); + el.busy = true; + vi.advanceTimersByTime(49); + expect(shown(el)).toBe(false); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(true); + }); + + test("delay=0 shows synchronously", () => { + const el = mount({ delay: "0" }); + el.busy = true; + expect(shown(el)).toBe(true); + }); +}); + +describe("min-duration", () => { + test("bones stay until min-duration has elapsed, then bones:hide fires", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(100); + el.busy = false; + expect(shown(el)).toBe(true); + expect(el.showing).toBe(true); + vi.advanceTimersByTime(299); + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(false); + expect(el.getAttribute("aria-busy")).toBeNull(); + expect(el.hasAttribute("inert")).toBe(false); + expect(el.showing).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("clearing busy after min-duration hides at once", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200 + 400); + el.busy = false; + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("busy set again while draining cancels the hide without a second show", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); + el.busy = false; + vi.advanceTimersByTime(100); + el.busy = true; + vi.advanceTimersByTime(1000); + expect(shown(el)).toBe(true); + expect(log).toEqual(["show"]); + }); + + test("min-duration=0 hides at once", () => { + const el = mount({ "min-duration": "0" }); + el.busy = true; + vi.advanceTimersByTime(200); + el.busy = false; + expect(shown(el)).toBe(false); + }); +}); + +describe("force", () => { + test("force shows at once and ignores delay", () => { + const el = mount(); + const log = events(el); + el.force = true; + expect(shown(el)).toBe(true); + expect(log).toEqual(["show"]); + }); + + test("force in markup shows at connect", () => { + const el = mount({ force: "" }); + expect(shown(el)).toBe(true); + }); + + test("force set during the delay cancels the timer and fires one bones:show", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(100); + el.force = true; + expect(shown(el)).toBe(true); + // The delay timer would have fired at t=200 and shown a second time. + vi.advanceTimersByTime(1000); + expect(shown(el)).toBe(true); + expect(log).toEqual(["show"]); + }); + + test("force set while draining keeps bones showing", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); + el.busy = false; + el.force = true; + vi.advanceTimersByTime(1000); + expect(shown(el)).toBe(true); + expect(log).toEqual(["show"]); + }); + + test("clearing force with busy absent hides after min-duration counted from the force", () => { + const el = mount(); + el.force = true; + vi.advanceTimersByTime(100); + el.force = false; + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(299); + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(false); + }); + + test("clearing force with busy set keeps showing", () => { + const el = mount(); + el.busy = true; + el.force = true; + vi.advanceTimersByTime(1000); + el.force = false; + vi.advanceTimersByTime(1000); + expect(shown(el)).toBe(true); + }); +}); + +describe("output attribute defense", () => { + test("attributes stripped while bones show are put back", () => { + const el = mount({ delay: "0" }); + const log = events(el); + el.busy = true; + el.removeAttribute("aria-busy"); + expect(el.getAttribute("aria-busy")).toBe("true"); + el.removeAttribute("inert"); + expect(shown(el)).toBe(true); + // Draining counts as on screen too: the bones are still painted. + el.busy = false; + expect(el.showing).toBe(true); + el.removeAttribute("aria-busy"); + el.removeAttribute("inert"); + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(400); + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("the element leaves alone attributes it did not set", () => { + const el = mount({ delay: "0", "min-duration": "0" }); + // Idle: the author owns these attributes, so neither setting nor + // removing them by hand pulls the element into a showing state. + el.setAttribute("aria-busy", "true"); + el.toggleAttribute("inert", true); + expect(el.showing).toBe(false); + el.removeAttribute("aria-busy"); + el.removeAttribute("inert"); + expect(el.hasAttribute("aria-busy")).toBe(false); + expect(el.hasAttribute("inert")).toBe(false); + // And the element's own hide is not something it defends against. + el.busy = true; + expect(shown(el)).toBe(true); + el.busy = false; + expect(shown(el)).toBe(false); + expect(el.showing).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// View transitions. jsdom has neither startViewTransition nor matchMedia, so +// each test installs what it needs and the afterEach removes it. +// --------------------------------------------------------------------------- + +// Omit + intersect rather than plain intersect: this lib's DOM types already +// declare startViewTransition/matchMedia with stricter, non-optional +// signatures, so a plain intersection would merge the real property type +// with the mock's and reject the mock as unassignable. +type TransitionDoc = Omit & { + startViewTransition?: (update: () => void) => unknown; +}; +type MediaWindow = Omit & { + matchMedia?: (query: string) => { matches: boolean }; +}; + +function stubTransition() { + const fn = vi.fn((update: () => void) => { + update(); + return {}; + }); + (document as TransitionDoc).startViewTransition = fn; + return fn; +} + +function stubReducedMotion(matches: boolean) { + (window as MediaWindow).matchMedia = vi.fn(() => ({ matches })); +} + +// A real startViewTransition runs its update callback on a later frame. This +// stub captures the callback instead of running it, so a test can act inside +// that window and then let the stale update run. +function captureTransition(): () => void { + let captured = (): void => {}; + (document as TransitionDoc).startViewTransition = (update: () => void) => { + captured = update; + return {}; + }; + return () => captured(); +} + +describe("view transitions", () => { + afterEach(() => { + delete (document as TransitionDoc).startViewTransition; + delete (window as MediaWindow).matchMedia; + }); + + test("hide runs inside startViewTransition and show does not", () => { + const fn = stubTransition(); + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); + expect(fn).not.toHaveBeenCalled(); + vi.advanceTimersByTime(400); + el.busy = false; + expect(fn).toHaveBeenCalledTimes(1); + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("bones:hide fires inside the update callback", () => { + const order: string[] = []; + const fn = vi.fn((update: () => void) => { + order.push("before"); + update(); + order.push("after"); + return {}; + }); + (document as TransitionDoc).startViewTransition = fn; + const el = mount({ delay: "0", "min-duration": "0" }); + el.addEventListener("bones:hide", () => order.push("hide")); + el.busy = true; + el.busy = false; + expect(order).toEqual(["before", "hide", "after"]); + }); + + test("transition=none skips startViewTransition", () => { + const fn = stubTransition(); + const el = mount({ delay: "0", "min-duration": "0", transition: "none" }); + el.busy = true; + el.busy = false; + expect(fn).not.toHaveBeenCalled(); + expect(shown(el)).toBe(false); + }); + + test("reduced motion skips startViewTransition", () => { + const fn = stubTransition(); + stubReducedMotion(true); + const el = mount({ delay: "0", "min-duration": "0" }); + el.busy = true; + el.busy = false; + expect(fn).not.toHaveBeenCalled(); + expect(shown(el)).toBe(false); + }); + + test("a matchMedia that does not match still allows the transition", () => { + const fn = stubTransition(); + stubReducedMotion(false); + const el = mount({ delay: "0", "min-duration": "0" }); + el.busy = true; + el.busy = false; + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("busy set again before the update runs keeps bones and fires no second show", () => { + const run = captureTransition(); + const el = mount({ delay: "0", "min-duration": "0" }); + const log = events(el); + el.busy = true; + el.busy = false; + expect(el.showing).toBe(true); + expect(shown(el)).toBe(true); + el.busy = true; + run(); + expect(shown(el)).toBe(true); + expect(el.showing).toBe(true); + expect(log).toEqual(["show"]); + }); + + test("an element removed before the update runs fires no bones:hide", () => { + const run = captureTransition(); + const el = mount({ delay: "0", "min-duration": "0" }); + const log = events(el); + el.busy = true; + el.busy = false; + el.remove(); + run(); + expect(log).toEqual(["show"]); + expect(el.getAttribute("aria-busy")).toBe("true"); + expect(el.hasAttribute("inert")).toBe(true); + }); + + test("an uninterrupted update still hides and fires bones:hide", () => { + const run = captureTransition(); + const el = mount({ delay: "0", "min-duration": "0" }); + const log = events(el); + el.busy = true; + el.busy = false; + expect(shown(el)).toBe(true); + run(); + expect(shown(el)).toBe(false); + expect(el.showing).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("an element moved while its hide is queued fires no second bones:show and hides once", () => { + const updates: Array<() => void> = []; + (document as TransitionDoc).startViewTransition = (update: () => void) => { + updates.push(update); + return {}; + }; + const el = mount({ delay: "0", "min-duration": "0" }); + const log = events(el); + el.busy = true; + el.busy = false; + const other = document.createElement("div"); + document.body.append(other); + other.append(el); + expect(log).toEqual(["show"]); + expect(el.showing).toBe(true); + expect(updates).toHaveLength(2); + updates[0](); + expect(shown(el)).toBe(true); + expect(log).toEqual(["show"]); + updates[1](); + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("rejected ViewTransition promises get handlers", async () => { + const ready = Promise.reject(new Error("AbortError")); + const updateCallbackDone = Promise.resolve(); + const finished = Promise.reject(new Error("AbortError")); + const spies = [ready, updateCallbackDone, finished].map((p) => vi.spyOn(p, "catch")); + (document as TransitionDoc).startViewTransition = (update: () => void) => { + update(); + return { ready, updateCallbackDone, finished }; + }; + const el = mount({ delay: "0", "min-duration": "0" }); + el.busy = true; + el.busy = false; + for (const spy of spies) expect(spy).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(shown(el)).toBe(false); + }); + + test("hide completes when startViewTransition is absent", () => { + const el = mount({ delay: "0", "min-duration": "0" }); + const log = events(el); + el.busy = true; + el.busy = false; + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); +}); + +describe("server-rendered state", () => { + test("an element connected with aria-busy=true adopts showing", () => { + const el = document.createElement("bones-boundary"); + el.setAttribute("busy", ""); + el.setAttribute("aria-busy", "true"); + el.setAttribute("inert", ""); + document.body.append(el); + expect(el.showing).toBe(true); + expect(shown(el)).toBe(true); + }); + + test("min-duration counts from connect for adopted state", () => { + const el = document.createElement("bones-boundary"); + el.setAttribute("busy", ""); + el.setAttribute("aria-busy", "true"); + document.body.append(el); + el.busy = false; + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(399); + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(false); + }); + + test("aria-busy without busy or force still adopts, then hides after min-duration", () => { + const el = document.createElement("bones-boundary"); + el.setAttribute("aria-busy", "true"); + document.body.append(el); + expect(el.showing).toBe(true); + vi.advanceTimersByTime(400); + expect(shown(el)).toBe(false); + }); +}); + +describe("disconnect", () => { + test("removing the element clears timers and fires nothing", () => { + const el = mount(); + const log = events(el); + el.busy = true; + el.remove(); + vi.advanceTimersByTime(1000); + expect(shown(el)).toBe(false); + expect(log).toEqual([]); + }); + + test("removing a draining element leaves its attributes alone and fires nothing more", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); + el.busy = false; + el.remove(); + vi.advanceTimersByTime(1000); + expect(log).toEqual(["show"]); + expect(el.getAttribute("aria-busy")).toBe("true"); + expect(el.hasAttribute("inert")).toBe(true); + }); + + test("an element moved while pending restarts its delay", () => { + const el = mount(); + el.busy = true; + vi.advanceTimersByTime(100); + const other = document.createElement("div"); + document.body.append(other); + other.append(el); + vi.advanceTimersByTime(199); + expect(shown(el)).toBe(false); + vi.advanceTimersByTime(1); + expect(shown(el)).toBe(true); + }); + + test("a showing element that is moved keeps its bones and still hides", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); // shows at t=200, shownAt=200 + vi.advanceTimersByTime(100); // t=300, still showing (busy), 100ms since show + const other = document.createElement("div"); + document.body.append(other); + other.append(el); + // The move must not re-fire bones:show or reset the min-duration clock. + expect(shown(el)).toBe(true); + expect(log).toEqual(["show"]); + el.busy = false; // t=300: 300ms remain until 400ms since the original show + vi.advanceTimersByTime(299); // t=599, still short of t=600 + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(1); // t=600 = 400ms after the ORIGINAL show at t=200 + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); + + test("a draining element that is moved hides on its original schedule", () => { + const el = mount(); + const log = events(el); + el.busy = true; + vi.advanceTimersByTime(200); // shows at t=200, shownAt=200 + el.busy = false; // draining starts at t=200, scheduled to hide at t=600 + vi.advanceTimersByTime(350); // t=550, still draining, 50ms left + const other = document.createElement("div"); + document.body.append(other); + other.append(el); + vi.advanceTimersByTime(49); // t=599, still short of t=600 + expect(shown(el)).toBe(true); + vi.advanceTimersByTime(1); // t=600 = 400ms after the original show at t=200 + expect(shown(el)).toBe(false); + expect(log).toEqual(["show", "hide"]); + }); +}); + +describe("auto.css contract", () => { + test("a leaf inside a showing boundary matches the auto.css text-leaf selector", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const css = readFileSync(join(import.meta.dirname, "../src/css/auto.css"), "utf8"); + // Search past the file's header comment: its prose mentions the literal + // text `[aria-busy="true"]` before the first real rule does. + const layerStart = css.indexOf("@layer bones-auto"); + const start = css.indexOf('[aria-busy="true"]', layerStart); + const selector = css.slice(start, css.indexOf("{", start)).replace(/\s+/g, " ").trim(); + const el = mount({ delay: "0", "min-duration": "0" }); + const leaf = el.querySelector("p")!; + expect(leaf.matches(selector)).toBe(false); + el.busy = true; + expect(leaf.matches(selector)).toBe(true); + el.busy = false; + expect(leaf.matches(selector)).toBe(false); + }); +}); diff --git a/packages/bones/vite.config.ts b/packages/bones/vite.config.ts index e0c1f0e..8f41a84 100644 --- a/packages/bones/vite.config.ts +++ b/packages/bones/vite.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ "*": "vp check --fix", }, pack: { - entry: ["src/index.ts", "src/react/index.ts"], + entry: ["src/index.ts", "src/react/index.ts", "src/element/index.ts"], unbundle: true, copy: "src/css", dts: {