stimeo-ui 0.9.0 → 0.11.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 (162) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/dist/cable/index.d.ts +79 -14
  3. package/dist/cable/index.js +226 -43
  4. package/dist/cable/index.js.map +1 -1
  5. package/dist/controllers/accordion_controller.js.map +1 -1
  6. package/dist/controllers/alert_dialog_controller.js +17 -7
  7. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  8. package/dist/controllers/announcer_controller.js +1 -1
  9. package/dist/controllers/announcer_controller.js.map +1 -1
  10. package/dist/controllers/auto_submit_controller.js.map +1 -1
  11. package/dist/controllers/avatar_controller.js.map +1 -1
  12. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  13. package/dist/controllers/bulk_select_controller.js +2 -2
  14. package/dist/controllers/bulk_select_controller.js.map +1 -1
  15. package/dist/controllers/calendar_controller.js.map +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.js.map +1 -1
  19. package/dist/controllers/collapsible_controller.d.ts +1 -1
  20. package/dist/controllers/collapsible_controller.js +1 -1
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +1 -1
  23. package/dist/controllers/color_picker_controller.js.map +1 -1
  24. package/dist/controllers/combobox_controller.js.map +1 -1
  25. package/dist/controllers/command_palette_controller.js +15 -5
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  28. package/dist/controllers/confirm_controller.js +15 -5
  29. package/dist/controllers/confirm_controller.js.map +1 -1
  30. package/dist/controllers/context_menu_controller.d.ts +5 -4
  31. package/dist/controllers/context_menu_controller.js.map +1 -1
  32. package/dist/controllers/count_up_controller.js.map +1 -1
  33. package/dist/controllers/countdown_controller.js +1 -1
  34. package/dist/controllers/countdown_controller.js.map +1 -1
  35. package/dist/controllers/data_grid_controller.js +1 -1
  36. package/dist/controllers/data_grid_controller.js.map +1 -1
  37. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  38. package/dist/controllers/dialog_controller.js +15 -5
  39. package/dist/controllers/dialog_controller.js.map +1 -1
  40. package/dist/controllers/direct_upload_controller.d.ts +2 -2
  41. package/dist/controllers/direct_upload_controller.js +1 -5
  42. package/dist/controllers/direct_upload_controller.js.map +1 -1
  43. package/dist/controllers/dismissible_controller.js.map +1 -1
  44. package/dist/controllers/drawer_controller.d.ts +2 -2
  45. package/dist/controllers/drawer_controller.js +17 -7
  46. package/dist/controllers/drawer_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 +5 -0
  50. package/dist/controllers/focus_controller.js +45 -7
  51. package/dist/controllers/focus_controller.js.map +1 -1
  52. package/dist/controllers/form_field_controller.js +1 -1
  53. package/dist/controllers/form_field_controller.js.map +1 -1
  54. package/dist/controllers/form_validation_controller.js.map +1 -1
  55. package/dist/controllers/frame_loading_controller.js.map +1 -1
  56. package/dist/controllers/highlight_controller.js.map +1 -1
  57. package/dist/controllers/input_mask_controller.js.map +1 -1
  58. package/dist/controllers/intersection_controller.d.ts +4 -3
  59. package/dist/controllers/intersection_controller.js.map +1 -1
  60. package/dist/controllers/lazy_frame_controller.d.ts +2 -2
  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/masonry_controller.d.ts +3 -1
  64. package/dist/controllers/masonry_controller.js.map +1 -1
  65. package/dist/controllers/menu_controller.d.ts +1 -1
  66. package/dist/controllers/menu_controller.js.map +1 -1
  67. package/dist/controllers/menubar_controller.js +2 -2
  68. package/dist/controllers/menubar_controller.js.map +1 -1
  69. package/dist/controllers/meter_controller.d.ts +1 -1
  70. package/dist/controllers/meter_controller.js.map +1 -1
  71. package/dist/controllers/multi_select_controller.js +1 -1
  72. package/dist/controllers/multi_select_controller.js.map +1 -1
  73. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  74. package/dist/controllers/nested_form_controller.js.map +1 -1
  75. package/dist/controllers/number_input_controller.js.map +1 -1
  76. package/dist/controllers/otp_controller.js.map +1 -1
  77. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  78. package/dist/controllers/overflow_menu_controller.js +2 -2
  79. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  80. package/dist/controllers/pagination_controller.d.ts +1 -1
  81. package/dist/controllers/pagination_controller.js.map +1 -1
  82. package/dist/controllers/password_reveal_controller.d.ts +27 -2
  83. package/dist/controllers/password_reveal_controller.js +102 -3
  84. package/dist/controllers/password_reveal_controller.js.map +1 -1
  85. package/dist/controllers/password_strength_controller.d.ts +71 -17
  86. package/dist/controllers/password_strength_controller.js +287 -42
  87. package/dist/controllers/password_strength_controller.js.map +1 -1
  88. package/dist/controllers/pointer_drag_controller.d.ts +23 -5
  89. package/dist/controllers/pointer_drag_controller.js +64 -18
  90. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  91. package/dist/controllers/popover_controller.js.map +1 -1
  92. package/dist/controllers/portal_controller.d.ts +17 -4
  93. package/dist/controllers/portal_controller.js +32 -11
  94. package/dist/controllers/portal_controller.js.map +1 -1
  95. package/dist/controllers/preview_guard_controller.d.ts +22 -14
  96. package/dist/controllers/preview_guard_controller.js +180 -20
  97. package/dist/controllers/preview_guard_controller.js.map +1 -1
  98. package/dist/controllers/progress_controller.js.map +1 -1
  99. package/dist/controllers/radio_group_controller.js.map +1 -1
  100. package/dist/controllers/range_slider_controller.js.map +1 -1
  101. package/dist/controllers/rating_controller.js.map +1 -1
  102. package/dist/controllers/read_more_controller.js.map +1 -1
  103. package/dist/controllers/relative_time_controller.js.map +1 -1
  104. package/dist/controllers/reset_before_cache_controller.d.ts +1 -2
  105. package/dist/controllers/reset_before_cache_controller.js.map +1 -1
  106. package/dist/controllers/resizable_controller.d.ts +1 -1
  107. package/dist/controllers/resizable_controller.js +1 -1
  108. package/dist/controllers/resizable_controller.js.map +1 -1
  109. package/dist/controllers/roving_controller.d.ts +17 -4
  110. package/dist/controllers/roving_controller.js +127 -14
  111. package/dist/controllers/roving_controller.js.map +1 -1
  112. package/dist/controllers/scroll_area_controller.js.map +1 -1
  113. package/dist/controllers/scroll_restore_controller.d.ts +33 -11
  114. package/dist/controllers/scroll_restore_controller.js +127 -27
  115. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  116. package/dist/controllers/scroll_visibility_controller.d.ts +42 -10
  117. package/dist/controllers/scroll_visibility_controller.js +167 -14
  118. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  119. package/dist/controllers/scrollspy_controller.js.map +1 -1
  120. package/dist/controllers/separator_controller.js.map +1 -1
  121. package/dist/controllers/sidebar_controller.js +15 -5
  122. package/dist/controllers/sidebar_controller.js.map +1 -1
  123. package/dist/controllers/skeleton_controller.js.map +1 -1
  124. package/dist/controllers/slider_controller.js.map +1 -1
  125. package/dist/controllers/spinner_controller.js.map +1 -1
  126. package/dist/controllers/step_indicator_controller.js +0 -5
  127. package/dist/controllers/step_indicator_controller.js.map +1 -1
  128. package/dist/controllers/stick_to_bottom_controller.d.ts +26 -9
  129. package/dist/controllers/stick_to_bottom_controller.js +149 -28
  130. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  131. package/dist/controllers/submit_once_controller.js.map +1 -1
  132. package/dist/controllers/switch_controller.js.map +1 -1
  133. package/dist/controllers/tabs_controller.js.map +1 -1
  134. package/dist/controllers/tags_input_controller.js.map +1 -1
  135. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  136. package/dist/controllers/theme_controller.d.ts +32 -6
  137. package/dist/controllers/theme_controller.js +151 -40
  138. package/dist/controllers/theme_controller.js.map +1 -1
  139. package/dist/controllers/time_picker_controller.js.map +1 -1
  140. package/dist/controllers/toast_controller.d.ts +2 -2
  141. package/dist/controllers/toast_controller.js +2 -2
  142. package/dist/controllers/toast_controller.js.map +1 -1
  143. package/dist/controllers/toggle_group_controller.js.map +1 -1
  144. package/dist/controllers/toolbar_controller.js.map +1 -1
  145. package/dist/controllers/transition_controller.d.ts +11 -7
  146. package/dist/controllers/transition_controller.js +79 -15
  147. package/dist/controllers/transition_controller.js.map +1 -1
  148. package/dist/controllers/tree_view_controller.js.map +1 -1
  149. package/dist/index.d.ts +6 -3
  150. package/dist/index.js +1188 -318
  151. package/dist/index.js.map +1 -1
  152. package/dist/inspector/cli.d.ts +3 -4
  153. package/dist/inspector/cli.js +1 -0
  154. package/dist/inspector/cli.js.map +1 -1
  155. package/dist/inspector/cli_bin.js +6 -5
  156. package/dist/inspector/cli_bin.js.map +1 -1
  157. package/dist/inspector/examples.json +6 -6
  158. package/dist/inspector/manifest.json +15 -32
  159. package/dist/positioning/index.d.ts +18 -4
  160. package/dist/positioning/index.js +84 -25
  161. package/dist/positioning/index.js.map +1 -1
  162. package/package.json +6 -6
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/focus_candidate.ts","../../src/utils/half_width.ts","../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported for the consumer that needs this rule *without* the rest of\n * {@link canTakeFocus}: `toolbar` bounds its own `hidden` walk at the toolbar\n * root, which is a different rule, so it composes the two itself.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the three conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","/** The distance between a full-width ASCII code point and its half-width twin. */\nconst FULL_WIDTH_SHIFT = 0xfee0;\n\n/**\n * The half-width form of `char`, or `char` itself when it has none.\n *\n * An IME confirming in full-width mode produces U+FF01–U+FF5E (and U+3000 for\n * the space) where the page means ASCII — the digits of a phone number, the\n * letters of a code, the separators between them. Reading those as their ASCII\n * equivalents keeps confirmed text usable instead of discarding it. Characters\n * outside that block, including kana and astral code points, are returned\n * unchanged, so this never rewrites text a consumer meant to keep as typed.\n */\nexport function halfWidthChar(char: string): string {\n if (char >= \"!\" && char <= \"~\") {\n return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);\n }\n return char === \" \" ? \" \" : char;\n}\n\n/** Every full-width ASCII character in `text` rewritten by {@link halfWidthChar}. */\nexport function toHalfWidth(text: string): string {\n let out = \"\";\n for (const char of text) out += halfWidthChar(char);\n return out;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { inheritsFieldsetDisabled } from \"../utils/focus_candidate\";\nimport { halfWidthChar } from \"../utils/half_width\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/** Attribute marking the hidden raw-value sink; its value may name the paired input's `id`. */\nconst UNMASK_ATTR = \"data-stimeo--input-mask-unmask\";\n\n/** Which neighbour a deleting keystroke aimed at, when the platform names one. */\ntype Deletion = \"backward\" | \"forward\";\n\n/** Who moved the value: the user editing the field, or the controller re-deciding it. */\ntype Cause = \"edit\" | \"reconcile\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * The form of `char` this token takes: as typed when the token accepts it, else\n * its half-width form when *that* is what the token accepts, else `null` for a\n * rejected character. Trying the character as typed first keeps a token written\n * for full-width text (or for text an IME leaves as kana) authoritative.\n */\nfunction acceptedForm(regex: RegExp, char: string): string | null {\n if (regex.test(char)) return char;\n const half = halfWidthChar(char);\n return half !== char && regex.test(half) ? half : null;\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. A character an IME confirmed in\n * full-width form fills a token — or consumes a literal — when its half-width\n * form is the one the mask asked for, and the half-width form is what the output\n * carries. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one fills this token (or input runs out).\n let taken: string | null = null;\n while (valueIndex < value.length) {\n taken = acceptedForm(regex, value[valueIndex] ?? \"\");\n if (taken !== null) break;\n valueIndex += 1;\n }\n if (taken === null) continue;\n masked += taken;\n unmasked += taken;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n // The literal is already in the output, so the typed character only has to\n // be the one that renders it — in either width.\n if (halfWidthChar(value[valueIndex] ?? \"\") === halfWidthChar(patternChar)) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * The token map a `tokens` declaration selects: the defaults with the declared\n * single-char regex sources merged over. A declaration that is not a JSON object\n * of string sources falls back to the defaults, and an individual source that is\n * not a valid regex is skipped, so a broken declaration keeps the field working\n * instead of taking the mask (and every later keystroke) down with it.\n */\nfunction compileTokens(declaration: string): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {\n try {\n map.set(key, new RegExp(`^(?:${source})$`));\n } catch {\n /* skip an invalid token regex rather than breaking the whole mask */\n }\n }\n return map;\n}\n\n/** The `{ token: source }` pairs a declaration holds; anything else reads as none. */\nfunction parseTokens(declaration: string): Record<string, string> {\n let parsed: unknown;\n try {\n parsed = JSON.parse(declaration);\n } catch {\n return {};\n }\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return {};\n\n const tokens: Record<string, string> = {};\n for (const [key, source] of Object.entries(parsed)) {\n if (typeof source === \"string\") tokens[key] = source;\n }\n return tokens;\n}\n\n/** The direction an `input` event deleted in, or `null` when it is not a deletion. */\nfunction deletionOf(event: Event | undefined): Deletion | null {\n const inputType = event instanceof InputEvent ? event.inputType : \"\";\n if (!inputType.startsWith(\"delete\")) return null;\n // Word, line, and content deletions all name their direction; the ones that\n // name none (a cut, a drag-out) took a selection, so read them as backward.\n return inputType.endsWith(\"Forward\") ? \"forward\" : \"backward\";\n}\n\n/** The offset of the `n`-th token char in a masked string, or `-1` when there is none. */\nfunction tokenCharOffset(tokenFlags: readonly boolean[], n: number): number {\n let seen = 0;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) return i;\n }\n return -1;\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; Currency Input owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * `change` and `reconcile` dispatch `{ masked, unmasked, complete }`.\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first among\n * the associated form's own controls (an explicit pairing by the sink attribute's\n * value naming the input's `id` wins; otherwise the closest ancestor's value-less\n * sink) — when one form holds several masked inputs, wrap each input+sink pair in a\n * container or pair them by `id`.\n *\n * Formatting never steals keys: it rejects disallowed characters silently and keeps\n * the caret on insert, Backspace, and range replacement (WCAG 2.2 3.3.2 / 1.3.5;\n * the expected format is the consumer's `aria-describedby`). The caret anchors on\n * the token slots the text before it fills, so a rejected character leaves it\n * exactly where it was. Backspace and Delete always progress: when the keystroke\n * only removed literals the mask re-inserts, the significant character it aimed at\n * goes instead. Events fired mid-IME-composition are ignored; the confirmed text is\n * formatted once on `compositionend`.\n *\n * It reflects `data-mask-complete` / `data-mask-empty`, dispatches\n * `stimeo--input-mask:change` only when a user edit moves the committed value, and\n * `stimeo--input-mask:reconcile` when the controller itself decides that value —\n * a server-rendered or restored value normalized on connection, or a re-format\n * after `pattern` / `tokens` / `unmaskToHidden` changed. A malformed `tokens`\n * declaration falls back to the default tokens: it is parsed once in\n * `tokensValueChanged`, and the hot path only ever sees the validated map. A field\n * the user cannot edit (`readonly`, `disabled`, or inside a disabled `<fieldset>`)\n * keeps the value the page authored — the sink and the state hooks still follow it,\n * but the field itself is never rewritten. The composition listener is released on\n * `disconnect()`.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: String, default: \"\" },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare patternValue: string;\n declare tokensValue: string;\n declare unmaskToHiddenValue: boolean;\n\n /** The value this controller last committed, and the baseline both events compare. */\n #lastValue: string | null = null;\n #started = false;\n\n /** Validated token map; the hot path never parses the `tokens` declaration. */\n #tokens = compileTokens(\"\");\n\n /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */\n readonly #composition = new CompositionTracker({ onEnd: () => this.#reformat(\"edit\") });\n\n /** Re-parses the declaration and re-formats under the tokens it now selects. */\n tokensValueChanged(): void {\n this.#tokens = compileTokens(this.tokensValue);\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n patternValueChanged(): void {\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */\n unmaskToHiddenValueChanged(): void {\n if (!this.#started) return;\n if (!this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = \"\";\n }\n this.#reformat(\"reconcile\");\n }\n\n override connect(): void {\n this.#started = true;\n this.#composition.observe(this.element);\n // Seed the baseline from the DOM so a value that is already masked stays\n // silent, then re-format any server-rendered/restored value.\n this.#lastValue = this.element.value;\n this.#reformat(\"reconcile\");\n }\n\n override disconnect(): void {\n this.#started = false;\n this.#composition.disconnect();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(event?: Event): void {\n if (this.#composition.isComposing(event as InputEvent | undefined)) return;\n this.#reformat(\"edit\", deletionOf(event));\n }\n\n /**\n * Core reformat: mask the current value, restore the caret, sync the sink and\n * the state hooks, and report a moved value under the event `cause` selects.\n */\n #reformat(cause: Cause, deletion: Deletion | null = null): void {\n const input = this.element;\n const raw = input.value;\n\n // No pattern → act as a pass-through. Without this branch `applyMask` would\n // produce an empty string and blank a misconfigured field's value; the hooks\n // still describe the field so a pattern removed at runtime cannot leave a\n // stale `data-mask-complete` behind.\n if (this.patternValue === \"\") {\n this.#flag(\"data-mask-complete\", false);\n this.#flag(\"data-mask-empty\", raw.length === 0);\n this.#lastValue = raw;\n return;\n }\n\n const caret = input.selectionStart ?? raw.length;\n let anchor = this.#significantBefore(raw.slice(0, caret));\n let result = applyMask(raw, this.patternValue, this.#tokens);\n\n // A deletion that only took literals the mask re-inserts would land back on\n // the value it started from, so that keystroke could never delete anything:\n // drop the significant character it aimed at instead.\n if (deletion !== null && result.masked === this.#lastValue) {\n const target = deletion === \"backward\" ? anchor : anchor + 1;\n const offset = tokenCharOffset(result.tokenFlags, target);\n if (offset >= 0) {\n anchor = target - 1;\n result = applyMask(\n result.masked.slice(0, offset) + result.masked.slice(offset + 1),\n this.patternValue,\n this.#tokens,\n );\n }\n }\n\n // The value of a field the user cannot edit belongs to the page: publish the\n // derived outputs against it, but never rewrite it and never report a move.\n if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {\n this.#publish(result, raw);\n this.#lastValue = raw;\n return;\n }\n\n input.value = result.masked;\n this.#restoreCaret(result.tokenFlags, anchor);\n this.#publish(result, result.masked);\n\n if (result.masked === this.#lastValue) return;\n this.#lastValue = result.masked;\n const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };\n if (cause === \"edit\") this.dispatch(\"change\", { detail });\n else this.dispatch(\"reconcile\", { detail });\n }\n\n /** Syncs the raw-value sink and the state hooks for the `shown` field text. */\n #publish(result: MaskResult, shown: string): void {\n if (this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", shown.length === 0);\n }\n\n /**\n * How many token slots the text before the caret fills. Masking that prefix is\n * what makes a rejected character — or a literal that also matches a token —\n * count exactly as the rendering counts it, so the caret cannot drift.\n */\n #significantBefore(prefix: string): number {\n return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n const offset = tokenCharOffset(tokenFlags, n);\n if (offset < 0) {\n position = tokenFlags.length;\n } else {\n let j = offset + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n }\n }\n try {\n this.element.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`).\n * 2. **Nearest container** — otherwise, walking up from the input, the first\n * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each\n * find their own sink, and the single form-level sink keeps working\n * unchanged. A sink claimed by another input's id is never matched here.\n *\n * Both steps only ever consider the associated form's own controls, so a sink\n * belonging to a different form (or to none) is never written, while one the\n * `form` attribute associates from elsewhere in the document still resolves —\n * for either the input or the sink, neither of which needs to contain or be\n * contained by the form. A form-less input reads the document instead.\n */\n #resolveSink(): HTMLInputElement | null {\n const candidates = this.#sinkCandidates();\n const id = this.element.id;\n if (id.length > 0) {\n const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);\n if (paired) return paired;\n }\n\n // A bare (value-less) attribute is unclaimed, so a sink naming another\n // input's id can never be taken by this fallback.\n const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === \"\");\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const ancestor = node;\n const sink = free.find((candidate) => ancestor.contains(candidate));\n if (sink) return sink;\n }\n return null;\n }\n\n /**\n * Every sink the input's form owns, in document order — one collection read per\n * resolution, so the cost tracks the form's own controls and not the depth of\n * the markup around it. `form.elements` lists controls the `form` attribute\n * associates from anywhere in the document, and lists nothing another form owns.\n */\n #sinkCandidates(): HTMLInputElement[] {\n const form = this.element.form;\n if (form === null) {\n return Array.from(document.querySelectorAll<HTMLInputElement>(`input[${UNMASK_ATTR}]`));\n }\n return Array.from(form.elements).filter(\n (element): element is HTMLInputElement =>\n element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR),\n );\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/focus_candidate.ts","../../src/utils/half_width.ts","../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported on its own for callers that need the disabled-inheritance rule without\n * the rest of {@link canTakeFocus} — a control's availability check, or a `hidden`\n * walk bounded at a widget root, which is a different rule to compose with.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the three conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","/** The distance between a full-width ASCII code point and its half-width twin. */\nconst FULL_WIDTH_SHIFT = 0xfee0;\n\n/**\n * The half-width form of `char`, or `char` itself when it has none.\n *\n * An IME confirming in full-width mode produces U+FF01–U+FF5E (and U+3000 for\n * the space) where the page means ASCII — the digits of a phone number, the\n * letters of a code, the separators between them. Reading those as their ASCII\n * equivalents keeps confirmed text usable instead of discarding it. Characters\n * outside that block, including kana and astral code points, are returned\n * unchanged, so this never rewrites text a consumer meant to keep as typed.\n */\nexport function halfWidthChar(char: string): string {\n if (char >= \"!\" && char <= \"~\") {\n return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);\n }\n return char === \" \" ? \" \" : char;\n}\n\n/** Every full-width ASCII character in `text` rewritten by {@link halfWidthChar}. */\nexport function toHalfWidth(text: string): string {\n let out = \"\";\n for (const char of text) out += halfWidthChar(char);\n return out;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { inheritsFieldsetDisabled } from \"../utils/focus_candidate\";\nimport { halfWidthChar } from \"../utils/half_width\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/** Attribute marking the hidden raw-value sink; its value may name the paired input's `id`. */\nconst UNMASK_ATTR = \"data-stimeo--input-mask-unmask\";\n\n/** Which neighbour a deleting keystroke aimed at, when the platform names one. */\ntype Deletion = \"backward\" | \"forward\";\n\n/** Who moved the value: the user editing the field, or the controller re-deciding it. */\ntype Cause = \"edit\" | \"reconcile\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * The form of `char` this token takes: as typed when the token accepts it, else\n * its half-width form when *that* is what the token accepts, else `null` for a\n * rejected character. Trying the character as typed first keeps a token written\n * for full-width text (or for text an IME leaves as kana) authoritative.\n */\nfunction acceptedForm(regex: RegExp, char: string): string | null {\n if (regex.test(char)) return char;\n const half = halfWidthChar(char);\n return half !== char && regex.test(half) ? half : null;\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. A character an IME confirmed in\n * full-width form fills a token — or consumes a literal — when its half-width\n * form is the one the mask asked for, and the half-width form is what the output\n * carries. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one fills this token (or input runs out).\n let taken: string | null = null;\n while (valueIndex < value.length) {\n taken = acceptedForm(regex, value[valueIndex] ?? \"\");\n if (taken !== null) break;\n valueIndex += 1;\n }\n if (taken === null) continue;\n masked += taken;\n unmasked += taken;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n // The literal is already in the output, so the typed character only has to\n // be the one that renders it — in either width.\n if (halfWidthChar(value[valueIndex] ?? \"\") === halfWidthChar(patternChar)) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * The token map a `tokens` declaration selects: the defaults with the declared\n * single-char regex sources merged over. A declaration that is not a JSON object\n * of string sources falls back to the defaults, and an individual source that is\n * not a valid regex is skipped, so a broken declaration keeps the field working\n * instead of taking the mask (and every later keystroke) down with it.\n */\nfunction compileTokens(declaration: string): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {\n try {\n map.set(key, new RegExp(`^(?:${source})$`));\n } catch {\n /* skip an invalid token regex rather than breaking the whole mask */\n }\n }\n return map;\n}\n\n/** The `{ token: source }` pairs a declaration holds; anything else reads as none. */\nfunction parseTokens(declaration: string): Record<string, string> {\n let parsed: unknown;\n try {\n parsed = JSON.parse(declaration);\n } catch {\n return {};\n }\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return {};\n\n const tokens: Record<string, string> = {};\n for (const [key, source] of Object.entries(parsed)) {\n if (typeof source === \"string\") tokens[key] = source;\n }\n return tokens;\n}\n\n/** The direction an `input` event deleted in, or `null` when it is not a deletion. */\nfunction deletionOf(event: Event | undefined): Deletion | null {\n const inputType = event instanceof InputEvent ? event.inputType : \"\";\n if (!inputType.startsWith(\"delete\")) return null;\n // Word, line, and content deletions all name their direction; the ones that\n // name none (a cut, a drag-out) took a selection, so read them as backward.\n return inputType.endsWith(\"Forward\") ? \"forward\" : \"backward\";\n}\n\n/** The offset of the `n`-th token char in a masked string, or `-1` when there is none. */\nfunction tokenCharOffset(tokenFlags: readonly boolean[], n: number): number {\n let seen = 0;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) return i;\n }\n return -1;\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; Currency Input owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * `change` and `reconcile` dispatch `{ masked, unmasked, complete }`.\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first among\n * the associated form's own controls (an explicit pairing by the sink attribute's\n * value naming the input's `id` wins; otherwise the closest ancestor's value-less\n * sink) — when one form holds several masked inputs, wrap each input+sink pair in a\n * container or pair them by `id`.\n *\n * Formatting never steals keys: it rejects disallowed characters silently and keeps\n * the caret on insert, Backspace, and range replacement (WCAG 2.2 3.3.2 / 1.3.5;\n * the expected format is the consumer's `aria-describedby`). The caret anchors on\n * the token slots the text before it fills, so a rejected character leaves it\n * exactly where it was. Backspace and Delete always progress: when the keystroke\n * only removed literals the mask re-inserts, the significant character it aimed at\n * goes instead. Events fired mid-IME-composition are ignored; the confirmed text is\n * formatted once on `compositionend`.\n *\n * It reflects `data-mask-complete` / `data-mask-empty`, dispatches\n * `stimeo--input-mask:change` only when a user edit moves the committed value, and\n * `stimeo--input-mask:reconcile` when the controller itself decides that value —\n * a server-rendered or restored value normalized on connection, or a re-format\n * after `pattern` / `tokens` / `unmaskToHidden` changed. A malformed `tokens`\n * declaration falls back to the default tokens: it is parsed once in\n * `tokensValueChanged`, and the hot path only ever sees the validated map. A field\n * the user cannot edit (`readonly`, `disabled`, or inside a disabled `<fieldset>`)\n * keeps the value the page authored — the sink and the state hooks still follow it,\n * but the field itself is never rewritten. The composition listener is released on\n * `disconnect()`.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: String, default: \"\" },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare patternValue: string;\n declare tokensValue: string;\n declare unmaskToHiddenValue: boolean;\n\n /** The value this controller last committed, and the baseline both events compare. */\n #lastValue: string | null = null;\n #started = false;\n\n /** Validated token map; the hot path never parses the `tokens` declaration. */\n #tokens = compileTokens(\"\");\n\n /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */\n readonly #composition = new CompositionTracker({ onEnd: () => this.#reformat(\"edit\") });\n\n /** Re-parses the declaration and re-formats under the tokens it now selects. */\n tokensValueChanged(): void {\n this.#tokens = compileTokens(this.tokensValue);\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n patternValueChanged(): void {\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */\n unmaskToHiddenValueChanged(): void {\n if (!this.#started) return;\n if (!this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = \"\";\n }\n this.#reformat(\"reconcile\");\n }\n\n override connect(): void {\n this.#started = true;\n this.#composition.observe(this.element);\n // Seed the baseline from the DOM so a value that is already masked stays\n // silent, then re-format any server-rendered/restored value.\n this.#lastValue = this.element.value;\n this.#reformat(\"reconcile\");\n }\n\n override disconnect(): void {\n this.#started = false;\n this.#composition.disconnect();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(event?: Event): void {\n if (this.#composition.isComposing(event as InputEvent | undefined)) return;\n this.#reformat(\"edit\", deletionOf(event));\n }\n\n /**\n * Core reformat: mask the current value, restore the caret, sync the sink and\n * the state hooks, and report a moved value under the event `cause` selects.\n */\n #reformat(cause: Cause, deletion: Deletion | null = null): void {\n const input = this.element;\n const raw = input.value;\n\n // No pattern → act as a pass-through. Without this branch `applyMask` would\n // produce an empty string and blank a misconfigured field's value; the hooks\n // still describe the field so a pattern removed at runtime cannot leave a\n // stale `data-mask-complete` behind.\n if (this.patternValue === \"\") {\n this.#flag(\"data-mask-complete\", false);\n this.#flag(\"data-mask-empty\", raw.length === 0);\n this.#lastValue = raw;\n return;\n }\n\n const caret = input.selectionStart ?? raw.length;\n let anchor = this.#significantBefore(raw.slice(0, caret));\n let result = applyMask(raw, this.patternValue, this.#tokens);\n\n // A deletion that only took literals the mask re-inserts would land back on\n // the value it started from, so that keystroke could never delete anything:\n // drop the significant character it aimed at instead.\n if (deletion !== null && result.masked === this.#lastValue) {\n const target = deletion === \"backward\" ? anchor : anchor + 1;\n const offset = tokenCharOffset(result.tokenFlags, target);\n if (offset >= 0) {\n anchor = target - 1;\n result = applyMask(\n result.masked.slice(0, offset) + result.masked.slice(offset + 1),\n this.patternValue,\n this.#tokens,\n );\n }\n }\n\n // The value of a field the user cannot edit belongs to the page: publish the\n // derived outputs against it, but never rewrite it and never report a move.\n if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {\n this.#publish(result, raw);\n this.#lastValue = raw;\n return;\n }\n\n input.value = result.masked;\n this.#restoreCaret(result.tokenFlags, anchor);\n this.#publish(result, result.masked);\n\n if (result.masked === this.#lastValue) return;\n this.#lastValue = result.masked;\n const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };\n if (cause === \"edit\") this.dispatch(\"change\", { detail });\n else this.dispatch(\"reconcile\", { detail });\n }\n\n /** Syncs the raw-value sink and the state hooks for the `shown` field text. */\n #publish(result: MaskResult, shown: string): void {\n if (this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", shown.length === 0);\n }\n\n /**\n * How many token slots the text before the caret fills. Masking that prefix is\n * what makes a rejected character — or a literal that also matches a token —\n * count exactly as the rendering counts it, so the caret cannot drift.\n */\n #significantBefore(prefix: string): number {\n return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n const offset = tokenCharOffset(tokenFlags, n);\n if (offset < 0) {\n position = tokenFlags.length;\n } else {\n let j = offset + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n }\n }\n try {\n this.element.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`).\n * 2. **Nearest container** — otherwise, walking up from the input, the first\n * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each\n * find their own sink, and the single form-level sink keeps working\n * unchanged. A sink claimed by another input's id is never matched here.\n *\n * Both steps only ever consider the associated form's own controls, so a sink\n * belonging to a different form (or to none) is never written, while one the\n * `form` attribute associates from elsewhere in the document still resolves —\n * for either the input or the sink, neither of which needs to contain or be\n * contained by the form. A form-less input reads the document instead.\n */\n #resolveSink(): HTMLInputElement | null {\n const candidates = this.#sinkCandidates();\n const id = this.element.id;\n if (id.length > 0) {\n const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);\n if (paired) return paired;\n }\n\n // A bare (value-less) attribute is unclaimed, so a sink naming another\n // input's id can never be taken by this fallback.\n const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === \"\");\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const ancestor = node;\n const sink = free.find((candidate) => ancestor.contains(candidate));\n if (sink) return sink;\n }\n return null;\n }\n\n /**\n * Every sink the input's form owns, in document order — one collection read per\n * resolution, so the cost tracks the form's own controls and not the depth of\n * the markup around it. `form.elements` lists controls the `form` attribute\n * associates from anywhere in the document, and lists nothing another form owns.\n */\n #sinkCandidates(): HTMLInputElement[] {\n const form = this.element.form;\n if (form === null) {\n return Array.from(document.querySelectorAll<HTMLInputElement>(`input[${UNMASK_ATTR}]`));\n }\n return Array.from(form.elements).filter(\n (element): element is HTMLInputElement =>\n element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR),\n );\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
@@ -3,9 +3,10 @@ import { Controller } from '@hotwired/stimulus';
3
3
  /**
4
4
  * Headless **intersection primitive**: a thin declarative wrapper over
5
5
  * {@link IntersectionObserver} that turns viewport visibility into events and
6
- * state hooks. It is the scroll-triggered building block that infinite-scroll,
7
- * reading-progress, count-up ("animate when visible") and smart sticky headers
8
- * compose from, without each writing its own observer. No APG widget — a pure
6
+ * state hooks. It is the scroll-triggered building block for
7
+ * scroll-driven behavior — loading more on approach, "animate when visible"
8
+ * (compose it with `stimeo--count-up`), progress and sticky-header work — so a
9
+ * consumer does not write its own observer. No APG widget — a pure
9
10
  * state-detection utility. Core (zero dependencies).
10
11
  *
11
12
  * Markup contract (identifier: `stimeo--intersection`):
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block that infinite-scroll,\n * reading-progress, count-up (\"animate when visible\") and smart sticky headers\n * compose from, without each writing its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`), `exit` when it\n * leaves (detail carries `position`: `\"before\"` = scrolled past the root's\n * start edge, `\"after\"` = still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). Without `IntersectionObserver` (very\n * old browsers) the controller stays inert — consumers keep whatever no-JS\n * fallback their markup provides. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#effectiveThreshold = this.#clampedThreshold();\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n this.dispatch(\"enter\", { detail: { ratio } });\n // One-shot mode: the enter fired; stop observing and leave the hooks in\n // their final state (`data-intersecting=\"true\"` marks it for reconnects).\n if (this.onceValue) this.#watcher.stop();\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: isBeforeRootStart(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block for\n * scroll-driven behavior — loading more on approach, \"animate when visible\"\n * (compose it with `stimeo--count-up`), progress and sticky-header work — so a\n * consumer does not write its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`), `exit` when it\n * leaves (detail carries `position`: `\"before\"` = scrolled past the root's\n * start edge, `\"after\"` = still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). Without `IntersectionObserver` (very\n * old browsers) the controller stays inert — consumers keep whatever no-JS\n * fallback their markup provides. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#effectiveThreshold = this.#clampedThreshold();\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n this.dispatch(\"enter\", { detail: { ratio } });\n // One-shot mode: the enter fired; stop observing and leave the hooks in\n // their final state (`data-intersecting=\"true\"` marks it for reconnects).\n if (this.onceValue) this.#watcher.stop();\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: isBeforeRootStart(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
@@ -3,8 +3,8 @@ import { Controller } from '@hotwired/stimulus';
3
3
  /**
4
4
  * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport
5
5
  * (or focus reaches it), to keep the initial render light. Turbo's own `loading="lazy"`
6
- * fires on *render*, not on viewport entry, so this drives an explicit `IntersectionObserver`
7
- * with a configurable `rootMargin` for early loading, plus a focus fallback so keyboard /
6
+ * observes with no margin, so the frame loads only once it is already in view; this
7
+ * drives an explicit `IntersectionObserver` with a configurable `rootMargin`, plus a focus fallback so keyboard /
8
8
  * assistive-tech users trigger the load too (no APG pattern).
9
9
  *
10
10
  * Markup contract (identifier: `stimeo--lazy-frame`):
@@ -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, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } 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 * fires on *render*, not on viewport entry, so this drives an explicit `IntersectionObserver`\n * with a configurable `rootMargin` for early loading, 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` — which starts the Turbo load — marks `data-lazy-loaded`, and\n * emits `load`. With `once` (default) it then stops observing; otherwise re-entry asks\n * Turbo to `reload()` the frame.\n *\n * `load` dispatches `{ url }`.\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 Frame Loading State. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is honored on a Turbo cache\n * restore — a frame that already loaded is not observed again). The `IntersectionObserver`\n * and focus listener are released once loaded (when `once`) and on `disconnect()` (Turbo\n * 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 #loaded = false;\n\n /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */\n readonly #onFocus = (): void => this.#trigger();\n\n override connect(): void {\n // A cache restore may bring back an already-loaded frame; respect it, do not reload.\n if (this.element.hasAttribute(\"data-lazy-loaded\")) {\n this.#loaded = true;\n return;\n }\n if (!this.urlValue) return;\n\n this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n override disconnect(): void {\n this.#stopObserving();\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n this.#trigger();\n return;\n }\n }\n }\n\n #trigger(): void {\n if (!this.#loaded) this.#load();\n else if (!this.onceValue) this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\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 if (this.onceValue) this.#stopObserving();\n }\n\n /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */\n #reload(): void {\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Only signal `load` when a reload actually happens. On a non-`<turbo-frame>`\n // host (no `reload()`), firing it would announce a load that never occurred.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\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":"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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, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } 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` — which starts the Turbo load — marks `data-lazy-loaded`, and\n * emits `load`. With `once` (default) it then stops observing; otherwise re-entry asks\n * Turbo to `reload()` the frame.\n *\n * `load` dispatches `{ url }`.\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 Frame Loading State. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is honored on a Turbo cache\n * restore — a frame that already loaded is not observed again). The `IntersectionObserver`\n * and focus listener are released once loaded (when `once`) and on `disconnect()` (Turbo\n * 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 #loaded = false;\n\n /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */\n readonly #onFocus = (): void => this.#trigger();\n\n override connect(): void {\n // A cache restore may bring back an already-loaded frame; respect it, do not reload.\n if (this.element.hasAttribute(\"data-lazy-loaded\")) {\n this.#loaded = true;\n return;\n }\n if (!this.urlValue) return;\n\n this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n override disconnect(): void {\n this.#stopObserving();\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n this.#trigger();\n return;\n }\n }\n }\n\n #trigger(): void {\n if (!this.#loaded) this.#load();\n else if (!this.onceValue) this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\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 if (this.onceValue) this.#stopObserving();\n }\n\n /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */\n #reload(): void {\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Only signal `load` when a reload actually happens. On a non-`<turbo-frame>`\n // host (no `reload()`), firing it would announce a load that never occurred.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}