stimeo-ui 0.13.0 → 0.14.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.
- package/CHANGELOG.md +39 -5
- package/dist/cable/index.d.ts +1 -1
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +1 -1
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +2 -2
- package/dist/controllers/calendar_controller.js +31 -7
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.js +8 -6
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +1 -1
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +1 -1
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.js +7 -1
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.js +68 -23
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +3 -3
- package/dist/controllers/date_range_picker_controller.js +31 -7
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +2 -2
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.js +21 -10
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -1
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +7 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +3 -2
- package/dist/controllers/input_mask_controller.js +33 -12
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +10 -5
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +10 -5
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +3 -2
- package/dist/controllers/local_time_controller.js +34 -7
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +1 -1
- package/dist/controllers/masonry_controller.js +1 -1
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.js +10 -2
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.js +10 -2
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.js +8 -2
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +23 -10
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.js +10 -1
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +1 -1
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +10 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/portal_controller.js +21 -10
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +11 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +1 -1
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.js +31 -5
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/reset_before_cache_controller.js +23 -10
- package/dist/controllers/reset_before_cache_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.js +7 -2
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +7 -3
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +1 -1
- package/dist/controllers/scroll_visibility_controller.js +22 -13
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +1 -1
- package/dist/controllers/scrollspy_controller.js +11 -6
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.js +23 -12
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +10 -5
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +1 -1
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +1 -1
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +2 -3
- package/dist/controllers/theme_controller.js +23 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +11 -3
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +7 -3
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/index.js +165 -171
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +38 -1
- package/dist/inspector/cli.js +80 -7
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +103 -7
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +5 -5
- package/dist/inspector/manifest.json +127 -1
- package/package.json +9 -11
|
@@ -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 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 four 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/declared_value.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 * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\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 four 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 { compileRegExp, parseJsonObject } from \"../utils/declared_value\";\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 compiled over them.\n *\n * A declaration that is not a JSON object of string sources declares no tokens,\n * leaving the defaults. A source that is not a valid regex is answered per key:\n * a key that has a default keeps that default, and a key that has none is left\n * out so the character stays a literal. Either way the rest of the mask, and the\n * field, keep working instead of going down with the broken source.\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)) {\n const compiled = compileRegExp(source, \"exact\");\n if (compiled) map.set(key, compiled);\n }\n for (const [key, source] of Object.entries(parseTokens(declaration))) {\n const compiled = compileRegExp(source, \"exact\");\n // A broken source declares nothing: the default this key may already carry\n // is what it falls back to, and a key without one holds no token at all.\n if (compiled) map.set(key, compiled);\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 const parsed = parseJsonObject(declaration);\n if (parsed === null) 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, and a single broken source falls\n * back to that key's default: it is parsed once in `tokensValueChanged`, and the\n * 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"]}
|
|
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/intersection_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/declared_value.ts
|
|
6
|
+
function parseDeclared(raw, parse, fallback) {
|
|
7
|
+
try {
|
|
8
|
+
return parse(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
// src/utils/intersection_watcher.ts
|
|
6
15
|
function isBeforeRootStart(entry) {
|
|
7
16
|
const rect = entry.boundingClientRect;
|
|
@@ -11,11 +20,7 @@ function isBeforeRootStart(entry) {
|
|
|
11
20
|
}
|
|
12
21
|
function queryRoot(selector) {
|
|
13
22
|
if (!selector) return null;
|
|
14
|
-
|
|
15
|
-
return document.querySelector(selector);
|
|
16
|
-
} catch {
|
|
17
|
-
}
|
|
18
|
-
return null;
|
|
23
|
+
return parseDeclared(selector, (raw) => document.querySelector(raw), null);
|
|
19
24
|
}
|
|
20
25
|
var IntersectionWatcher = class {
|
|
21
26
|
#onEntries;
|
|
@@ -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 (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n try {\n return document.querySelector(selector);\n } catch {\n // Unparsable selector: fall through to the viewport below.\n }\n return null;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, 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`; detail `{ ratio }`),\n * `exit` when it leaves (detail `{ ratio, position }`, where `position` is the\n * edge it left across — `\"before\"` = upward past the root's start edge,\n * `\"after\"` = downward, 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). `threshold` is re-read when Turbo morphs\n * the attribute in place, and a `rootSelector` that does not parse observes the\n * viewport rather than leaving the element unobserved. Without\n * `IntersectionObserver` (very old browsers) the controller stays inert —\n * consumers keep whatever no-JS fallback their markup provides. The observer is\n * disconnected on `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.#observe();\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing\n * rewrites the attribute in place without a reconnect, and the line is what\n * the intersection callback compares every ratio against, so a value frozen at\n * connect time would decide `data-intersecting` wrongly for the rest of the\n * page's life. Nothing to rebuild before the first `connect()`; after a spent\n * one-shot the watcher is deliberately stopped, and re-observing would deliver\n * the current state and fire `enter` a second time.\n */\n thresholdValueChanged(): void {\n if (this.#watcher.active) this.#observe();\n }\n\n /** (Re)installs the observer from the current Values. */\n #observe(): void {\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 /**\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 // One-shot mode: the shot is spent at this transition, so stop observing\n // before the event. A handler that re-arms (the `enter` -> append ->\n // `refresh()` reflex) then finds an inactive watcher and leaves the hooks\n // in their final state — `data-intersecting=\"true\"` marks it for reconnects.\n if (this.onceValue) this.#watcher.stop();\n this.dispatch(\"enter\", { detail: { ratio } });\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#leftViaStartEdge(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Which edge the element left across, for the `exit` detail. A non-zero\n * `threshold` withdraws visibility while the element still overlaps the root,\n * so the leaving rect can straddle the start edge — the direction is the\n * element's own top against that edge, not whether it has cleared the root\n * entirely (that is what `passed` reports). An element with no layout box\n * (`display: none`, a collapsed `<details>`) is reported with an empty rect\n * that carries no position at all, so it is deliberately neither direction\n * and takes the \"still ahead\" reading.\n */\n #leftViaStartEdge(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 return rect.top < (entry.rootBounds?.top ?? 0);\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 * 0 is always observed. An observer notifies only at the lines it was given,\n * so a non-zero `threshold` on its own delivers its last callback while the\n * element is still partly visible: the element leaving for good would never be\n * reported, freezing the ratio and `data-passed` mid-departure.\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([0, 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/declared_value.ts","../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\nimport { parseDeclared } from \"./declared_value\";\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n return parseDeclared(selector, (raw) => document.querySelector(raw), null);\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** 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`; detail `{ ratio }`),\n * `exit` when it leaves (detail `{ ratio, position }`, where `position` is the\n * edge it left across — `\"before\"` = upward past the root's start edge,\n * `\"after\"` = downward, 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). `threshold` is re-read when Turbo morphs\n * the attribute in place, and a `rootSelector` that does not parse observes the\n * viewport rather than leaving the element unobserved. Without\n * `IntersectionObserver` (very old browsers) the controller stays inert —\n * consumers keep whatever no-JS fallback their markup provides. The observer is\n * disconnected on `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.#observe();\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing\n * rewrites the attribute in place without a reconnect, and the line is what\n * the intersection callback compares every ratio against, so a value frozen at\n * connect time would decide `data-intersecting` wrongly for the rest of the\n * page's life. Nothing to rebuild before the first `connect()`; after a spent\n * one-shot the watcher is deliberately stopped, and re-observing would deliver\n * the current state and fire `enter` a second time.\n */\n thresholdValueChanged(): void {\n if (this.#watcher.active) this.#observe();\n }\n\n /** (Re)installs the observer from the current Values. */\n #observe(): void {\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 /**\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 // One-shot mode: the shot is spent at this transition, so stop observing\n // before the event. A handler that re-arms (the `enter` -> append ->\n // `refresh()` reflex) then finds an inactive watcher and leaves the hooks\n // in their final state — `data-intersecting=\"true\"` marks it for reconnects.\n if (this.onceValue) this.#watcher.stop();\n this.dispatch(\"enter\", { detail: { ratio } });\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#leftViaStartEdge(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Which edge the element left across, for the `exit` detail. A non-zero\n * `threshold` withdraws visibility while the element still overlaps the root,\n * so the leaving rect can straddle the start edge — the direction is the\n * element's own top against that edge, not whether it has cleared the root\n * entirely (that is what `passed` reports). An element with no layout box\n * (`display: none`, a collapsed `<details>`) is reported with an empty rect\n * that carries no position at all, so it is deliberately neither direction\n * and takes the \"still ahead\" reading.\n */\n #leftViaStartEdge(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 return rect.top < (entry.rootBounds?.top ?? 0);\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 * 0 is always observed. An observer notifies only at the lines it was given,\n * so a non-zero `threshold` on its own delivers its last callback while the\n * element is still partly visible: the element leaving for good would never be\n * reported, freezing the ratio and `data-passed` mid-departure.\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([0, 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"]}
|
|
@@ -2,14 +2,19 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/lazy_frame_controller.ts
|
|
4
4
|
|
|
5
|
-
// src/utils/
|
|
6
|
-
function
|
|
7
|
-
if (!selector) return null;
|
|
5
|
+
// src/utils/declared_value.ts
|
|
6
|
+
function parseDeclared(raw, parse, fallback) {
|
|
8
7
|
try {
|
|
9
|
-
return
|
|
8
|
+
return parse(raw);
|
|
10
9
|
} catch {
|
|
10
|
+
return fallback;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/utils/intersection_watcher.ts
|
|
15
|
+
function queryRoot(selector) {
|
|
16
|
+
if (!selector) return null;
|
|
17
|
+
return parseDeclared(selector, (raw) => document.querySelector(raw), null);
|
|
13
18
|
}
|
|
14
19
|
var IntersectionWatcher = class {
|
|
15
20
|
#onEntries;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":"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","file":"lazy_frame_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n try {\n return document.querySelector(selector);\n } catch {\n // Unparsable selector: fall through to the viewport below.\n }\n return null;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * observes with no margin, so the frame loads only once it is already in view; this\n * drives an explicit `IntersectionObserver` with a configurable `rootMargin`, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — **writing `src` is what starts the fetch** — marks\n * `data-lazy-loaded`, and emits `load`. Focus is the fallback for the *first* load only;\n * once the frame has loaded, its listener is released and further focus moves inside the\n * frame change nothing.\n *\n * With `once` (default) the controller then stops observing. With `once` off it keeps\n * watching and re-fetches on a **re-entry** — the frame has to be seen inside the\n * observed area, leave it, and come back. Where a connection first finds the frame is the\n * baseline rather than a movement, so a focus-started load and a cache restore each begin\n * the visit wherever the frame sits — in view or out of it — and re-fetch nothing until it\n * leaves and returns. A re-entry serves the held `url`: when it still matches `src`, the\n * host's own `reload()` refetches it; when it has moved on, writing the new `url` to `src`\n * is the fetch.\n *\n * `load` dispatches `{ url }` — always the URL the fetch actually started for.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to `stimeo--frame-loading`. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is the truth source on a\n * Turbo cache restore: a restored frame is observed again only while `once` is off, which\n * is the mode that asked to keep re-fetching). An empty `url` arms nothing and is never\n * written to `src`. The `IntersectionObserver` and focus listener are released once loaded\n * (when `once`) and on `disconnect()` (Turbo navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n #connected = false;\n #loaded = false;\n /**\n * Whether the observer last saw the frame inside the observed area — `null`\n * until it has reported. A re-entry needs a frame that was seen inside and\n * then left, so an unknown position is never a departure.\n */\n #inside: boolean | null = null;\n\n /**\n * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).\n * `#load` releases this listener, so focus never reaches an already-loaded frame.\n */\n readonly #onFocus = (): void => this.#load();\n\n /**\n * Follows the held URL at runtime: one that arrives arms the frame, one that is\n * taken away disarms it, so an empty `url` holds no triggers either way.\n *\n * Stimulus runs value callbacks before `connect()`, so the connected guard keeps\n * arming in one place. A frame that already loaded keeps its held URL for the next\n * re-entry instead of fetching on the spot.\n */\n urlValueChanged(): void {\n if (!this.#connected || this.#loaded) return;\n if (this.urlValue) this.#arm();\n else this.#stopObserving();\n }\n\n /** Rebuilds the observer when the early-load margin changes at runtime. */\n rootMarginValueChanged(): void {\n if (this.#connected && this.#watcher.active) this.#observe();\n }\n\n override connect(): void {\n // `data-lazy-loaded` is the truth source across a cache restore, and `once`\n // decides whether a restored frame still has anything to watch for.\n this.#loaded = this.element.hasAttribute(\"data-lazy-loaded\");\n this.#inside = null;\n this.#connected = true;\n if (this.#loaded && this.onceValue) return;\n this.#arm();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#stopObserving();\n }\n\n /** Attaches the triggers that still have a job to do. */\n #arm(): void {\n if (!this.urlValue) return;\n if (!this.#loaded) this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#observe();\n }\n\n #observe(): void {\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const returning = this.#inside === false;\n this.#inside = true;\n this.#enter(returning);\n } else if (this.#inside) {\n // An observer reports where the frame is before it reports any movement,\n // so only a frame already seen inside can be leaving.\n this.#inside = false;\n }\n }\n }\n\n /** Applies an intersecting entry: the first one loads, a return re-fetches. */\n #enter(returning: boolean): void {\n if (!this.#loaded) {\n this.#load();\n return;\n }\n if (this.onceValue || !returning) return;\n this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n if (!this.urlValue) return;\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n // The focus fallback exists to start the first load; it has nothing left to do.\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n if (this.onceValue) this.#watcher.stop();\n }\n\n /** Re-entry while `once` is off: fetch the held URL again. */\n #reload(): void {\n const src = this.element.getAttribute(\"src\");\n if (this.urlValue && this.urlValue !== src) {\n // The held URL moved on. Writing it to `src` is the fetch, the same start\n // the first load uses, so it works on any host.\n this.element.setAttribute(\"src\", this.urlValue);\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n return;\n }\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Refetching the URL already on `src` needs the host's own `reload()`. On a\n // non-`<turbo-frame>` host there is none, and firing `load` would announce a\n // fetch that never started.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: src ?? \"\" } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/intersection_watcher.ts","../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":"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","file":"lazy_frame_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\nimport { parseDeclared } from \"./declared_value\";\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n return parseDeclared(selector, (raw) => document.querySelector(raw), null);\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * observes with no margin, so the frame loads only once it is already in view; this\n * drives an explicit `IntersectionObserver` with a configurable `rootMargin`, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — **writing `src` is what starts the fetch** — marks\n * `data-lazy-loaded`, and emits `load`. Focus is the fallback for the *first* load only;\n * once the frame has loaded, its listener is released and further focus moves inside the\n * frame change nothing.\n *\n * With `once` (default) the controller then stops observing. With `once` off it keeps\n * watching and re-fetches on a **re-entry** — the frame has to be seen inside the\n * observed area, leave it, and come back. Where a connection first finds the frame is the\n * baseline rather than a movement, so a focus-started load and a cache restore each begin\n * the visit wherever the frame sits — in view or out of it — and re-fetch nothing until it\n * leaves and returns. A re-entry serves the held `url`: when it still matches `src`, the\n * host's own `reload()` refetches it; when it has moved on, writing the new `url` to `src`\n * is the fetch.\n *\n * `load` dispatches `{ url }` — always the URL the fetch actually started for.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to `stimeo--frame-loading`. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is the truth source on a\n * Turbo cache restore: a restored frame is observed again only while `once` is off, which\n * is the mode that asked to keep re-fetching). An empty `url` arms nothing and is never\n * written to `src`. The `IntersectionObserver` and focus listener are released once loaded\n * (when `once`) and on `disconnect()` (Turbo navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n #connected = false;\n #loaded = false;\n /**\n * Whether the observer last saw the frame inside the observed area — `null`\n * until it has reported. A re-entry needs a frame that was seen inside and\n * then left, so an unknown position is never a departure.\n */\n #inside: boolean | null = null;\n\n /**\n * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).\n * `#load` releases this listener, so focus never reaches an already-loaded frame.\n */\n readonly #onFocus = (): void => this.#load();\n\n /**\n * Follows the held URL at runtime: one that arrives arms the frame, one that is\n * taken away disarms it, so an empty `url` holds no triggers either way.\n *\n * Stimulus runs value callbacks before `connect()`, so the connected guard keeps\n * arming in one place. A frame that already loaded keeps its held URL for the next\n * re-entry instead of fetching on the spot.\n */\n urlValueChanged(): void {\n if (!this.#connected || this.#loaded) return;\n if (this.urlValue) this.#arm();\n else this.#stopObserving();\n }\n\n /** Rebuilds the observer when the early-load margin changes at runtime. */\n rootMarginValueChanged(): void {\n if (this.#connected && this.#watcher.active) this.#observe();\n }\n\n override connect(): void {\n // `data-lazy-loaded` is the truth source across a cache restore, and `once`\n // decides whether a restored frame still has anything to watch for.\n this.#loaded = this.element.hasAttribute(\"data-lazy-loaded\");\n this.#inside = null;\n this.#connected = true;\n if (this.#loaded && this.onceValue) return;\n this.#arm();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#stopObserving();\n }\n\n /** Attaches the triggers that still have a job to do. */\n #arm(): void {\n if (!this.urlValue) return;\n if (!this.#loaded) this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#observe();\n }\n\n #observe(): void {\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const returning = this.#inside === false;\n this.#inside = true;\n this.#enter(returning);\n } else if (this.#inside) {\n // An observer reports where the frame is before it reports any movement,\n // so only a frame already seen inside can be leaving.\n this.#inside = false;\n }\n }\n }\n\n /** Applies an intersecting entry: the first one loads, a return re-fetches. */\n #enter(returning: boolean): void {\n if (!this.#loaded) {\n this.#load();\n return;\n }\n if (this.onceValue || !returning) return;\n this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n if (!this.urlValue) return;\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n // The focus fallback exists to start the first load; it has nothing left to do.\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n if (this.onceValue) this.#watcher.stop();\n }\n\n /** Re-entry while `once` is off: fetch the held URL again. */\n #reload(): void {\n const src = this.element.getAttribute(\"src\");\n if (this.urlValue && this.urlValue !== src) {\n // The held URL moved on. Writing it to `src` is the fetch, the same start\n // the first load uses, so it works on any host.\n this.element.setAttribute(\"src\", this.urlValue);\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n return;\n }\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Refetching the URL already on `src` needs the host's own `reload()`. On a\n // non-`<turbo-frame>` host there is none, and firing `load` would announce a\n // fetch that never started.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: src ?? \"\" } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
|
|
@@ -23,8 +23,9 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
23
23
|
* assistive tech and crawlers keep the canonical value while only the display
|
|
24
24
|
* text — and an optional `title` — change. Formatting is a pure function of
|
|
25
25
|
* `datetime` with no module-scope state or timers, so a Turbo Drive cache restore
|
|
26
|
-
* re-runs `connect()` and stays consistent. A
|
|
27
|
-
* authored absolute text in place
|
|
26
|
+
* re-runs `connect()` and stays consistent. A `datetime` that does not parse, and
|
|
27
|
+
* a locale or time zone `Intl` rejects, leave the authored absolute text in place;
|
|
28
|
+
* any other failure surfaces rather than being absorbed.
|
|
28
29
|
*
|
|
29
30
|
* Render inputs are followed at runtime: a morph that swaps a Value or the
|
|
30
31
|
* `datetime` attribute on the live element — which keeps the element, so
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/local_time_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/intl_format.ts
|
|
6
|
+
var formatters = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
function intlFormatter(factory, locale, options, fallbackLocale) {
|
|
8
|
+
const cache = cacheFor(factory);
|
|
9
|
+
const key = cacheKey(locale, options, fallbackLocale);
|
|
10
|
+
if (cache.has(key)) return cache.get(key);
|
|
11
|
+
const formatter = construct(factory, locale, options);
|
|
12
|
+
cache.set(key, formatter);
|
|
13
|
+
return formatter;
|
|
14
|
+
}
|
|
15
|
+
function cacheFor(factory) {
|
|
16
|
+
const existing = formatters.get(factory);
|
|
17
|
+
if (existing) return existing;
|
|
18
|
+
const created = /* @__PURE__ */ new Map();
|
|
19
|
+
formatters.set(factory, created);
|
|
20
|
+
return created;
|
|
21
|
+
}
|
|
22
|
+
function cacheKey(locale, options, fallbackLocale) {
|
|
23
|
+
return JSON.stringify([locale, options, fallbackLocale]);
|
|
24
|
+
}
|
|
25
|
+
function construct(factory, locale, options, fallbackLocale) {
|
|
26
|
+
try {
|
|
27
|
+
return new factory(locale, options);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!(error instanceof RangeError)) throw error;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
5
34
|
// src/utils/microtask_coalescer.ts
|
|
6
35
|
var MicrotaskCoalescer = class {
|
|
7
36
|
#run;
|
|
@@ -150,8 +179,9 @@ var LocalTimeController = class extends Controller {
|
|
|
150
179
|
/**
|
|
151
180
|
* Formats `date` with `Intl.DateTimeFormat`, including each style only when it
|
|
152
181
|
* is a valid keyword (so a consumer can show date-only or time-only by clearing
|
|
153
|
-
* the other). Returns `null` when neither style is usable or
|
|
154
|
-
* the caller can leave the
|
|
182
|
+
* the other). Returns `null` when neither style is usable or the declaration
|
|
183
|
+
* names a locale or time zone `Intl` rejects, so the caller can leave the
|
|
184
|
+
* authored text untouched.
|
|
155
185
|
*/
|
|
156
186
|
#applyFormat(date, dateStyle, timeStyle) {
|
|
157
187
|
const options = {
|
|
@@ -160,11 +190,8 @@ var LocalTimeController = class extends Controller {
|
|
|
160
190
|
};
|
|
161
191
|
if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
|
|
162
192
|
if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
} catch {
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
193
|
+
const formatter = intlFormatter(Intl.DateTimeFormat, this.#locale, options);
|
|
194
|
+
return formatter === null ? null : formatter.format(date);
|
|
168
195
|
}
|
|
169
196
|
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
170
197
|
get #locale() {
|