stimeo-ui 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/cable/index.js +23 -2
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/count_up_controller.d.ts +12 -10
  5. package/dist/controllers/count_up_controller.js +74 -35
  6. package/dist/controllers/count_up_controller.js.map +1 -1
  7. package/dist/controllers/intersection_controller.d.ts +20 -7
  8. package/dist/controllers/intersection_controller.js +55 -8
  9. package/dist/controllers/intersection_controller.js.map +1 -1
  10. package/dist/controllers/lazy_frame_controller.d.ts +31 -9
  11. package/dist/controllers/lazy_frame_controller.js +81 -19
  12. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  13. package/dist/controllers/pointer_drag_controller.js +4 -0
  14. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  15. package/dist/controllers/reading_progress_controller.d.ts +18 -9
  16. package/dist/controllers/reading_progress_controller.js +177 -6
  17. package/dist/controllers/reading_progress_controller.js.map +1 -1
  18. package/dist/controllers/scrollspy_controller.js +13 -2
  19. package/dist/controllers/scrollspy_controller.js.map +1 -1
  20. package/dist/controllers/smart_sticky_header_controller.d.ts +17 -8
  21. package/dist/controllers/smart_sticky_header_controller.js +52 -10
  22. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  23. package/dist/controllers/sortable_controller.d.ts +38 -8
  24. package/dist/controllers/sortable_controller.js +241 -67
  25. package/dist/controllers/sortable_controller.js.map +1 -1
  26. package/dist/controllers/sticky_observer_controller.js +13 -2
  27. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  28. package/dist/index.js +486 -145
  29. package/dist/index.js.map +1 -1
  30. package/dist/inspector/cli.js +1 -0
  31. package/dist/inspector/cli.js.map +1 -1
  32. package/dist/inspector/cli_bin.js +1 -0
  33. package/dist/inspector/cli_bin.js.map +1 -1
  34. package/dist/inspector/examples.json +1 -1
  35. package/dist/inspector/manifest.json +8 -28
  36. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/logical_scroll.ts","../../src/controllers/sortable_controller.ts"],"names":[],"mappings":"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","file":"sortable_controller.js","sourcesContent":["/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isRtl } from \"../utils/logical_scroll\";\n\n/** A reorder in flight: one item picked up by pointer or keyboard. */\ninterface SortSession {\n item: HTMLElement;\n /** Index at pickup, for the `reorder` detail and the cancel restore. */\n from: number;\n /** Last primary-axis cumulative delta consumed by keyboard stepping. */\n lastPrimary: number;\n}\n\n/**\n * Headless, accessible **sortable** (single-list reorder) — the a11y-first\n * drag-and-drop that is the flagship consumer of the\n * `stimeo--pointer-drag` primitive. The composition is markup-level and each\n * layer keeps its job: `pointer-drag` (on every item) emits the normalized drag\n * signal with its built-in keyboard alternative, `roving` (on the list) keeps\n * the handles a single Tab stop, and this controller interprets the signal —\n * live-reordering the DOM, announcing each step through a `status` live region,\n * and reporting the final `reorder`. No dedicated APG pattern exists for\n * drag-and-drop; the keyboard model (grab → arrows → drop / Escape) comes from\n * `pointer-drag` and the announcements make it non-visually trackable\n * (WCAG 2.1.1 / 2.5.7 / 4.1.3). Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--sortable`):\n * <div data-controller=\"stimeo--sortable\">\n * <ul data-stimeo--sortable-target=\"list\" data-controller=\"stimeo--roving\"\n * data-stimeo--roving-orientation-value=\"vertical\">\n * <li data-stimeo--sortable-target=\"item\" data-stimeo--sortable-name=\"Card A\"\n * data-controller=\"stimeo--pointer-drag\"\n * data-stimeo--pointer-drag-axis-value=\"y\">\n * <span>Card A</span>\n * <button type=\"button\" aria-label=\"Reorder Card A\"\n * data-stimeo--pointer-drag-target=\"handle\"\n * data-stimeo--roving-target=\"item\">⠿</button>\n * </li>\n * …\n * </ul>\n * <span role=\"status\" data-stimeo--sortable-target=\"status\"></span>\n * </div>\n *\n * The composed values must follow the sort axis: roving's `orientation`\n * (which defaults to `horizontal`, so a vertical list has to author\n * `vertical`) and pointer-drag's `axis`. `stimeo check` enforces both\n * alignments as composition rules (`composition-mismatch`).\n *\n * Pointer flow: dragging an item live-moves it whenever the pointer crosses a\n * sibling's midpoint (per `orientation`). Keyboard flow: each synthetic\n * `pointer-drag` move steps the item one position. A horizontal list under\n * `dir=\"rtl\"` reverses both, since DOM order then runs right-to-left while\n * `pointer-drag` reports physical coordinates. Dropping dispatches\n * `reorder` (`{ item, from, to }`, zero-based) when the position changed;\n * Escape / `pointercancel` restores the pickup position. Every step is mirrored\n * into the `status` live region (localizable via `data-grabbed` / `data-moved`\n * / `data-dropped` / `data-canceled` templates with `%{name}` / `%{position}` /\n * `%{total}` placeholders; terse English is the fallback).\n *\n * @remarks\n * Behavior only — the ghost/placeholder/drop-hint visuals are the consumer's\n * CSS, keyed off `pointer-drag`'s `data-dragging`/`data-grabbed` on the item\n * and this controller's `data-sortable-dragging` on the root. Persistence is\n * the consumer's `reorder` listener (post the new position; a Turbo Stream\n * broadcast makes the board collaborative). The DOM order is the single source\n * of truth: items are read live on every event, so rows appended by Turbo need\n * no rewiring, and a Turbo cache restore reconnects idempotently (`connect()`\n * clears the transient root hook; a drag cannot survive a navigation). Only the\n * event listeners are held, and they are removed on `disconnect()`. Multi-list\n * / kanban, nested DnD, auto-scroll at the edges, and virtualized lists are\n * deliberately out of this single-list scope.\n */\nexport class SortableController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"item\", \"status\"];\n static override values = {\n orientation: { type: String, default: \"vertical\" },\n };\n static events = [\"reorder\"] as const;\n\n declare readonly hasListTarget: boolean;\n declare readonly listTarget: HTMLElement;\n declare readonly itemTargets: HTMLElement[];\n declare readonly hasStatusTarget: boolean;\n declare readonly statusTarget: HTMLElement;\n declare orientationValue: string;\n\n #session: SortSession | null = null;\n\n override connect(): void {\n // A drag cannot survive a navigation: drop the hook a Turbo cache snapshot\n // may have preserved mid-drag (idempotent reconnect).\n this.element.removeAttribute(\"data-sortable-dragging\");\n this.element.addEventListener(\"stimeo--pointer-drag:start\", this.#onDragStart);\n this.element.addEventListener(\"stimeo--pointer-drag:move\", this.#onDragMove);\n this.element.addEventListener(\"stimeo--pointer-drag:end\", this.#onDragEnd);\n this.element.addEventListener(\"stimeo--pointer-drag:cancel\", this.#onDragCancel);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"stimeo--pointer-drag:start\", this.#onDragStart);\n this.element.removeEventListener(\"stimeo--pointer-drag:move\", this.#onDragMove);\n this.element.removeEventListener(\"stimeo--pointer-drag:end\", this.#onDragEnd);\n this.element.removeEventListener(\"stimeo--pointer-drag:cancel\", this.#onDragCancel);\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n }\n\n /** Picks the item up: remembers its origin and announces the grab. */\n readonly #onDragStart = (event: Event): void => {\n // One reorder at a time: a start from another item (each item has its own\n // pointer-drag instance) must not clobber the live session — its from\n // index and cancel restore would be lost.\n if (this.#session) return;\n const item = this.#itemFor(event.target);\n if (!item) return;\n this.#session = { item, from: this.#items().indexOf(item), lastPrimary: 0 };\n this.element.setAttribute(\"data-sortable-dragging\", \"true\");\n this.#announce(\"grabbed\", item);\n };\n\n readonly #onDragMove = (event: Event): void => {\n const session = this.#session;\n const detail = (event as CustomEvent<Record<string, number | string>>).detail;\n if (!session || this.#itemFor(event.target) !== session.item) return;\n\n if (detail.pointerType === \"keyboard\") {\n this.#stepFromKeyboard(session, detail);\n } else {\n this.#followPointer(session, detail);\n }\n };\n\n /** Drops the item: announces, then reports `reorder` if the position changed. */\n readonly #onDragEnd = (event: Event): void => {\n const session = this.#session;\n if (!session || this.#itemFor(event.target) !== session.item) return;\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n this.#announce(\"dropped\", session.item);\n const to = this.#items().indexOf(session.item);\n if (to !== session.from) {\n this.dispatch(\"reorder\", { detail: { item: session.item, from: session.from, to } });\n }\n };\n\n /** Restores the pickup position (Escape / OS `pointercancel`). */\n readonly #onDragCancel = (event: Event): void => {\n const session = this.#session;\n if (!session || this.#itemFor(event.target) !== session.item) return;\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n this.#moveTo(session.item, session.from);\n this.#announce(\"canceled\", session.item);\n };\n\n /**\n * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so\n * the difference from the last consumed value is one arrow press — its sign is\n * the direction. Cross-axis arrows never change the primary delta (no move).\n *\n * The sign is physical (`ArrowRight` is always `+dx`), so a right-to-left row\n * has to invert it: there, moving the item rightward means moving it *earlier*\n * in the DOM. Skipping this would also split the two halves of one keypress —\n * `roving` already moves focus logically, so the same arrow would send the\n * focus and the grabbed item opposite ways.\n */\n #stepFromKeyboard(session: SortSession, detail: Record<string, number | string>): void {\n const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;\n const delta = primary - session.lastPrimary;\n session.lastPrimary = primary;\n if (delta === 0) return;\n\n const items = this.#items();\n const index = items.indexOf(session.item);\n const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);\n const next = Math.max(0, Math.min(index + step, items.length - 1));\n if (next === index) return;\n this.#moveTo(session.item, next);\n this.#announce(\"moved\", session.item);\n }\n\n /**\n * Pointer following: the item moves to the slot whose siblings' midpoints the\n * pointer has passed (per `orientation`). Skipped when the list has no layout\n * geometry (every rect is zero — nothing meaningful to compare against).\n */\n #followPointer(session: SortSession, detail: Record<string, number | string>): void {\n const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;\n const others = this.#items().filter((item) => item !== session.item);\n if (others.length === 0) return;\n\n let laidOut = false;\n let target = 0;\n // `target` counts the siblings that precede the pointer *in DOM order*, and\n // the two agree only while DOM order runs the same way as the coordinate. In\n // a right-to-left row it runs the other way, so the comparison flips;\n // leaving it physical would mix two orderings into one number and drop the\n // item at a slot the pointer never crossed. Resolved once: it cannot change\n // mid-loop, and reading it per sibling would re-run `getComputedStyle` on\n // every pointermove.\n const reversed = this.#isReversed;\n for (const other of others) {\n const rect = other.getBoundingClientRect();\n if (rect.width > 0 || rect.height > 0) laidOut = true;\n const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;\n const precedes = reversed ? pointer < midpoint : pointer > midpoint;\n if (precedes) target += 1;\n }\n if (!laidOut) return;\n\n // The item's index among all items equals the count of others before it,\n // so it doubles as the current insertion slot in others-space.\n const current = this.#items().indexOf(session.item);\n if (target !== current) {\n this.#moveTo(session.item, target);\n this.#announce(\"moved\", session.item);\n }\n }\n\n /** Reinserts `item` so it lands at `index` among the list's items. */\n #moveTo(item: HTMLElement, index: number): void {\n const others = this.#items().filter((candidate) => candidate !== item);\n const clamped = Math.max(0, Math.min(index, others.length));\n const reference = others[clamped] ?? null;\n // Real browsers drop focus when the focused node is re-inserted (the move\n // is a remove+insert), which would strand the keyboard grab after one\n // arrow press — restore it so focus rides the moved item.\n const active = document.activeElement;\n const hadFocus = active instanceof HTMLElement && item.contains(active);\n this.#list.insertBefore(item, reference);\n if (hadFocus) active.focus();\n }\n\n /**\n * Mirrors a step into the `status` live region. Copy is localizable through\n * `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on\n * the status element (`%{name}` / `%{position}` / `%{total}` placeholders);\n * terse English is the fallback.\n */\n #announce(key: \"grabbed\" | \"moved\" | \"dropped\" | \"canceled\", item: HTMLElement): void {\n if (!this.hasStatusTarget) return;\n const position = String(this.#items().indexOf(item) + 1);\n const total = String(this.#items().length);\n const name = this.#nameOf(item);\n const fallback = {\n grabbed: `Grabbed ${name}, position ${position} of ${total}`,\n moved: `${name}, position ${position} of ${total}`,\n dropped: `Dropped ${name} at position ${position} of ${total}`,\n canceled: `Reorder canceled, ${name} returned to position ${position} of ${total}`,\n } as const;\n // Single-pass function replacement keeps the substitution literal-safe:\n // `$`-sequences in the (author/content-derived) name never expand, and a\n // name that happens to contain a placeholder token is not re-substituted.\n const values: Record<string, string> = { name, position, total };\n const template = this.statusTarget.dataset[key];\n this.statusTarget.textContent = template\n ? template.replace(/%\\{(name|position|total)\\}/g, (match, token: string) => {\n return values[token] ?? match;\n })\n : fallback[key];\n }\n\n /** The announced item name: the authored override, else its collapsed text. */\n #nameOf(item: HTMLElement): string {\n const authored = item.getAttribute(\"data-stimeo--sortable-name\");\n if (authored) return authored;\n return (item.textContent ?? \"\").replace(/\\s+/g, \" \").trim();\n }\n\n /** Resolves the sortable item owning a bubbled `pointer-drag` event. */\n #itemFor(target: EventTarget | null): HTMLElement | null {\n const node = target as Node | null;\n if (!node) return null;\n return this.#items().find((item) => item === node || item.contains(node)) ?? null;\n }\n\n /** The items in live DOM order (targets re-query the DOM on every access). */\n #items(): HTMLElement[] {\n return this.itemTargets;\n }\n\n /** The reorder container: the `list` target when present, else the element. */\n get #list(): HTMLElement {\n return this.hasListTarget ? this.listTarget : this.element;\n }\n\n get #isVertical(): boolean {\n return this.orientationValue !== \"horizontal\";\n }\n\n /**\n * Whether DOM order runs opposite to the primary coordinate — true only for a\n * horizontal row under `dir=\"rtl\"`, where the first item sits at the largest\n * `x`. Both drag paths reach this controller in physical terms (`pointer-drag`\n * documents its deltas as physical and hands RTL to its consumer, which is\n * this controller), so both have to be mapped back onto DOM order here.\n *\n * Read from the list, the element that lays the items out — never from the\n * dragged item, which may carry its own `dir`. The list inherits the computed\n * direction, so authoring `dir` on the root works too. A vertical list is\n * unaffected: writing direction does not mirror the block axis.\n */\n get #isReversed(): boolean {\n return !this.#isVertical && isRtl(this.#list);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/logical_scroll.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/sortable_controller.ts"],"names":[],"mappings":"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","file":"sortable_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","/** Normalized scroll position and maximum distance on one logical axis. */\nexport interface LogicalScrollMetrics {\n position: number;\n max: number;\n}\n\n/**\n * Whether horizontal scrolling on `element` follows right-to-left inline flow.\n *\n * Resolved from the **computed** `direction`, so the authoring contract is the\n * usual `dir=\"rtl\"` (or a stylesheet) on the element or any ancestor.\n *\n * Scope: horizontal writing modes. A vertical writing mode (`writing-mode:\n * vertical-rl`) also inverts the horizontal axis, which this check does not\n * model — vertical writing modes are out of scope for the scroll utilities\n * (their consumers describe axes as horizontal/vertical, not inline/block).\n */\nexport function isRtl(element: Element): boolean {\n return window.getComputedStyle(element).direction === \"rtl\";\n}\n\n/**\n * Returns scroll distance from the logical start edge.\n *\n * CSSOM View exposes standards-mode RTL horizontal offsets as `0` at the inline\n * start (right) and increasingly negative values toward the inline end (left).\n * The normalized position is always clamped to `[0, max]`, which also absorbs\n * Safari's elastic overscroll values.\n */\nexport function logicalScrollMetrics(\n element: HTMLElement,\n horizontal: boolean,\n): LogicalScrollMetrics {\n const max = Math.max(\n 0,\n horizontal\n ? element.scrollWidth - element.clientWidth\n : element.scrollHeight - element.clientHeight,\n );\n const raw = horizontal ? element.scrollLeft : element.scrollTop;\n const position = horizontal && isRtl(element) ? -raw : raw;\n return { position: Math.min(max, Math.max(0, position)), max };\n}\n\n/**\n * Converts a logical start/end delta to the physical value accepted by\n * `Element.scrollBy`.\n */\nexport function physicalScrollDelta(\n element: HTMLElement,\n horizontal: boolean,\n logicalDelta: number,\n): number {\n return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { isRtl } from \"../utils/logical_scroll\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** The four steps a consumer can give wording to. */\ntype AnnounceKey = \"grabbed\" | \"moved\" | \"dropped\" | \"canceled\";\n\n/** A reorder in flight: one item picked up by pointer or keyboard. */\ninterface SortSession {\n item: HTMLElement;\n /**\n * The item that followed it at pickup, and the one that preceded it.\n *\n * The pickup slot is remembered as nodes rather than as an index because the\n * list can change underneath a live drag — a broadcast board inserts and\n * deletes rows while someone is holding one — and an index taken at pickup\n * stops pointing at that place the moment a row appears above it.\n */\n anchor: HTMLElement | null;\n predecessor: HTMLElement | null;\n /** Last primary-axis cumulative delta consumed by keyboard stepping. */\n lastPrimary: number;\n}\n\n/**\n * Headless, accessible **sortable** (single-list reorder) — the a11y-first\n * drag-and-drop that is the flagship consumer of the\n * `stimeo--pointer-drag` primitive. The composition is markup-level and each\n * layer keeps its job: `pointer-drag` (on every item) emits the normalized drag\n * signal with its built-in keyboard alternative, `roving` (on the list) keeps\n * the handles a single Tab stop, and this controller interprets the signal —\n * live-reordering the DOM, handing each step to the page's shared announcer,\n * and reporting the final `reorder`. No dedicated APG pattern exists for\n * drag-and-drop; the keyboard model (grab → arrows → drop / Escape) comes from\n * `pointer-drag` and the announcements make it non-visually trackable\n * (WCAG 2.1.1 / 2.5.7 / 4.1.3). Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--sortable`):\n * <div data-controller=\"stimeo--sortable\">\n * <ul data-stimeo--sortable-target=\"list\" data-controller=\"stimeo--roving\"\n * data-stimeo--roving-orientation-value=\"vertical\">\n * <li data-stimeo--sortable-target=\"item\" data-stimeo--sortable-name=\"Card A\"\n * data-controller=\"stimeo--pointer-drag\"\n * data-stimeo--pointer-drag-axis-value=\"y\">\n * <span>Card A</span>\n * <button type=\"button\" aria-label=\"Reorder Card A\"\n * data-stimeo--pointer-drag-target=\"handle\"\n * data-stimeo--roving-target=\"item\">⠿</button>\n * </li>\n * …\n * </ul>\n * </div>\n *\n * The composed values must follow the sort axis: roving's `orientation`\n * (which defaults to `horizontal`, so a vertical list has to author\n * `vertical`) and pointer-drag's `axis`. `stimeo check` enforces both\n * alignments as composition rules (`composition-mismatch`).\n *\n * Pointer flow: dragging an item live-moves it whenever the pointer crosses a\n * sibling's midpoint (per `orientation`). Keyboard flow: each synthetic\n * `pointer-drag` move steps the item one position. A horizontal list under\n * `dir=\"rtl\"` reverses both, since DOM order then runs right-to-left while\n * `pointer-drag` reports physical coordinates. Dropping dispatches\n * `reorder` (`{ item, from, to }`, zero-based) when the position changed;\n * Escape / `pointercancel` restores the pickup position. Every step is handed to\n * the page's shared `stimeo--announcer` as the consumer's own wording —\n * `announceGrabbedText` / `announceMovedText` / `announceDroppedText` /\n * `announceCanceledText`, with `{name}` / `{position}` / `{total}` placeholders —\n * and an unset one announces nothing.\n *\n * @remarks\n * Behavior only — the ghost/placeholder/drop-hint visuals are the consumer's\n * CSS, keyed off `pointer-drag`'s `data-dragging`/`data-grabbed` on the item\n * and this controller's `data-sortable-dragging` on the root. Persistence is\n * the consumer's `reorder` listener (post the new position; a Turbo Stream\n * broadcast makes the board collaborative). The DOM order is the single source\n * of truth: items are read live on every event, so rows appended by Turbo need\n * no rewiring, and a Turbo cache restore reconnects idempotently (`connect()`\n * clears the transient root hook; a drag cannot survive a navigation). Only the\n * event listeners are held, and they are removed on `disconnect()`. Multi-list\n * / kanban, nested DnD, auto-scroll at the edges, and virtualized lists are\n * deliberately out of this single-list scope.\n */\nexport class SortableController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"item\"];\n static override values = {\n orientation: { type: String, default: \"vertical\" },\n announceGrabbedText: { type: String, default: \"\" },\n announceMovedText: { type: String, default: \"\" },\n announceDroppedText: { type: String, default: \"\" },\n announceCanceledText: { type: String, default: \"\" },\n };\n static events = [\"reorder\"] as const;\n\n declare readonly hasListTarget: boolean;\n declare readonly listTarget: HTMLElement;\n declare readonly itemTargets: HTMLElement[];\n declare orientationValue: string;\n declare announceGrabbedTextValue: string;\n declare announceMovedTextValue: string;\n declare announceDroppedTextValue: string;\n declare announceCanceledTextValue: string;\n\n #session: SortSession | null = null;\n /** Defers the lost-item check to after the mutation batch (see below). */\n readonly #settle = new MicrotaskCoalescer(() => this.#dropLostSession());\n\n override connect(): void {\n // A drag cannot survive a navigation: drop the hook a Turbo cache snapshot\n // may have preserved mid-drag (idempotent reconnect).\n this.element.removeAttribute(\"data-sortable-dragging\");\n this.#settle.activate();\n this.element.addEventListener(\"stimeo--pointer-drag:start\", this.#onDragStart);\n this.element.addEventListener(\"stimeo--pointer-drag:move\", this.#onDragMove);\n this.element.addEventListener(\"stimeo--pointer-drag:end\", this.#onDragEnd);\n this.element.addEventListener(\"stimeo--pointer-drag:cancel\", this.#onDragCancel);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"stimeo--pointer-drag:start\", this.#onDragStart);\n this.element.removeEventListener(\"stimeo--pointer-drag:move\", this.#onDragMove);\n this.element.removeEventListener(\"stimeo--pointer-drag:end\", this.#onDragEnd);\n this.element.removeEventListener(\"stimeo--pointer-drag:cancel\", this.#onDragCancel);\n this.#settle.cancel();\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n }\n\n /**\n * Ends a session whose item left the item set.\n *\n * The controller's own reorder detaches and reattaches the item inside one\n * mutation batch, so the loss is only real once the batch has settled — the\n * check therefore runs a microtask later and asks whether the item is a target\n * again. Without it a deleted row (a broadcast that drops it from the board)\n * or a morph that strips the item's target attribute would hold the\n * one-at-a-time session for the rest of the page's life, with the root hook\n * stuck on and every later grab refused with no way out.\n */\n itemTargetDisconnected(item: HTMLElement): void {\n if (this.#session?.item !== item) return;\n this.#settle.schedule();\n }\n\n #dropLostSession(): void {\n const session = this.#session;\n if (!session) return;\n const items = this.#items();\n if (items.includes(session.item)) return;\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n // An element that is still in the document (a morph took only its target\n // attribute) goes back where it was picked up. Nothing is announced either\n // way: the row is no longer one of the items, so there is no position to\n // read out — what matters is that the next grab is not refused.\n if (session.item.isConnected) this.#restore(session, items);\n }\n\n /** Picks the item up: remembers its neighbours and announces the grab. */\n readonly #onDragStart = (event: Event): void => {\n // One reorder at a time: a start from another item (each item has its own\n // pointer-drag instance) must not clobber the live session — its pickup\n // slot and cancel restore would be lost.\n if (this.#session) return;\n const items = this.#items();\n const item = this.#itemFor(event.target, items);\n if (!item) return;\n const index = items.indexOf(item);\n this.#session = {\n item,\n anchor: items[index + 1] ?? null,\n predecessor: items[index - 1] ?? null,\n lastPrimary: 0,\n };\n this.element.setAttribute(\"data-sortable-dragging\", \"true\");\n this.#announce(\"grabbed\", item);\n };\n\n readonly #onDragMove = (event: Event): void => {\n const session = this.#session;\n if (!session) return;\n const items = this.#items();\n if (this.#itemFor(event.target, items) !== session.item) return;\n\n const detail = (event as CustomEvent<Record<string, number | string>>).detail;\n if (detail.pointerType === \"keyboard\") {\n this.#stepFromKeyboard(session, detail, items);\n } else {\n this.#followPointer(session, detail, items);\n }\n };\n\n /** Drops the item: announces, then reports `reorder` if the position changed. */\n readonly #onDragEnd = (event: Event): void => {\n const session = this.#session;\n if (!session) return;\n const items = this.#items();\n if (this.#itemFor(event.target, items) !== session.item) return;\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n this.#announce(\"dropped\", session.item);\n const to = items.indexOf(session.item);\n const from = this.#pickupSlot(session, items) ?? to;\n if (to !== from) {\n this.dispatch(\"reorder\", { detail: { item: session.item, from, to } });\n }\n };\n\n /** Restores the pickup position (Escape / OS `pointercancel`). */\n readonly #onDragCancel = (event: Event): void => {\n const session = this.#session;\n if (!session) return;\n const items = this.#items();\n if (this.#itemFor(event.target, items) !== session.item) return;\n this.#session = null;\n this.element.removeAttribute(\"data-sortable-dragging\");\n this.#restore(session, items);\n this.#announce(\"canceled\", session.item);\n };\n\n /**\n * The slot the item was picked up from, read back through the neighbours it\n * had then — `null` when neither survives and the item itself is gone.\n *\n * The anchor is the item that followed it, so restoring means \"before that one\n * again\". When the anchor was deleted mid-drag the predecessor answers the\n * same question from the other side. With both gone the item's current slot is\n * the honest answer: nothing is known to have moved, so no reorder is reported\n * and a cancel leaves the item where it is.\n */\n #pickupSlot(session: SortSession, items: HTMLElement[]): number | null {\n const others = items.filter((candidate) => candidate !== session.item);\n if (session.anchor) {\n const at = others.indexOf(session.anchor);\n if (at !== -1) return at;\n }\n if (session.predecessor) {\n const at = others.indexOf(session.predecessor);\n if (at !== -1) return at + 1;\n }\n const here = items.indexOf(session.item);\n return here === -1 ? null : here;\n }\n\n /** Puts the item back where it was picked up from. */\n #restore(session: SortSession, items: HTMLElement[]): void {\n const slot = this.#pickupSlot(session, items);\n if (slot === null) return;\n this.#insertAt(\n session.item,\n items.filter((candidate) => candidate !== session.item),\n slot,\n );\n }\n\n /**\n * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so\n * the difference from the last consumed value is one arrow press — its sign is\n * the direction. Cross-axis arrows never change the primary delta (no move).\n *\n * The sign is physical (`ArrowRight` is always `+dx`), so a right-to-left row\n * has to invert it: there, moving the item rightward means moving it *earlier*\n * in the DOM. Skipping this would also split the two halves of one keypress —\n * `roving` already moves focus logically, so the same arrow would send the\n * focus and the grabbed item opposite ways.\n */\n #stepFromKeyboard(\n session: SortSession,\n detail: Record<string, number | string>,\n items: HTMLElement[],\n ): void {\n const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;\n const delta = primary - session.lastPrimary;\n session.lastPrimary = primary;\n if (delta === 0) return;\n\n const index = items.indexOf(session.item);\n const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);\n const next = Math.max(0, Math.min(index + step, items.length - 1));\n if (next === index) return;\n this.#insertAt(\n session.item,\n items.filter((candidate) => candidate !== session.item),\n next,\n );\n this.#announce(\"moved\", session.item);\n }\n\n /**\n * Pointer following: the item moves to the slot whose siblings' midpoints the\n * pointer has passed (per `orientation`).\n *\n * Only siblings that occupy space take part. A row with no layout box — a\n * filtered-out item, a `display: none` ancestor, a collapsed `<details>` — is\n * reported with an empty rect at the document origin, and its midpoint of `0`\n * sits below every pointer position: counted, it would read as passed on the\n * very first move and send the item across a slot the pointer never crossed.\n * With no laid-out sibling at all there is nothing to compare against and the\n * move is skipped entirely.\n */\n #followPointer(\n session: SortSession,\n detail: Record<string, number | string>,\n items: HTMLElement[],\n ): void {\n const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;\n const vertical = this.#isVertical;\n // `target` counts the siblings that precede the pointer *in DOM order*, and\n // the two agree only while DOM order runs the same way as the coordinate. In\n // a right-to-left row it runs the other way, so the comparison flips;\n // leaving it physical would mix two orderings into one number and drop the\n // item at a slot the pointer never crossed. Resolved once: it cannot change\n // mid-loop, and reading it per sibling would re-run `getComputedStyle` on\n // every pointermove.\n const reversed = this.#isReversed;\n const here = items.indexOf(session.item);\n const laidOut: HTMLElement[] = [];\n let target = 0;\n // `current` is the item's own slot in that same laid-out space: the number\n // of laid-out siblings that come before it in DOM order.\n let current = 0;\n items.forEach((item, index) => {\n if (item === session.item) return;\n const rect = item.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) return;\n if (index < here) current += 1;\n laidOut.push(item);\n const midpoint = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;\n if (reversed ? pointer < midpoint : pointer > midpoint) target += 1;\n });\n if (laidOut.length === 0 || target === current) return;\n\n this.#insertAt(session.item, laidOut, target);\n this.#announce(\"moved\", session.item);\n }\n\n /**\n * Reinserts `item` at `index` among `scope`, relative to the sibling already\n * standing there.\n *\n * The reorder is defined against the items, not against a container: the\n * neighbour's own parent is where the item belongs. A `list` target that is\n * not the items' parent — or none at all, which the markup contract allows —\n * therefore still lands the move in the right place instead of throwing, and\n * the last slot is *after the last item* rather than after whatever else the\n * container holds (a live region, a footer), which would put the row outside\n * the reading order the list publishes.\n */\n #insertAt(item: HTMLElement, scope: HTMLElement[], index: number): void {\n const clamped = Math.max(0, Math.min(index, scope.length));\n const ahead = scope[clamped] ?? null;\n const behind = ahead ? null : (scope[scope.length - 1] ?? null);\n const neighbour = ahead ?? behind;\n if (!neighbour) return;\n const parent = neighbour.parentNode;\n if (!parent) return;\n // Real browsers drop focus when the focused node is re-inserted (the move\n // is a remove+insert), which would strand the keyboard grab after one\n // arrow press — restore it so focus rides the moved item.\n const active = document.activeElement;\n const hadFocus = active instanceof HTMLElement && item.contains(active);\n parent.insertBefore(item, ahead ?? neighbour.nextSibling);\n if (hadFocus) active.focus();\n }\n\n /**\n * Hands one step to the page's shared announcer.\n *\n * The library carries no live region and no English copy: the wording is the\n * consumer's, written into `announceGrabbedText` / `announceMovedText` /\n * `announceDroppedText` / `announceCanceledText` with `{name}` / `{position}` /\n * `{total}` placeholders, and an unset one announces nothing.\n *\n * Only transitions reach here — the pickup, a step that actually changed the\n * landing slot, and the single end of the session — so a pointer crossing the\n * same slot twice or an arrow clamped at an end stays silent.\n */\n #announce(key: AnnounceKey, item: HTMLElement): void {\n const template = this.#announceTemplate(key);\n if (template.length === 0) return;\n const items = this.#items();\n announce(\n fillTemplate(template, {\n name: this.#nameOf(item),\n position: items.indexOf(item) + 1,\n total: items.length,\n }),\n );\n }\n\n /** The consumer's wording for one step, or `\"\"` when they authored none. */\n #announceTemplate(key: AnnounceKey): string {\n switch (key) {\n case \"grabbed\":\n return this.announceGrabbedTextValue;\n case \"moved\":\n return this.announceMovedTextValue;\n case \"dropped\":\n return this.announceDroppedTextValue;\n case \"canceled\":\n return this.announceCanceledTextValue;\n }\n }\n\n /** The announced item name: the authored override, else its collapsed text. */\n #nameOf(item: HTMLElement): string {\n const authored = item.getAttribute(\"data-stimeo--sortable-name\");\n if (authored) return authored;\n return (item.textContent ?? \"\").replace(/\\s+/g, \" \").trim();\n }\n\n /**\n * Resolves the sortable item owning a bubbled `pointer-drag` event.\n *\n * `pointer-drag` dispatches on its own element, and the markup contract puts\n * one on each item, so the owner is the item that **is** the target. Matching\n * an ancestor instead would make a card's own inner draggable — a knob, a\n * split pane, anything the primitive is composed into — drive the card.\n */\n #itemFor(target: EventTarget | null, items: HTMLElement[]): HTMLElement | null {\n if (!target) return null;\n return items.find((item) => item === target) ?? null;\n }\n\n /** The items in live DOM order (targets re-query the DOM on every access). */\n #items(): HTMLElement[] {\n return this.itemTargets;\n }\n\n /** The reorder container: the `list` target when present, else the element. */\n get #list(): HTMLElement {\n return this.hasListTarget ? this.listTarget : this.element;\n }\n\n get #isVertical(): boolean {\n return this.orientationValue !== \"horizontal\";\n }\n\n /**\n * Whether DOM order runs opposite to the primary coordinate — true only for a\n * horizontal row under `dir=\"rtl\"`, where the first item sits at the largest\n * `x`. Both drag paths reach this controller in physical terms (`pointer-drag`\n * documents its deltas as physical and hands RTL to its consumer, which is\n * this controller), so both have to be mapped back onto DOM order here.\n *\n * Read from the list, the element that lays the items out — never from the\n * dragged item, which may carry its own `dir`. The list inherits the computed\n * direction, so authoring `dir` on the root works too. A vertical list is\n * unaffected: writing direction does not mirror the block axis.\n */\n get #isReversed(): boolean {\n return !this.#isVertical && isRtl(this.#list);\n }\n}\n"]}
@@ -9,6 +9,14 @@ function isBeforeRootStart(entry) {
9
9
  const rootTop = entry.rootBounds?.top ?? 0;
10
10
  return rect.bottom <= rootTop;
11
11
  }
12
+ function queryRoot(selector) {
13
+ if (!selector) return null;
14
+ try {
15
+ return document.querySelector(selector);
16
+ } catch {
17
+ }
18
+ return null;
19
+ }
12
20
  var IntersectionWatcher = class {
13
21
  #onEntries;
14
22
  #observer = null;
@@ -30,7 +38,10 @@ var IntersectionWatcher = class {
30
38
  * the watcher inert — without `IntersectionObserver` support (very old
31
39
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
32
40
  * If initial construction with the configured options fails, the watcher
33
- * warns and retries once with the same root and platform defaults.
41
+ * warns and retries once with the same root and platform defaults. A
42
+ * `rootSelector` that does not parse resolves to the viewport (see
43
+ * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
44
+ * call.
34
45
  *
35
46
  * @throws The fallback constructor error if both construction attempts fail,
36
47
  * or whatever the platform throws from `observe()`. The exception is passed
@@ -43,7 +54,7 @@ var IntersectionWatcher = class {
43
54
  if (typeof IntersectionObserver === "undefined") return false;
44
55
  const list = Array.isArray(targets) ? targets : [targets];
45
56
  if (list.length === 0) return false;
46
- const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
57
+ const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
47
58
  let observer = null;
48
59
  try {
49
60
  const onEntries = (entries) => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":"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","file":"sticky_observer_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The observer's initial snapshot dispatches `change`\n * once with the current state, including after a Turbo reconnect; subsequent\n * notifications dispatch only when that state changes.\n *\n * `change` dispatches `{ stuck: boolean }`.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` is negated numerically for the\n * top `rootMargin` (so negative offsets remain valid) and must match the sticky\n * element's CSS `top`. The observer follows dynamic sentinel targets and value\n * changes, and is disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n /** Shared IO plumbing (support guard, root resolution, active guard). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n /** Target currently owned by the watcher; comparing it avoids duplicate restarts. */\n #observedSentinel: HTMLElement | null = null;\n #connected = false;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // Delivery can batch multiple transitions after a fast scroll. Process\n // every snapshot in order so an above→visible pair is not collapsed.\n for (const entry of entries) {\n if (!this.#connected || !this.#watcher.active) return;\n // A sentinel with no layout box (hidden tab panel, collapsed section, an\n // undisplayed Turbo Frame) reports an empty rect that the shared edge test\n // deliberately refuses, so an unrendered sticky element is never published\n // as stuck; the state stays put until the sentinel is actually laid out.\n this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n this.#connected = true;\n this.#stuck = null;\n this.#syncObserver();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#watcher.stop();\n this.#observedSentinel = null;\n this.#stuck = null;\n }\n\n /** Starts observation when a sentinel is inserted after connection. */\n sentinelTargetConnected(): void {\n if (this.#connected) this.#syncObserver();\n }\n\n /** Stops or transfers observation when the current sentinel is removed. */\n sentinelTargetDisconnected(): void {\n if (this.#connected) this.#syncObserver();\n }\n\n /** Reflects the last snapshot onto an element inserted after that snapshot. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#stuck !== null) {\n element.setAttribute(\"data-stuck\", this.#stuck ? \"true\" : \"false\");\n }\n }\n\n /** Rebuilds the observer when Turbo morphs the configured root. */\n rootSelectorValueChanged(): void {\n if (this.#connected) this.#syncObserver(true);\n }\n\n /** Rebuilds the observer when Turbo morphs the configured top offset. */\n offsetValueChanged(): void {\n if (this.#connected) this.#syncObserver(true);\n }\n\n #syncObserver(force = false): void {\n const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;\n if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;\n\n this.#watcher.stop();\n this.#observedSentinel = null;\n if (!sentinel) return;\n\n const configuredOffset = this.offsetValue;\n const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;\n const started = this.#watcher.start(sentinel, {\n rootSelector: this.rootSelectorValue,\n rootMargin: `${-offset}px 0px 0px 0px`,\n threshold: [0],\n });\n if (started) this.#observedSentinel = sentinel;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":"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","file":"sticky_observer_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n try {\n return document.querySelector(selector);\n } catch {\n // Unparsable selector: fall through to the viewport below.\n }\n return null;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The observer's initial snapshot dispatches `change`\n * once with the current state, including after a Turbo reconnect; subsequent\n * notifications dispatch only when that state changes.\n *\n * `change` dispatches `{ stuck: boolean }`.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` is negated numerically for the\n * top `rootMargin` (so negative offsets remain valid) and must match the sticky\n * element's CSS `top`. The observer follows dynamic sentinel targets and value\n * changes, and is disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n /** Shared IO plumbing (support guard, root resolution, active guard). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n /** Target currently owned by the watcher; comparing it avoids duplicate restarts. */\n #observedSentinel: HTMLElement | null = null;\n #connected = false;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // Delivery can batch multiple transitions after a fast scroll. Process\n // every snapshot in order so an above→visible pair is not collapsed.\n for (const entry of entries) {\n if (!this.#connected || !this.#watcher.active) return;\n // A sentinel with no layout box (hidden tab panel, collapsed section, an\n // undisplayed Turbo Frame) reports an empty rect that the shared edge test\n // deliberately refuses, so an unrendered sticky element is never published\n // as stuck; the state stays put until the sentinel is actually laid out.\n this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n this.#connected = true;\n this.#stuck = null;\n this.#syncObserver();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#watcher.stop();\n this.#observedSentinel = null;\n this.#stuck = null;\n }\n\n /** Starts observation when a sentinel is inserted after connection. */\n sentinelTargetConnected(): void {\n if (this.#connected) this.#syncObserver();\n }\n\n /** Stops or transfers observation when the current sentinel is removed. */\n sentinelTargetDisconnected(): void {\n if (this.#connected) this.#syncObserver();\n }\n\n /** Reflects the last snapshot onto an element inserted after that snapshot. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#stuck !== null) {\n element.setAttribute(\"data-stuck\", this.#stuck ? \"true\" : \"false\");\n }\n }\n\n /** Rebuilds the observer when Turbo morphs the configured root. */\n rootSelectorValueChanged(): void {\n if (this.#connected) this.#syncObserver(true);\n }\n\n /** Rebuilds the observer when Turbo morphs the configured top offset. */\n offsetValueChanged(): void {\n if (this.#connected) this.#syncObserver(true);\n }\n\n #syncObserver(force = false): void {\n const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;\n if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;\n\n this.#watcher.stop();\n this.#observedSentinel = null;\n if (!sentinel) return;\n\n const configuredOffset = this.offsetValue;\n const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;\n const started = this.#watcher.start(sentinel, {\n rootSelector: this.rootSelectorValue,\n rootMargin: `${-offset}px 0px 0px 0px`,\n threshold: [0],\n });\n if (started) this.#observedSentinel = sentinel;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}