stimeo-ui 0.12.0 → 0.13.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 (133) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cable/index.d.ts +67 -13
  3. package/dist/cable/index.js +438 -152
  4. package/dist/cable/index.js.map +1 -1
  5. package/dist/controllers/alert_dialog_controller.d.ts +1 -1
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  7. package/dist/controllers/announcer_controller.d.ts +1 -1
  8. package/dist/controllers/announcer_controller.js.map +1 -1
  9. package/dist/controllers/auto_submit_controller.js.map +1 -1
  10. package/dist/controllers/breadcrumb_controller.d.ts +1 -1
  11. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  12. package/dist/controllers/calendar_controller.d.ts +2 -1
  13. package/dist/controllers/calendar_controller.js +10 -2
  14. package/dist/controllers/calendar_controller.js.map +1 -1
  15. package/dist/controllers/carousel_controller.js +1 -1
  16. package/dist/controllers/carousel_controller.js.map +1 -1
  17. package/dist/controllers/character_counter_controller.js.map +1 -1
  18. package/dist/controllers/clipboard_controller.d.ts +1 -1
  19. package/dist/controllers/clipboard_controller.js.map +1 -1
  20. package/dist/controllers/collapsible_controller.js.map +1 -1
  21. package/dist/controllers/color_picker_controller.d.ts +1 -1
  22. package/dist/controllers/color_picker_controller.js.map +1 -1
  23. package/dist/controllers/command_palette_controller.d.ts +3 -3
  24. package/dist/controllers/command_palette_controller.js +2 -2
  25. package/dist/controllers/command_palette_controller.js.map +1 -1
  26. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  27. package/dist/controllers/confirm_controller.d.ts +2 -2
  28. package/dist/controllers/confirm_controller.js.map +1 -1
  29. package/dist/controllers/context_menu_controller.d.ts +1 -1
  30. package/dist/controllers/context_menu_controller.js.map +1 -1
  31. package/dist/controllers/countdown_controller.d.ts +1 -1
  32. package/dist/controllers/countdown_controller.js.map +1 -1
  33. package/dist/controllers/data_grid_controller.d.ts +1 -1
  34. package/dist/controllers/data_grid_controller.js.map +1 -1
  35. package/dist/controllers/date_range_picker_controller.js +10 -10
  36. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  37. package/dist/controllers/dialog_controller.d.ts +1 -1
  38. package/dist/controllers/dialog_controller.js.map +1 -1
  39. package/dist/controllers/direct_upload_controller.js.map +1 -1
  40. package/dist/controllers/dirty_form_controller.js.map +1 -1
  41. package/dist/controllers/dismissible_controller.js.map +1 -1
  42. package/dist/controllers/drawer_controller.d.ts +2 -2
  43. package/dist/controllers/drawer_controller.js +2 -2
  44. package/dist/controllers/drawer_controller.js.map +1 -1
  45. package/dist/controllers/dropdown_controller.d.ts +1 -1
  46. package/dist/controllers/dropdown_controller.js.map +1 -1
  47. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  48. package/dist/controllers/flash_controller.js.map +1 -1
  49. package/dist/controllers/focus_controller.d.ts +2 -2
  50. package/dist/controllers/focus_controller.js.map +1 -1
  51. package/dist/controllers/form_validation_controller.js.map +1 -1
  52. package/dist/controllers/frame_loading_controller.d.ts +3 -3
  53. package/dist/controllers/frame_loading_controller.js.map +1 -1
  54. package/dist/controllers/highlight_controller.js.map +1 -1
  55. package/dist/controllers/hover_card_controller.d.ts +1 -1
  56. package/dist/controllers/hover_card_controller.js.map +1 -1
  57. package/dist/controllers/idle_controller.d.ts +1 -1
  58. package/dist/controllers/idle_controller.js.map +1 -1
  59. package/dist/controllers/input_mask_controller.js.map +1 -1
  60. package/dist/controllers/intersection_controller.js.map +1 -1
  61. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  62. package/dist/controllers/listbox_controller.js.map +1 -1
  63. package/dist/controllers/local_time_controller.d.ts +1 -1
  64. package/dist/controllers/local_time_controller.js.map +1 -1
  65. package/dist/controllers/masonry_controller.d.ts +1 -1
  66. package/dist/controllers/masonry_controller.js.map +1 -1
  67. package/dist/controllers/menu_controller.d.ts +1 -1
  68. package/dist/controllers/menu_controller.js.map +1 -1
  69. package/dist/controllers/menubar_controller.d.ts +2 -2
  70. package/dist/controllers/menubar_controller.js.map +1 -1
  71. package/dist/controllers/navigation_menu_controller.d.ts +1 -1
  72. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  73. package/dist/controllers/nested_form_controller.js.map +1 -1
  74. package/dist/controllers/network_status_controller.js.map +1 -1
  75. package/dist/controllers/number_input_controller.js.map +1 -1
  76. package/dist/controllers/optimistic_controller.d.ts +23 -10
  77. package/dist/controllers/optimistic_controller.js +141 -20
  78. package/dist/controllers/optimistic_controller.js.map +1 -1
  79. package/dist/controllers/overflow_indicator_controller.d.ts +1 -1
  80. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  81. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  82. package/dist/controllers/pagination_controller.js.map +1 -1
  83. package/dist/controllers/password_reveal_controller.js.map +1 -1
  84. package/dist/controllers/password_strength_controller.d.ts +2 -1
  85. package/dist/controllers/password_strength_controller.js.map +1 -1
  86. package/dist/controllers/persist_controller.js.map +1 -1
  87. package/dist/controllers/popover_controller.d.ts +1 -1
  88. package/dist/controllers/popover_controller.js.map +1 -1
  89. package/dist/controllers/portal_controller.d.ts +2 -2
  90. package/dist/controllers/portal_controller.js.map +1 -1
  91. package/dist/controllers/radio_group_controller.d.ts +1 -1
  92. package/dist/controllers/radio_group_controller.js.map +1 -1
  93. package/dist/controllers/relative_time_controller.d.ts +3 -3
  94. package/dist/controllers/relative_time_controller.js.map +1 -1
  95. package/dist/controllers/roving_controller.d.ts +1 -1
  96. package/dist/controllers/roving_controller.js.map +1 -1
  97. package/dist/controllers/scroll_area_controller.js.map +1 -1
  98. package/dist/controllers/scrollspy_controller.js.map +1 -1
  99. package/dist/controllers/sidebar_controller.d.ts +1 -1
  100. package/dist/controllers/sidebar_controller.js.map +1 -1
  101. package/dist/controllers/skeleton_controller.d.ts +2 -2
  102. package/dist/controllers/skeleton_controller.js.map +1 -1
  103. package/dist/controllers/spinner_controller.d.ts +3 -3
  104. package/dist/controllers/spinner_controller.js.map +1 -1
  105. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  106. package/dist/controllers/submit_once_controller.d.ts +3 -3
  107. package/dist/controllers/submit_once_controller.js.map +1 -1
  108. package/dist/controllers/switch_controller.js.map +1 -1
  109. package/dist/controllers/textarea_autosize_controller.d.ts +1 -1
  110. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  111. package/dist/controllers/theme_controller.d.ts +3 -2
  112. package/dist/controllers/theme_controller.js.map +1 -1
  113. package/dist/controllers/toast_controller.js +1 -1
  114. package/dist/controllers/toast_controller.js.map +1 -1
  115. package/dist/controllers/toggle_group_controller.js.map +1 -1
  116. package/dist/controllers/toolbar_controller.d.ts +1 -1
  117. package/dist/controllers/toolbar_controller.js.map +1 -1
  118. package/dist/controllers/tooltip_controller.d.ts +1 -1
  119. package/dist/controllers/tooltip_controller.js.map +1 -1
  120. package/dist/controllers/transition_controller.d.ts +1 -1
  121. package/dist/controllers/transition_controller.js.map +1 -1
  122. package/dist/controllers/tree_view_controller.d.ts +4 -3
  123. package/dist/controllers/tree_view_controller.js.map +1 -1
  124. package/dist/index.js +101 -38
  125. package/dist/index.js.map +1 -1
  126. package/dist/inspector/cli.d.ts +1 -1
  127. package/dist/inspector/cli.js +1 -1
  128. package/dist/inspector/cli.js.map +1 -1
  129. package/dist/inspector/cli_bin.js +1 -1
  130. package/dist/inspector/cli_bin.js.map +1 -1
  131. package/dist/inspector/examples.json +1 -1
  132. package/dist/inspector/manifest.json +6 -3
  133. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":"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","file":"lazy_frame_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 } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * observes with no margin, so the frame loads only once it is already in view; this\n * drives an explicit `IntersectionObserver` with a configurable `rootMargin`, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — **writing `src` is what starts the fetch** — marks\n * `data-lazy-loaded`, and emits `load`. Focus is the fallback for the *first* load only;\n * once the frame has loaded, its listener is released and further focus moves inside the\n * frame change nothing.\n *\n * With `once` (default) the controller then stops observing. With `once` off it keeps\n * watching and re-fetches on a **re-entry** — the frame has to be seen inside the\n * observed area, leave it, and come back. Where a connection first finds the frame is the\n * baseline rather than a movement, so a focus-started load and a cache restore each begin\n * the visit wherever the frame sits — in view or out of it — and re-fetch nothing until it\n * leaves and returns. A re-entry serves the held `url`: when it still matches `src`, the\n * host's own `reload()` refetches it; when it has moved on, writing the new `url` to `src`\n * is the fetch.\n *\n * `load` dispatches `{ url }` — always the URL the fetch actually started for.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to `stimeo--frame-loading`. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is the truth source on a\n * Turbo cache restore: a restored frame is observed again only while `once` is off, which\n * is the mode that asked to keep re-fetching). An empty `url` arms nothing and is never\n * written to `src`. The `IntersectionObserver` and focus listener are released once loaded\n * (when `once`) and on `disconnect()` (Turbo navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n #connected = false;\n #loaded = false;\n /**\n * Whether the observer last saw the frame inside the observed area — `null`\n * until it has reported. A re-entry needs a frame that was seen inside and\n * then left, so an unknown position is never a departure.\n */\n #inside: boolean | null = null;\n\n /**\n * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).\n * `#load` releases this listener, so focus never reaches an already-loaded frame.\n */\n readonly #onFocus = (): void => this.#load();\n\n /**\n * Follows the held URL at runtime: one that arrives arms the frame, one that is\n * taken away disarms it, so an empty `url` holds no triggers either way.\n *\n * Stimulus runs value callbacks before `connect()`, so the connected guard keeps\n * arming in one place. A frame that already loaded keeps its held URL for the next\n * re-entry instead of fetching on the spot.\n */\n urlValueChanged(): void {\n if (!this.#connected || this.#loaded) return;\n if (this.urlValue) this.#arm();\n else this.#stopObserving();\n }\n\n /** Rebuilds the observer when the early-load margin changes at runtime. */\n rootMarginValueChanged(): void {\n if (this.#connected && this.#watcher.active) this.#observe();\n }\n\n override connect(): void {\n // `data-lazy-loaded` is the truth source across a cache restore, and `once`\n // decides whether a restored frame still has anything to watch for.\n this.#loaded = this.element.hasAttribute(\"data-lazy-loaded\");\n this.#inside = null;\n this.#connected = true;\n if (this.#loaded && this.onceValue) return;\n this.#arm();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#stopObserving();\n }\n\n /** Attaches the triggers that still have a job to do. */\n #arm(): void {\n if (!this.urlValue) return;\n if (!this.#loaded) this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#observe();\n }\n\n #observe(): void {\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const returning = this.#inside === false;\n this.#inside = true;\n this.#enter(returning);\n } else if (this.#inside) {\n // An observer reports where the frame is before it reports any movement,\n // so only a frame already seen inside can be leaving.\n this.#inside = false;\n }\n }\n }\n\n /** Applies an intersecting entry: the first one loads, a return re-fetches. */\n #enter(returning: boolean): void {\n if (!this.#loaded) {\n this.#load();\n return;\n }\n if (this.onceValue || !returning) return;\n this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n if (!this.urlValue) return;\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n // The focus fallback exists to start the first load; it has nothing left to do.\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n if (this.onceValue) this.#watcher.stop();\n }\n\n /** Re-entry while `once` is off: fetch the held URL again. */\n #reload(): void {\n const src = this.element.getAttribute(\"src\");\n if (this.urlValue && this.urlValue !== src) {\n // The held URL moved on. Writing it to `src` is the fetch, the same start\n // the first load uses, so it works on any host.\n this.element.setAttribute(\"src\", this.urlValue);\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n return;\n }\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Refetching the URL already on `src` needs the host's own `reload()`. On a\n // non-`<turbo-frame>` host there is none, and firing `load` would announce a\n // fetch that never started.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: src ?? \"\" } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":";;;;;AA+CA,SAAS,UAAU,QAAA,EAA8C;AAC/D,EAAA,IAAI,CAAC,UAAU,OAAO,IAAA;AACtB,EAAA,IAAI;AACF,IAAA,OAAO,QAAA,CAAS,cAAc,QAAQ,CAAA;AAAA,EACxC,CAAA,CAAA,MAAQ;AAAA,EAER;AACA,EAAA,OAAO,IAAA;AACT;AAiBO,IAAM,sBAAN,MAA0B;AAAA,EACtB,UAAA;AAAA,EACT,SAAA,GAAyC,IAAA;AAAA,EACzC,OAAA,GAAU,KAAA;AAAA,EACV,sBAAA,GAAyB,KAAA;AAAA,EAEzB,YAAY,SAAA,EAA2D;AACrE,IAAA,IAAA,CAAK,UAAA,GAAa,SAAA;AAAA,EACpB;AAAA;AAAA,EAGA,IAAI,MAAA,GAAkB;AACpB,IAAA,OAAO,IAAA,CAAK,OAAA;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,qBAAA,GAAiC;AACnC,IAAA,OAAO,IAAA,CAAK,sBAAA;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,KAAA,CAAM,OAAA,EAAuC,OAAA,GAAoC,EAAC,EAAY;AAC5F,IAAA,IAAA,CAAK,IAAA,EAAK;AACV,IAAA,IAAI,OAAO,oBAAA,KAAyB,WAAA,EAAa,OAAO,KAAA;AACxD,IAAA,MAAM,OAAO,KAAA,CAAM,OAAA,CAAQ,OAAO,CAAA,GAAK,OAAA,GAAiC,CAAC,OAAkB,CAAA;AAC3F,IAAA,IAAI,IAAA,CAAK,MAAA,KAAW,CAAA,EAAG,OAAO,KAAA;AAE9B,IAAA,MAAM,IAAA,GAAO,UAAU,OAAA,GAAW,OAAA,CAAQ,QAAQ,IAAA,GAAQ,SAAA,CAAU,QAAQ,YAAY,CAAA;AAExF,IAAA,IAAI,QAAA,GAAwC,IAAA;AAC5C,IAAA,IAAI;AACF,MAAA,MAAM,SAAA,GAAY,CAAC,OAAA,KAA+C;AAGhE,QAAA,IAAI,KAAK,OAAA,IAAW,IAAA,CAAK,cAAc,QAAA,EAAU,IAAA,CAAK,WAAW,OAAO,CAAA;AAAA,MAC1E,CAAA;AACA,MAAA,IAAI;AACF,QAAA,QAAA,GAAW,IAAI,qBAAqB,SAAA,EAAW;AAAA,UAC7C,IAAA;AAAA,UACA,YAAY,OAAA,CAAQ,UAAA;AAAA,UACpB,WAAW,OAAA,CAAQ;AAAA,SACpB,CAAA;AAAA,MACH,SAAS,KAAA,EAAO;AACd,QAAA,OAAA,CAAQ,IAAA;AAAA,UACN,wHAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,QAAA,GAAW,IAAI,oBAAA,CAAqB,SAAA,EAAW,EAAE,MAAM,CAAA;AACvD,QAAA,IAAA,CAAK,sBAAA,GAAyB,IAAA;AAAA,MAChC;AACA,MAAA,KAAA,MAAW,MAAA,IAAU,IAAA,EAAM,QAAA,CAAS,OAAA,CAAQ,MAAM,CAAA;AAClD,MAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AACjB,MAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,MAAA,OAAO,IAAA;AAAA,IACT,SAAS,KAAA,EAAO;AAGd,MAAA,QAAA,EAAU,UAAA,EAAW;AACrB,MAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AACjB,MAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,MAAA,IAAA,CAAK,sBAAA,GAAyB,KAAA;AAC9B,MAAA,MAAM,KAAA;AAAA,IACR;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,MAAA,EAAuB;AAC3B,IAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,IAAA,IAAI;AACF,MAAA,IAAA,CAAK,SAAA,CAAU,UAAU,MAAM,CAAA;AAC/B,MAAA,IAAA,CAAK,SAAA,CAAU,QAAQ,MAAM,CAAA;AAAA,IAC/B,SAAS,KAAA,EAAO;AACd,MAAA,IAAA,CAAK,IAAA,EAAK;AACV,MAAA,MAAM,KAAA;AAAA,IACR;AAAA,EACF;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,WAAW,UAAA,EAAW;AAC3B,IAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AACjB,IAAA,IAAA,CAAK,sBAAA,GAAyB,KAAA;AAAA,EAChC;AACF,CAAA;;;ACxIO,IAAM,mBAAA,GAAN,cAAkC,UAAA,CAAwB;AAAA,EAC/D,OAAgB,MAAA,GAAS;AAAA,IACvB,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACjC,UAAA,EAAY,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,KAAA,EAAM;AAAA,IAC3C,IAAA,EAAM,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,IAAA;AAAK,GACvC;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,MAAM,CAAA;AAAA;AAAA,EAOd,QAAA,GAAW,IAAI,mBAAA,CAAoB,CAAC,YAAY,IAAA,CAAK,YAAA,CAAa,OAAO,CAAC,CAAA;AAAA,EACnF,UAAA,GAAa,KAAA;AAAA,EACb,OAAA,GAAU,KAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMV,OAAA,GAA0B,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMjB,QAAA,GAAW,MAAY,IAAA,CAAK,KAAA,EAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAU3C,eAAA,GAAwB;AACtB,IAAA,IAAI,CAAC,IAAA,CAAK,UAAA,IAAc,IAAA,CAAK,OAAA,EAAS;AACtC,IAAA,IAAI,IAAA,CAAK,QAAA,EAAU,IAAA,CAAK,IAAA,EAAK;AAAA,cACnB,cAAA,EAAe;AAAA,EAC3B;AAAA;AAAA,EAGA,sBAAA,GAA+B;AAC7B,IAAA,IAAI,KAAK,UAAA,IAAc,IAAA,CAAK,QAAA,CAAS,MAAA,OAAa,QAAA,EAAS;AAAA,EAC7D;AAAA,EAES,OAAA,GAAgB;AAGvB,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,kBAAkB,CAAA;AAC3D,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAClB,IAAA,IAAI,IAAA,CAAK,OAAA,IAAW,IAAA,CAAK,SAAA,EAAW;AACpC,IAAA,IAAA,CAAK,IAAA,EAAK;AAAA,EACZ;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,IAAA,CAAK,cAAA,EAAe;AAAA,EACtB;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAI,CAAC,KAAK,QAAA,EAAU;AACpB,IAAA,IAAI,CAAC,KAAK,OAAA,EAAS,IAAA,CAAK,QAAQ,gBAAA,CAAiB,SAAA,EAAW,KAAK,QAAQ,CAAA;AACzE,IAAA,IAAA,CAAK,QAAA,EAAS;AAAA,EAChB;AAAA,EAEA,QAAA,GAAiB;AACf,IAAA,IAAA,CAAK,QAAA,CAAS,MAAM,IAAA,CAAK,OAAA,EAAS,EAAE,UAAA,EAAY,IAAA,CAAK,iBAAiB,CAAA;AAAA,EACxE;AAAA,EAEA,aAAa,OAAA,EAA4C;AACvD,IAAA,KAAA,MAAW,SAAS,OAAA,EAAS;AAC3B,MAAA,IAAI,MAAM,cAAA,EAAgB;AACxB,QAAA,MAAM,SAAA,GAAY,KAAK,OAAA,KAAY,KAAA;AACnC,QAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,QAAA,IAAA,CAAK,OAAO,SAAS,CAAA;AAAA,MACvB,CAAA,MAAA,IAAW,KAAK,OAAA,EAAS;AAGvB,QAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,SAAA,EAA0B;AAC/B,IAAA,IAAI,CAAC,KAAK,OAAA,EAAS;AACjB,MAAA,IAAA,CAAK,KAAA,EAAM;AACX,MAAA;AAAA,IACF;AACA,IAAA,IAAI,IAAA,CAAK,SAAA,IAAa,CAAC,SAAA,EAAW;AAClC,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAI,CAAC,KAAK,QAAA,EAAU;AACpB,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,KAAA,EAAO,IAAA,CAAK,QAAQ,CAAA;AAC9C,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,kBAAA,EAAoB,MAAM,CAAA;AACpD,IAAA,IAAA,CAAK,QAAA,CAAS,QAAQ,EAAE,MAAA,EAAQ,EAAE,GAAA,EAAK,IAAA,CAAK,QAAA,EAAS,EAAG,CAAA;AAExD,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,SAAA,EAAW,IAAA,CAAK,QAAQ,CAAA;AACzD,IAAA,IAAI,IAAA,CAAK,SAAA,EAAW,IAAA,CAAK,QAAA,CAAS,IAAA,EAAK;AAAA,EACzC;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,KAAK,CAAA;AAC3C,IAAA,IAAI,IAAA,CAAK,QAAA,IAAY,IAAA,CAAK,QAAA,KAAa,GAAA,EAAK;AAG1C,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,KAAA,EAAO,IAAA,CAAK,QAAQ,CAAA;AAC9C,MAAA,IAAA,CAAK,QAAA,CAAS,QAAQ,EAAE,MAAA,EAAQ,EAAE,GAAA,EAAK,IAAA,CAAK,QAAA,EAAS,EAAG,CAAA;AACxD,MAAA;AAAA,IACF;AACA,IAAA,MAAM,QAAQ,IAAA,CAAK,OAAA;AAInB,IAAA,IAAI,OAAO,KAAA,CAAM,MAAA,KAAW,UAAA,EAAY;AACxC,IAAA,KAAA,CAAM,MAAA,EAAO;AACb,IAAA,IAAA,CAAK,QAAA,CAAS,QAAQ,EAAE,MAAA,EAAQ,EAAE,GAAA,EAAK,GAAA,IAAO,EAAA,EAAG,EAAG,CAAA;AAAA,EACtD;AAAA,EAEA,cAAA,GAAuB;AACrB,IAAA,IAAA,CAAK,SAAS,IAAA,EAAK;AACnB,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,SAAA,EAAW,IAAA,CAAK,QAAQ,CAAA;AAAA,EAC3D;AACF","file":"lazy_frame_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 `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/**\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 } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * observes with no margin, so the frame loads only once it is already in view; this\n * drives an explicit `IntersectionObserver` with a configurable `rootMargin`, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — **writing `src` is what starts the fetch** — marks\n * `data-lazy-loaded`, and emits `load`. Focus is the fallback for the *first* load only;\n * once the frame has loaded, its listener is released and further focus moves inside the\n * frame change nothing.\n *\n * With `once` (default) the controller then stops observing. With `once` off it keeps\n * watching and re-fetches on a **re-entry** — the frame has to be seen inside the\n * observed area, leave it, and come back. Where a connection first finds the frame is the\n * baseline rather than a movement, so a focus-started load and a cache restore each begin\n * the visit wherever the frame sits — in view or out of it — and re-fetch nothing until it\n * leaves and returns. A re-entry serves the held `url`: when it still matches `src`, the\n * host's own `reload()` refetches it; when it has moved on, writing the new `url` to `src`\n * is the fetch.\n *\n * `load` dispatches `{ url }` — always the URL the fetch actually started for.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to `stimeo--frame-loading`. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is the truth source on a\n * Turbo cache restore: a restored frame is observed again only while `once` is off, which\n * is the mode that asked to keep re-fetching). An empty `url` arms nothing and is never\n * written to `src`. The `IntersectionObserver` and focus listener are released once loaded\n * (when `once`) and on `disconnect()` (Turbo navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n #connected = false;\n #loaded = false;\n /**\n * Whether the observer last saw the frame inside the observed area — `null`\n * until it has reported. A re-entry needs a frame that was seen inside and\n * then left, so an unknown position is never a departure.\n */\n #inside: boolean | null = null;\n\n /**\n * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).\n * `#load` releases this listener, so focus never reaches an already-loaded frame.\n */\n readonly #onFocus = (): void => this.#load();\n\n /**\n * Follows the held URL at runtime: one that arrives arms the frame, one that is\n * taken away disarms it, so an empty `url` holds no triggers either way.\n *\n * Stimulus runs value callbacks before `connect()`, so the connected guard keeps\n * arming in one place. A frame that already loaded keeps its held URL for the next\n * re-entry instead of fetching on the spot.\n */\n urlValueChanged(): void {\n if (!this.#connected || this.#loaded) return;\n if (this.urlValue) this.#arm();\n else this.#stopObserving();\n }\n\n /** Rebuilds the observer when the early-load margin changes at runtime. */\n rootMarginValueChanged(): void {\n if (this.#connected && this.#watcher.active) this.#observe();\n }\n\n override connect(): void {\n // `data-lazy-loaded` is the truth source across a cache restore, and `once`\n // decides whether a restored frame still has anything to watch for.\n this.#loaded = this.element.hasAttribute(\"data-lazy-loaded\");\n this.#inside = null;\n this.#connected = true;\n if (this.#loaded && this.onceValue) return;\n this.#arm();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#stopObserving();\n }\n\n /** Attaches the triggers that still have a job to do. */\n #arm(): void {\n if (!this.urlValue) return;\n if (!this.#loaded) this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#observe();\n }\n\n #observe(): void {\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const returning = this.#inside === false;\n this.#inside = true;\n this.#enter(returning);\n } else if (this.#inside) {\n // An observer reports where the frame is before it reports any movement,\n // so only a frame already seen inside can be leaving.\n this.#inside = false;\n }\n }\n }\n\n /** Applies an intersecting entry: the first one loads, a return re-fetches. */\n #enter(returning: boolean): void {\n if (!this.#loaded) {\n this.#load();\n return;\n }\n if (this.onceValue || !returning) return;\n this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n if (!this.urlValue) return;\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n // The focus fallback exists to start the first load; it has nothing left to do.\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n if (this.onceValue) this.#watcher.stop();\n }\n\n /** Re-entry while `once` is off: fetch the held URL again. */\n #reload(): void {\n const src = this.element.getAttribute(\"src\");\n if (this.urlValue && this.urlValue !== src) {\n // The held URL moved on. Writing it to `src` is the fetch, the same start\n // the first load uses, so it works on any host.\n this.element.setAttribute(\"src\", this.urlValue);\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n return;\n }\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Refetching the URL already on `src` needs the host's own `reload()`. On a\n // non-`<turbo-frame>` host there is none, and firing `load` would announce a\n // fetch that never started.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: src ?? \"\" } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/option_scroll.ts","../../src/utils/safe_timeout.ts","../../src/utils/typeahead.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n\n/**\n * True when a press arrived carrying any modifier, for the keys the browser and\n * the OS own outright: return without calling `preventDefault()` and without\n * moving any state.\n *\n * `Control+Home` and `Control+End` jump the document to its ends, and a widget\n * that swallows them makes the shortcut work or not depending on where focus\n * happens to sit. {@link isReservedArrowChord} answers the same question for the\n * arrows, but returns `false` for every other key so that this one can decide.\n *\n * There is no `allow` list here on purpose. APG assigns no modifier chord to\n * `Home`/`End`, so a widget that wanted one would be claiming a combination the\n * pattern never gave it.\n *\n * @example\n * ```ts\n * case \"Home\":\n * case \"End\":\n * if (hasModifierChord(event)) return;\n * ```\n */\nexport function hasModifierChord(event: KeyboardEvent): boolean {\n return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;\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","/**\n * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette uses\n * `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Shared type-ahead (first-letter navigation) primitive for composite widgets.\n *\n * The APG asks list-like widgets to move focus by typed characters: one\n * character jumps to the next item starting with it, and several characters\n * typed in quick succession narrow the match to that prefix. Getting that right\n * needs three pieces of bookkeeping — accumulating the query, dropping it after\n * an idle window, and resolving the label each candidate is matched against —\n * and single-sourcing them here keeps every widget on the same rules instead of\n * letting them drift apart.\n *\n * The helpers here own *only* that mechanical part. They are deliberately\n * **policy-free** in the same sense as `RovingTabindex`: which elements are\n * candidates, where the search starts, and what happens on a match (real DOM\n * focus, a roving tab stop, or a virtual `aria-activedescendant`) differ per APG\n * pattern, so each controller keeps those decisions and calls\n * {@link findTypeaheadMatch} with a candidate list it assembled itself.\n *\n * Three decisions are worth stating up front:\n *\n * - **A repeated character collapses the stored query**, rather than deriving a\n * shorter one at search time. That is what lets a follow-up character resume\n * narrowing: `s`, `s`, `e` searches `\"se\"`, not the dead `\"sse\"` that no label\n * can match. Growing the query instead leaves the widget frozen on exactly\n * that sequence, which is why the choice is made here and not per widget.\n * - **`aria-label` wins over text**, so type-ahead matches what a screen reader\n * announces — but only when it contributes a name. A blank (empty or\n * whitespace-only) one is skipped, because accname skips it too: honoring it\n * would leave an element that AT announces by its text unreachable by that\n * text. This mirrors the accessible-name computation rather than the raw text.\n * - **`Space` and composition input are never type-ahead.** `Space` natively\n * activates a `<button>`-based item, and characters still being composed are\n * not a committed query.\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 contracts belong on the members.\n */\n\nimport { SafeTimeout } from \"./safe_timeout\";\n\n/** Idle window after which an accumulated query is dropped, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** Construction options for {@link Typeahead}. */\nexport interface TypeaheadOptions {\n /** Idle window before the query resets. Defaults to {@link TYPEAHEAD_RESET_MS}. */\n resetMs?: number;\n}\n\n/**\n * Accumulates typed characters into a search query and drops it when idle.\n *\n * @remarks\n * Owns a {@link SafeTimeout} of its own, so a consumer's `#timers.clearAll()` does\n * **not** reach the pending reset — call {@link Typeahead.reset} from `disconnect()`.\n */\nexport class Typeahead {\n /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */\n readonly #timers = new SafeTimeout();\n /** Idle window before the query resets, in milliseconds. */\n readonly #resetMs: number;\n /** The accumulated lowercase query, empty when idle. */\n #query = \"\";\n /** Id of the pending reset timer, `0` when none is scheduled. */\n #timerId = 0;\n\n /** @param options - Overrides for the idle window. */\n constructor({ resetMs = TYPEAHEAD_RESET_MS }: TypeaheadOptions = {}) {\n this.#resetMs = resetMs;\n }\n\n /** The query a search would currently run with; empty while idle. */\n get query(): string {\n return this.#query;\n }\n\n /**\n * Folds `key` into the query, restarts the idle window, and returns the query to\n * search with. A repeated character collapses the query to that one character.\n */\n push(key: string): string {\n const char = key.toLowerCase();\n const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);\n this.#query = repeated ? char : this.#query + char;\n this.#timers.clear(this.#timerId);\n this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);\n return this.#query;\n }\n\n /** Clears the query and cancels the pending idle reset. */\n reset(): void {\n this.#query = \"\";\n this.#timers.clear(this.#timerId);\n this.#timerId = 0;\n }\n}\n\n/** Whether `event` is a bare printable character usable for type-ahead. */\nexport function isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey &&\n !event.isComposing\n );\n}\n\n/**\n * The name `element` is matched under, normalized for comparison.\n *\n * @param fallbackText - Produces the name when `aria-label` contributes none.\n * Defaults to the element's `textContent`; pass a narrower source when part of\n * the subtree is not part of the name (a tree item's nested child group, say).\n * Called lazily, so an `aria-label` costs no subtree walk.\n */\nexport function typeaheadLabel(element: HTMLElement, fallbackText?: () => string): string {\n // Blank means absent, matching accname: a whitespace-only `aria-label` is\n // skipped there too, so honoring it here would make an element that a screen\n // reader announces by its text unreachable by that text.\n const label = element.getAttribute(\"aria-label\")?.trim();\n if (label) return label.toLowerCase();\n const text = fallbackText ? fallbackText() : (element.textContent ?? \"\");\n return text.trim().toLowerCase();\n}\n\n/**\n * Index of the first item after `from` whose label starts with `query`, wrapping\n * and evaluating `from` itself last, or `-1` when nothing matches.\n *\n * @param items - Candidates in navigation order, already filtered by the caller.\n * @param from - Index the search moves on from; `-1` starts at the first item. Any\n * out-of-range value is folded back in, so `indexOf` results pass straight through.\n * @param query - Lowercase query, normally the return of {@link Typeahead.push}.\n * @param label - Resolves a candidate's name. Defaults to {@link typeaheadLabel}.\n */\nexport function findTypeaheadMatch(\n items: readonly HTMLElement[],\n from: number,\n query: string,\n label: (item: HTMLElement) => string = (item) => typeaheadLabel(item),\n): number {\n // An empty query would prefix-match everything; an empty list never enters the\n // loop, so it needs no guard of its own.\n if (query === \"\") return -1;\n const count = items.length;\n for (let step = 1; step <= count; step += 1) {\n // Folded twice so a negative `from` still lands on a real slot: `%` keeps the\n // sign of its left operand, and a negative index would read `undefined`.\n const index = (((from + step) % count) + count) % count;\n const candidate = items[index];\n if (candidate && label(candidate).startsWith(query)) return index;\n }\n return -1;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\nimport { findTypeaheadMatch, isTypeaheadKey, Typeahead } from \"../utils/typeahead\";\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * `change` dispatches `{ value: string, option: HTMLElement }`.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Stable ID of the active option; DOM targets are resolved afresh before use. */\n #activeId: string | null = null;\n /** Target ID order captured while an option is active, used only for removal fallback. */\n #activeOrder: string[] = [];\n #connected = false;\n /** Collapses one mutation batch of target callbacks into a single pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());\n /** Accumulated typeahead query and its idle-reset timer. */\n readonly #typeahead = new Typeahead();\n\n /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */\n override connect(): void {\n this.#normalizeSelection();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.#connected = true;\n this.#reconcile.activate();\n }\n\n /**\n * Establishes an inactive baseline for a late option, re-resolves active\n * identity, and re-applies the selection baseline.\n */\n optionTargetConnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (!this.#connected) return;\n this.#normalizeSelection();\n this.#queueOptionReconciliation();\n }\n\n /** Removes controller-owned active state and reconciles the surviving targets. */\n optionTargetDisconnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (this.#connected) this.#queueOptionReconciliation();\n }\n\n /**\n * Brings the authored DOM to the shape the APG requires, and derives the state\n * that follows from the initial selection.\n *\n * Three things happen, and only these three — which option is chosen is the\n * author's, and is never changed:\n *\n * 1. Every option gets an explicit value. An absent `aria-selected` means \"not\n * selectable\" in ARIA, so a forgotten attribute hides a selectable option\n * from assistive technology.\n * 2. At most one stays `true`. The first in DOM order wins, since that is the\n * only deterministic reading of \"which one did the author mean\".\n * 3. The trigger label and the hidden field are derived from that selection.\n * Without this the widget announces a choice it does not submit: the popup\n * says \"Banana\", the trigger still says \"Choose…\", and the form posts \"\".\n *\n * No `change` fires — nothing changed, this is the initial state being told\n * properly. The scan is the `option` target set: a `role=\"option\"` without the\n * target is outside the contract and is neither counted nor written.\n */\n #normalizeSelection(): void {\n const options = this.optionTargets;\n const selected = options.find((option) => option.getAttribute(\"aria-selected\") === \"true\");\n for (const option of options) {\n option.setAttribute(\"aria-selected\", option === selected ? \"true\" : \"false\");\n }\n if (selected) this.#applySelection(selected);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n // The typeahead keeps its idle reset in a `SafeTimeout` of its own, so this is\n // the only teardown that reaches it — there is no controller-level registry to\n // fall back on. Every timer this controller can schedule lives in there.\n this.#typeahead.reset();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (!this.#isClosed) this.#reconcileActive();\n const options = this.optionTargets;\n const length = options.length;\n const activeIndex = this.#findActiveIndex(options);\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n // The entry guard already yields a press another handler owned. A press\n // during IME composition never dismisses, keeping one rule across the\n // widgets (the trigger is a button, so composition does not start here).\n if (event.isComposing) break;\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(options, activeIndex, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option || !this.optionTargets.includes(option)) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#typeahead.reset();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n this.#reconcileActive();\n const options = this.optionTargets;\n const activeIndex = this.#findActiveIndex(options);\n const option = activeIndex < 0 ? undefined : options[activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n const { value, fieldChanged } = this.#applySelection(option);\n if (fieldChanged) {\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Writes `option` into `aria-selected`, the trigger label and the hidden field.\n * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the\n * state is being described rather than changed.\n */\n #applySelection(option: HTMLElement): { value: string; fieldChanged: boolean } {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;\n if (fieldChanged) this.fieldTarget.value = value;\n return { value, fieldChanged };\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n const options = this.optionTargets;\n const active = index < 0 ? null : (options[index] ?? null);\n // Only the options whose marker actually changes are written, so a held arrow\n // key costs two attribute writes rather than one per option. The whole set is\n // still read: that is what makes a stray marker — one a morph left behind on\n // an element that never re-connected as a target — heal on the next move.\n for (const option of options) {\n const marked = option.hasAttribute(\"data-active\");\n if (option === active) {\n if (!marked) option.setAttribute(\"data-active\", \"\");\n } else if (marked) {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.#activeId = active.id;\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.#activeId = null;\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** Resolves active state against the current target collection. */\n #reconcileActive(): void {\n if (!this.hasTriggerTarget || this.#isClosed) {\n if (this.hasTriggerTarget) this.#setActive(-1);\n return;\n }\n\n const options = this.optionTargets;\n const currentIndex = this.#findActiveIndex(options);\n if (currentIndex >= 0) {\n const active = options[currentIndex] ?? null;\n const marked = options.filter((option) => option.hasAttribute(\"data-active\"));\n const stateMatches =\n this.#activeId === (active?.id || null) &&\n marked.length === 1 &&\n marked[0] === active &&\n this.triggerTarget.getAttribute(\"aria-activedescendant\") === (active?.id || null);\n\n if (stateMatches) {\n // Keep the deletion fallback snapshot current without re-scrolling the\n // already-active option before every keyboard command.\n this.#activeOrder = options.map((option) => option.id).filter(Boolean);\n } else {\n this.#setActive(currentIndex);\n }\n return;\n }\n\n const activeId = this.triggerTarget.getAttribute(\"aria-activedescendant\") ?? this.#activeId;\n this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);\n }\n\n /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */\n #findActiveIndex(options: readonly HTMLElement[]): number {\n const activeId =\n (this.hasTriggerTarget ? this.triggerTarget.getAttribute(\"aria-activedescendant\") : null) ??\n this.#activeId;\n if (activeId) return options.findIndex((option) => option.id === activeId);\n return options.findIndex((option) => option.hasAttribute(\"data-active\"));\n }\n\n /** Chooses a surviving former successor, then a former predecessor. */\n #findFallbackIndex(options: readonly HTMLElement[], activeId: string): number {\n const oldIndex = this.#activeOrder.indexOf(activeId);\n if (oldIndex < 0) return -1;\n const indexesById = new Map(options.map((option, index) => [option.id, index]));\n for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n for (let index = oldIndex - 1; index >= 0; index -= 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n return -1;\n }\n\n /** Coalesces all target callbacks from one MutationObserver batch. */\n #queueOptionReconciliation(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Advances the typeahead query and activates the next matching option.\n *\n * The search resumes just after the active option so repeating a character\n * cycles through the options starting with it, rather than re-activating the\n * same first match on every press.\n */\n #typeaheadTo(options: HTMLElement[], activeIndex: number, char: string): void {\n const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));\n if (index !== -1) this.#setActive(index);\n }\n\n /** Closes on an outside click before an inside handler can detach its target. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/option_scroll.ts","../../src/utils/safe_timeout.ts","../../src/utils/typeahead.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n\n/**\n * True when a press arrived carrying any modifier, for the keys the browser and\n * the OS own outright: return without calling `preventDefault()` and without\n * moving any state.\n *\n * `Control+Home` and `Control+End` jump the document to its ends, and a widget\n * that swallows them makes the shortcut work or not depending on where focus\n * happens to sit. {@link isReservedArrowChord} answers the same question for the\n * arrows, but returns `false` for every other key so that this one can decide.\n *\n * There is no `allow` list here on purpose. APG assigns no modifier chord to\n * `Home`/`End`, so a widget that wanted one would be claiming a combination the\n * pattern never gave it.\n *\n * @example\n * ```ts\n * case \"Home\":\n * case \"End\":\n * if (hasModifierChord(event)) return;\n * ```\n */\nexport function hasModifierChord(event: KeyboardEvent): boolean {\n return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;\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","/**\n * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette uses\n * `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Shared type-ahead (first-letter navigation) primitive for composite widgets.\n *\n * The APG asks list-like widgets to move focus by typed characters: one\n * character jumps to the next item starting with it, and several characters\n * typed in quick succession narrow the match to that prefix. Getting that right\n * needs three pieces of bookkeeping — accumulating the query, dropping it after\n * an idle window, and resolving the label each candidate is matched against —\n * and single-sourcing them here keeps every widget on the same rules instead of\n * letting them drift apart.\n *\n * The helpers here own *only* that mechanical part. They are deliberately\n * **policy-free** in the same sense as `RovingTabindex`: which elements are\n * candidates, where the search starts, and what happens on a match (real DOM\n * focus, a roving tab stop, or a virtual `aria-activedescendant`) differ per APG\n * pattern, so each controller keeps those decisions and calls\n * {@link findTypeaheadMatch} with a candidate list it assembled itself.\n *\n * Three decisions are worth stating up front:\n *\n * - **A repeated character collapses the stored query**, rather than deriving a\n * shorter one at search time. That is what lets a follow-up character resume\n * narrowing: `s`, `s`, `e` searches `\"se\"`, not the dead `\"sse\"` that no label\n * can match. Growing the query instead leaves the widget frozen on exactly\n * that sequence, which is why the choice is made here and not per widget.\n * - **`aria-label` wins over text**, so type-ahead matches what a screen reader\n * announces — but only when it contributes a name. A blank (empty or\n * whitespace-only) one is skipped, because accname skips it too: honoring it\n * would leave an element that AT announces by its text unreachable by that\n * text. This mirrors the accessible-name computation rather than the raw text.\n * - **`Space` and composition input are never type-ahead.** `Space` natively\n * activates a `<button>`-based item, and characters still being composed are\n * not a committed query.\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 contracts belong on the members.\n */\n\nimport { SafeTimeout } from \"./safe_timeout\";\n\n/** Idle window after which an accumulated query is dropped, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** Construction options for {@link Typeahead}. */\nexport interface TypeaheadOptions {\n /** Idle window before the query resets. Defaults to {@link TYPEAHEAD_RESET_MS}. */\n resetMs?: number;\n}\n\n/**\n * Accumulates typed characters into a search query and drops it when idle.\n *\n * @remarks\n * Owns a {@link SafeTimeout} of its own, so a consumer's `#timers.clearAll()` does\n * **not** reach the pending reset — call {@link Typeahead.reset} from `disconnect()`.\n */\nexport class Typeahead {\n /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */\n readonly #timers = new SafeTimeout();\n /** Idle window before the query resets, in milliseconds. */\n readonly #resetMs: number;\n /** The accumulated lowercase query, empty when idle. */\n #query = \"\";\n /** Id of the pending reset timer, `0` when none is scheduled. */\n #timerId = 0;\n\n /** @param options - Overrides for the idle window. */\n constructor({ resetMs = TYPEAHEAD_RESET_MS }: TypeaheadOptions = {}) {\n this.#resetMs = resetMs;\n }\n\n /** The query a search would currently run with; empty while idle. */\n get query(): string {\n return this.#query;\n }\n\n /**\n * Folds `key` into the query, restarts the idle window, and returns the query to\n * search with. A repeated character collapses the query to that one character.\n */\n push(key: string): string {\n const char = key.toLowerCase();\n const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);\n this.#query = repeated ? char : this.#query + char;\n this.#timers.clear(this.#timerId);\n this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);\n return this.#query;\n }\n\n /** Clears the query and cancels the pending idle reset. */\n reset(): void {\n this.#query = \"\";\n this.#timers.clear(this.#timerId);\n this.#timerId = 0;\n }\n}\n\n/** Whether `event` is a bare printable character usable for type-ahead. */\nexport function isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey &&\n !event.isComposing\n );\n}\n\n/**\n * The name `element` is matched under, normalized for comparison.\n *\n * @param fallbackText - Produces the name when `aria-label` contributes none.\n * Defaults to the element's `textContent`; pass a narrower source when part of\n * the subtree is not part of the name (a tree item's nested child group, say).\n * Called lazily, so an `aria-label` costs no subtree walk.\n */\nexport function typeaheadLabel(element: HTMLElement, fallbackText?: () => string): string {\n // Blank means absent, matching accname: a whitespace-only `aria-label` is\n // skipped there too, so honoring it here would make an element that a screen\n // reader announces by its text unreachable by that text.\n const label = element.getAttribute(\"aria-label\")?.trim();\n if (label) return label.toLowerCase();\n const text = fallbackText ? fallbackText() : (element.textContent ?? \"\");\n return text.trim().toLowerCase();\n}\n\n/**\n * Index of the first item after `from` whose label starts with `query`, wrapping\n * and evaluating `from` itself last, or `-1` when nothing matches.\n *\n * @param items - Candidates in navigation order, already filtered by the caller.\n * @param from - Index the search moves on from; `-1` starts at the first item. Any\n * out-of-range value is folded back in, so `indexOf` results pass straight through.\n * @param query - Lowercase query, normally the return of {@link Typeahead.push}.\n * @param label - Resolves a candidate's name. Defaults to {@link typeaheadLabel}.\n */\nexport function findTypeaheadMatch(\n items: readonly HTMLElement[],\n from: number,\n query: string,\n label: (item: HTMLElement) => string = (item) => typeaheadLabel(item),\n): number {\n // An empty query would prefix-match everything; an empty list never enters the\n // loop, so it needs no guard of its own.\n if (query === \"\") return -1;\n const count = items.length;\n for (let step = 1; step <= count; step += 1) {\n // Folded twice so a negative `from` still lands on a real slot: `%` keeps the\n // sign of its left operand, and a negative index would read `undefined`.\n const index = (((from + step) % count) + count) % count;\n const candidate = items[index];\n if (candidate && label(candidate).startsWith(query)) return index;\n }\n return -1;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\nimport { findTypeaheadMatch, isTypeaheadKey, Typeahead } from \"../utils/typeahead\";\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * `change` dispatches `{ value: string, option: HTMLElement }`.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Stable ID of the active option; DOM targets are resolved afresh before use. */\n #activeId: string | null = null;\n /** Target ID order captured while an option is active, used only for removal fallback. */\n #activeOrder: string[] = [];\n #connected = false;\n /** Collapses one mutation batch of target callbacks into a single pass. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());\n /** Accumulated typeahead query and its idle-reset timer. */\n readonly #typeahead = new Typeahead();\n\n /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */\n override connect(): void {\n this.#normalizeSelection();\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.#connected = true;\n this.#reconcile.activate();\n }\n\n /**\n * Establishes an inactive baseline for a late option, re-resolves active\n * identity, and re-applies the selection baseline.\n */\n optionTargetConnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (!this.#connected) return;\n this.#normalizeSelection();\n this.#queueOptionReconciliation();\n }\n\n /** Removes controller-owned active state and reconciles the surviving targets. */\n optionTargetDisconnected(option: HTMLElement): void {\n option.removeAttribute(\"data-active\");\n if (this.#connected) this.#queueOptionReconciliation();\n }\n\n /**\n * Brings the authored DOM to the shape the APG requires, and derives the state\n * that follows from the initial selection.\n *\n * Three things happen, and only these three — which option is chosen is the\n * author's, and is never changed:\n *\n * 1. Every option gets an explicit value. An absent `aria-selected` means \"not\n * selectable\" in ARIA, so a forgotten attribute hides a selectable option\n * from assistive technology.\n * 2. At most one stays `true`. The first in DOM order wins, since that is the\n * only deterministic reading of \"which one did the author mean\".\n * 3. The trigger label and the hidden field are derived from that selection.\n * Without this the widget announces a choice it does not submit: the popup\n * says \"Banana\", the trigger still says \"Choose…\", and the form posts \"\".\n *\n * No `change` fires — nothing changed, this is the initial state being told\n * properly. The scan is the `option` target set: a `role=\"option\"` without the\n * target is outside the contract and is neither counted nor written.\n */\n #normalizeSelection(): void {\n const options = this.optionTargets;\n const selected = options.find((option) => option.getAttribute(\"aria-selected\") === \"true\");\n for (const option of options) {\n option.setAttribute(\"aria-selected\", option === selected ? \"true\" : \"false\");\n }\n if (selected) this.#applySelection(selected);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n this.#connected = false;\n this.#reconcile.cancel();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n // The typeahead keeps its idle reset in a `SafeTimeout` of its own, so this is\n // the only teardown that reaches it — there is no controller-level registry to\n // fall back on. Every timer this controller can schedule lives in there.\n this.#typeahead.reset();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (!this.#isClosed) this.#reconcileActive();\n const options = this.optionTargets;\n const length = options.length;\n const activeIndex = this.#findActiveIndex(options);\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n // The entry guard already yields a press another handler owned. A press\n // during IME composition never dismisses, keeping one rule across the\n // widgets (the trigger is a button, so composition does not start here).\n if (event.isComposing) break;\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(options, activeIndex, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option || !this.optionTargets.includes(option)) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#typeahead.reset();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n this.#reconcileActive();\n const options = this.optionTargets;\n const activeIndex = this.#findActiveIndex(options);\n const option = activeIndex < 0 ? undefined : options[activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n const { value, fieldChanged } = this.#applySelection(option);\n if (fieldChanged) {\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Writes `option` into `aria-selected`, the trigger label and the hidden field.\n * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the\n * state is being described rather than changed.\n */\n #applySelection(option: HTMLElement): { value: string; fieldChanged: boolean } {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;\n if (fieldChanged) this.fieldTarget.value = value;\n return { value, fieldChanged };\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n const options = this.optionTargets;\n const active = index < 0 ? null : (options[index] ?? null);\n // Only the options whose marker actually changes are written, so a held arrow\n // key costs two attribute writes rather than one per option. The whole set is\n // still read: that is what makes a stray marker — one a morph left behind on\n // an element that never re-connected as a target — heal on the next move.\n for (const option of options) {\n const marked = option.hasAttribute(\"data-active\");\n if (option === active) {\n if (!marked) option.setAttribute(\"data-active\", \"\");\n } else if (marked) {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.#activeId = active.id;\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.#activeId = null;\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** Resolves active state against the current target collection. */\n #reconcileActive(): void {\n if (!this.hasTriggerTarget || this.#isClosed) {\n if (this.hasTriggerTarget) this.#setActive(-1);\n return;\n }\n\n const options = this.optionTargets;\n const currentIndex = this.#findActiveIndex(options);\n if (currentIndex >= 0) {\n const active = options[currentIndex] ?? null;\n const marked = options.filter((option) => option.hasAttribute(\"data-active\"));\n const stateMatches =\n this.#activeId === (active?.id || null) &&\n marked.length === 1 &&\n marked[0] === active &&\n this.triggerTarget.getAttribute(\"aria-activedescendant\") === (active?.id || null);\n\n if (stateMatches) {\n // Keep the deletion fallback snapshot current without re-scrolling the\n // already-active option before every keyboard command.\n this.#activeOrder = options.map((option) => option.id).filter(Boolean);\n } else {\n this.#setActive(currentIndex);\n }\n return;\n }\n\n const activeId = this.triggerTarget.getAttribute(\"aria-activedescendant\") ?? this.#activeId;\n this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);\n }\n\n /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */\n #findActiveIndex(options: readonly HTMLElement[]): number {\n const activeId =\n (this.hasTriggerTarget ? this.triggerTarget.getAttribute(\"aria-activedescendant\") : null) ??\n this.#activeId;\n if (activeId) return options.findIndex((option) => option.id === activeId);\n return options.findIndex((option) => option.hasAttribute(\"data-active\"));\n }\n\n /** Chooses a surviving former successor, then a former predecessor. */\n #findFallbackIndex(options: readonly HTMLElement[], activeId: string): number {\n const oldIndex = this.#activeOrder.indexOf(activeId);\n if (oldIndex < 0) return -1;\n const indexesById = new Map(options.map((option, index) => [option.id, index]));\n for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n for (let index = oldIndex - 1; index >= 0; index -= 1) {\n const fallback = indexesById.get(this.#activeOrder[index] ?? \"\");\n if (fallback !== undefined) return fallback;\n }\n return -1;\n }\n\n /** Coalesces all target callbacks from one MutationObserver batch. */\n #queueOptionReconciliation(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Advances the typeahead query and activates the next matching option.\n *\n * The search resumes just after the active option so repeating a character\n * cycles through the options starting with it, rather than re-activating the\n * same first match on every press.\n */\n #typeaheadTo(options: HTMLElement[], activeIndex: number, char: string): void {\n const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));\n if (index !== -1) this.#setActive(index);\n }\n\n /** Closes on an outside click before an inside handler can detach its target. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
@@ -29,7 +29,7 @@ import { Controller } from '@hotwired/stimulus';
29
29
  * Render inputs are followed at runtime: a morph that swaps a Value or the
30
30
  * `datetime` attribute on the live element — which keeps the element, so
31
31
  * `connect()` never runs again — repaints through one coalesced pass
32
- * ({@link MicrotaskCoalescer}) rather than leaving a stale reading on screen.
32
+ * (`MicrotaskCoalescer`) rather than leaving a stale reading on screen.
33
33
  */
34
34
  declare class LocalTimeController extends Controller<HTMLElement> {
35
35
  #private;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/microtask_coalescer.ts","../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","file":"local_time_controller.js","sourcesContent":["/**\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 { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * `format` dispatches `{ formatted }`.\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state or timers, so a Turbo Drive cache restore\n * re-runs `connect()` and stays consistent. A parse or `Intl` error leaves the\n * authored absolute text in place rather than throwing.\n *\n * Render inputs are followed at runtime: a morph that swaps a Value or the\n * `datetime` attribute on the live element — which keeps the element, so\n * `connect()` never runs again — repaints through one coalesced pass\n * ({@link MicrotaskCoalescer}) rather than leaving a stale reading on screen.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n /** Collapses a morph that swaps several render inputs at once into one repaint. */\n readonly #resync = new MicrotaskCoalescer(() => this.#render());\n /**\n * Watches the one render input that is not a Value. Only `datetime` is filtered\n * in, so the text and `title` this controller writes cannot re-enter the pass.\n */\n readonly #datetimeWatch = new MutationObserver(() => {\n this.#resync.schedule();\n });\n\n override connect(): void {\n this.#resync.activate();\n this.#datetimeWatch.observe(this.element, { attributeFilter: [\"datetime\"] });\n this.#render();\n }\n\n override disconnect(): void {\n this.#resync.cancel();\n this.#datetimeWatch.disconnect();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */\n localeValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */\n timeZoneValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */\n dateStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */\n timeStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */\n titleFormatValueChanged(): void {\n this.#resync.schedule();\n }\n\n /**\n * Formats the instant in `datetime` against the current Values and writes it out.\n *\n * The `format` event rides with every pass, including a repaint a morph triggers:\n * its condition is that formatting was applied, and a repaint applies it with a\n * new result. A pass that cannot format writes nothing and emits nothing, so the\n * authored absolute text stays as the fallback.\n *\n * @stimeoRenderRoot\n */\n #render(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /**\n * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace\n * around the attribute value is tolerated.\n */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the input contract of this\n * controller — since `Date.parse` would otherwise read `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n *\n * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is\n * left to each engine, so a whole value shaped that way is normalized to the `T`\n * separator first. The pattern is anchored: a value trailing anything else — a\n * zone word such as `\"2026-06-08 12:30:00 UTC\"` — is handed to `Date.parse` as\n * authored instead of being turned into a string nothing can parse.\n */\n #asUtc(value: string): string {\n const isoLike = value.replace(\n /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?)$/,\n \"$1T$2\",\n );\n const hasTime = /T\\d{2}:\\d{2}/.test(isoLike);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(isoLike);\n return hasTime && !hasZone ? `${isoLike}Z` : isoLike;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.closest(\"[lang]\")?.getAttribute(\"lang\") || undefined;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/microtask_coalescer.ts","../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","file":"local_time_controller.js","sourcesContent":["/**\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 { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * `format` dispatches `{ formatted }`.\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state or timers, so a Turbo Drive cache restore\n * re-runs `connect()` and stays consistent. A parse or `Intl` error leaves the\n * authored absolute text in place rather than throwing.\n *\n * Render inputs are followed at runtime: a morph that swaps a Value or the\n * `datetime` attribute on the live element — which keeps the element, so\n * `connect()` never runs again — repaints through one coalesced pass\n * (`MicrotaskCoalescer`) rather than leaving a stale reading on screen.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n /** Collapses a morph that swaps several render inputs at once into one repaint. */\n readonly #resync = new MicrotaskCoalescer(() => this.#render());\n /**\n * Watches the one render input that is not a Value. Only `datetime` is filtered\n * in, so the text and `title` this controller writes cannot re-enter the pass.\n */\n readonly #datetimeWatch = new MutationObserver(() => {\n this.#resync.schedule();\n });\n\n override connect(): void {\n this.#resync.activate();\n this.#datetimeWatch.observe(this.element, { attributeFilter: [\"datetime\"] });\n this.#render();\n }\n\n override disconnect(): void {\n this.#resync.cancel();\n this.#datetimeWatch.disconnect();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */\n localeValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */\n timeZoneValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */\n dateStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */\n timeStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */\n titleFormatValueChanged(): void {\n this.#resync.schedule();\n }\n\n /**\n * Formats the instant in `datetime` against the current Values and writes it out.\n *\n * The `format` event rides with every pass, including a repaint a morph triggers:\n * its condition is that formatting was applied, and a repaint applies it with a\n * new result. A pass that cannot format writes nothing and emits nothing, so the\n * authored absolute text stays as the fallback.\n *\n * @stimeoRenderRoot\n */\n #render(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /**\n * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace\n * around the attribute value is tolerated.\n */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the input contract of this\n * controller — since `Date.parse` would otherwise read `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n *\n * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is\n * left to each engine, so a whole value shaped that way is normalized to the `T`\n * separator first. The pattern is anchored: a value trailing anything else — a\n * zone word such as `\"2026-06-08 12:30:00 UTC\"` — is handed to `Date.parse` as\n * authored instead of being turned into a string nothing can parse.\n */\n #asUtc(value: string): string {\n const isoLike = value.replace(\n /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?)$/,\n \"$1T$2\",\n );\n const hasTime = /T\\d{2}:\\d{2}/.test(isoLike);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(isoLike);\n return hasTime && !hasZone ? `${isoLike}Z` : isoLike;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.closest(\"[lang]\")?.getAttribute(\"lang\") || undefined;\n }\n}\n"]}
@@ -30,7 +30,7 @@ import { Controller } from '@hotwired/stimulus';
30
30
  * positioning styles. Use only for independent cards whose visual order carries no
31
31
  * meaning.
32
32
  *
33
- * Re-layout runs on connect, on resize ({@link LayoutObserver}), on item
33
+ * Re-layout runs on connect, on resize (`LayoutObserver`), on item
34
34
  * add/remove ({@link MutationObserver}), on an item joining or leaving the target
35
35
  * set, when a declared number changes, and when a descendant resource loads.
36
36
  * Everything but the first pass is folded into one microtask, so a burst of