stimeo-ui 0.1.0-beta.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -6
  3. package/dist/controllers/auto_submit_controller.js +56 -21
  4. package/dist/controllers/auto_submit_controller.js.map +1 -1
  5. package/dist/controllers/character_counter_controller.js +52 -15
  6. package/dist/controllers/character_counter_controller.js.map +1 -1
  7. package/dist/controllers/combobox_controller.d.ts +6 -2
  8. package/dist/controllers/combobox_controller.js +64 -3
  9. package/dist/controllers/combobox_controller.js.map +1 -1
  10. package/dist/controllers/command_palette_controller.d.ts +13 -6
  11. package/dist/controllers/command_palette_controller.js +243 -39
  12. package/dist/controllers/command_palette_controller.js.map +1 -1
  13. package/dist/controllers/confirm_controller.js +94 -8
  14. package/dist/controllers/confirm_controller.js.map +1 -1
  15. package/dist/controllers/context_menu_controller.d.ts +11 -6
  16. package/dist/controllers/context_menu_controller.js +191 -13
  17. package/dist/controllers/context_menu_controller.js.map +1 -1
  18. package/dist/controllers/countdown_controller.d.ts +2 -2
  19. package/dist/controllers/countdown_controller.js +2 -2
  20. package/dist/controllers/countdown_controller.js.map +1 -1
  21. package/dist/controllers/dialog_controller.js +94 -8
  22. package/dist/controllers/dialog_controller.js.map +1 -1
  23. package/dist/controllers/dropdown_controller.d.ts +9 -6
  24. package/dist/controllers/dropdown_controller.js +139 -16
  25. package/dist/controllers/dropdown_controller.js.map +1 -1
  26. package/dist/controllers/focus_controller.js +94 -8
  27. package/dist/controllers/focus_controller.js.map +1 -1
  28. package/dist/controllers/form_validation_controller.js +2 -2
  29. package/dist/controllers/form_validation_controller.js.map +1 -1
  30. package/dist/controllers/hover_card_controller.d.ts +15 -12
  31. package/dist/controllers/hover_card_controller.js +103 -27
  32. package/dist/controllers/hover_card_controller.js.map +1 -1
  33. package/dist/controllers/intersection_controller.js.map +1 -1
  34. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  35. package/dist/controllers/listbox_controller.js +1 -0
  36. package/dist/controllers/listbox_controller.js.map +1 -1
  37. package/dist/controllers/menu_controller.d.ts +18 -11
  38. package/dist/controllers/menu_controller.js +181 -8
  39. package/dist/controllers/menu_controller.js.map +1 -1
  40. package/dist/controllers/otp_controller.js +62 -22
  41. package/dist/controllers/otp_controller.js.map +1 -1
  42. package/dist/controllers/overflow_menu_controller.d.ts +3 -2
  43. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  44. package/dist/controllers/pointer_drag_controller.js +3 -3
  45. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  46. package/dist/controllers/popover_controller.d.ts +18 -10
  47. package/dist/controllers/popover_controller.js +118 -27
  48. package/dist/controllers/popover_controller.js.map +1 -1
  49. package/dist/controllers/scroll_area_controller.js.map +1 -1
  50. package/dist/controllers/scrollspy_controller.js.map +1 -1
  51. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  52. package/dist/controllers/tabs_controller.d.ts +1 -2
  53. package/dist/controllers/tabs_controller.js.map +1 -1
  54. package/dist/controllers/toast_controller.d.ts +7 -9
  55. package/dist/controllers/toast_controller.js +108 -29
  56. package/dist/controllers/toast_controller.js.map +1 -1
  57. package/dist/controllers/tooltip_controller.d.ts +15 -18
  58. package/dist/controllers/tooltip_controller.js +148 -42
  59. package/dist/controllers/tooltip_controller.js.map +1 -1
  60. package/dist/index.d.ts +40 -10
  61. package/dist/index.js +833 -348
  62. package/dist/index.js.map +1 -1
  63. package/dist/inspector/cli.js.map +1 -1
  64. package/dist/inspector/cli_bin.js.map +1 -1
  65. package/dist/inspector/examples.json +6 -6
  66. package/dist/inspector/manifest.json +16 -3
  67. package/package.json +7 -7
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/scroll_dismiss.ts","../../src/controllers/popover_controller.ts"],"names":[],"mappings":"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","file":"popover_controller.js","sourcesContent":["/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **non-modal popover** behavior.\n *\n * Markup contract (identifier: `stimeo--popover`):\n * <div data-controller=\"stimeo--popover\">\n * <button data-stimeo--popover-target=\"trigger\"\n * aria-haspopup=\"dialog\" aria-expanded=\"false\" aria-controls=\"pop\"\n * data-action=\"click->stimeo--popover#toggle\">Edit profile</button>\n * <div id=\"pop\" data-stimeo--popover-target=\"panel\"\n * role=\"dialog\" aria-label=\"Edit profile\" hidden>…</div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Dialog** pattern run *non-modally* (no\n * `aria-modal`, no focus trap, no `inert`/scroll lock). The background stays\n * fully interactive; this is the modeless counterpart to `stimeo--dialog`. For\n * a roving `role=\"menu\"` of commands use `stimeo--menu`; for decorative-only\n * text use `stimeo--tooltip`.\n *\n * @remarks\n * Behavior only — static placement is the consumer's CSS, and dynamic\n * edge-collision avoidance is delegated to the opt-in `stimeo-ui/positioning`\n * module (this controller never imports it, preserving the zero-dep core). State\n * is exposed via the trigger's `aria-expanded` and the panel's `hidden`.\n *\n * Behavior provided:\n * - Click the trigger to toggle (`aria-expanded` + `hidden` reflect state).\n * - On open, focus moves to the first focusable element inside the panel (or the\n * panel itself if it has none).\n * - `Escape` closes and restores focus to the trigger.\n * - An outside click (anywhere off the controller element) closes and restores\n * focus to the trigger.\n * - Because it is modeless, focus is *not* trapped: when `Tab` moves focus out of\n * the panel it closes (detected via `focusout`) without yanking focus back, so\n * the natural tab destination is preserved.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the panel — the Radix / floating-ui\n * convention. Closes without restoring focus (like the modeless `focusout` path)\n * so the close never fights the user's scroll. Off by default.\n */\nexport class PopoverController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static override values = {\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\", \"toggle\"] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly panelTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasPanelTarget: boolean;\n declare readonly closeOnScrollValue: boolean;\n\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Selector for natively focusable elements used to find the first one. */\n static readonly #FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n /** Starts closed and registers the document-level dismissal listeners. */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n document.addEventListener(\"keydown\", this.#onKeydown);\n }\n\n /**\n * Removes the document-level listeners registered in {@link connect}, plus the\n * panel's `focusout` listener if the popover is torn down while open (e.g. a\n * Turbo navigation). `removeEventListener` is a no-op when it was never added,\n * so this is safe in the closed state too — no listener outlives the element.\n */\n override disconnect(): void {\n document.removeEventListener(\"click\", this.#onOutsideClick);\n document.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.hasPanelTarget) this.panelTarget.removeEventListener(\"focusout\", this.#onFocusOut);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Toggles the popover. Bound via `data-action` (click on the trigger). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n }\n }\n\n /** Opens the panel, reflects state, and moves focus inside it. */\n open(): void {\n if (!this.hasPanelTarget || this.#isOpen) return;\n this.panelTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n // focusout fires as focus settles; register only once the panel is open so an\n // initial close()/connect() does not immediately re-close it.\n this.panelTarget.addEventListener(\"focusout\", this.#onFocusOut);\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n // Close (no focus restore) so dismissing never fights the user's scroll.\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());\n }\n this.#focusFirst();\n }\n\n /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */\n close(): void {\n if (!this.hasPanelTarget) return;\n this.panelTarget.removeEventListener(\"focusout\", this.#onFocusOut);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n this.panelTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Moves focus to the first focusable element in the panel, or the panel itself. */\n #focusFirst(): void {\n const first = this.panelTarget.querySelector<HTMLElement>(PopoverController.#FOCUSABLE);\n if (first) {\n first.focus();\n return;\n }\n if (!this.panelTarget.hasAttribute(\"tabindex\")) this.panelTarget.tabIndex = -1;\n this.panelTarget.focus();\n }\n\n /** Closes and restores focus to the trigger (shared by Escape / outside click). */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes (restoring focus) when a click lands outside the controller element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (this.#isOpen && !this.element.contains(event.target as Node)) this.#closeAndRestore();\n };\n\n /** Closes (restoring focus) on `Escape` while open. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\" && this.#isOpen) {\n event.preventDefault();\n this.#closeAndRestore();\n }\n };\n\n /**\n * Closes when focus leaves the panel for an element outside the controller\n * (e.g. `Tab` past the last field). Focus is *not* restored — the natural\n * destination is kept, which is the modeless contract. Moves within the\n * controller (panel → trigger) keep it open.\n */\n readonly #onFocusOut = (event: FocusEvent): void => {\n const next = event.relatedTarget as Node | null;\n if (next && this.element.contains(next)) return;\n this.close();\n };\n\n /** Whether the panel is currently visible. */\n get #isOpen(): boolean {\n return this.hasPanelTarget && !this.panelTarget.hidden;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/popover_controller.ts"],"names":[],"mappings":"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","file":"popover_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **non-modal popover** behavior.\n *\n * Markup contract (identifier: `stimeo--popover`):\n * <div data-controller=\"stimeo--popover\">\n * <button data-stimeo--popover-target=\"trigger\"\n * aria-haspopup=\"dialog\" aria-expanded=\"false\" aria-controls=\"pop\"\n * data-action=\"click->stimeo--popover#toggle\">Edit profile</button>\n * <div id=\"pop\" data-stimeo--popover-target=\"panel\"\n * role=\"dialog\" aria-label=\"Edit profile\" hidden>…</div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Dialog** pattern run *non-modally* (no\n * `aria-modal`, no focus trap, no `inert`/scroll lock). The background stays\n * fully interactive; this is the modeless counterpart to `stimeo--dialog`. For\n * a roving `role=\"menu\"` of commands use `stimeo--menu`; for decorative-only\n * text use `stimeo--tooltip`.\n *\n * @remarks\n * Behavior only — static placement is the consumer's CSS, and dynamic\n * edge-collision avoidance is delegated to the opt-in `stimeo-ui/positioning`\n * module (this controller never imports it, preserving the zero-dep core). State\n * is exposed via the trigger's `aria-expanded` and the panel's `hidden`.\n *\n * Behavior provided:\n * - Click the trigger to toggle (`aria-expanded` + `hidden` reflect state).\n * - On open, focus moves to the first focusable element inside the panel (or the\n * panel itself if it has none).\n * - `Escape` closes and restores focus to the trigger. While open the panel is a\n * layer on the shared {@link EscapeLayer} stack; it claims a press only while\n * focus is inside the controller or fell to the body (a click on non-focusable\n * panel content), so a press aimed at another layer is never consumed here,\n * and one keypress closes exactly one layer (the shared layered-Escape\n * contract).\n * - An outside click (anywhere off the controller element) closes without moving\n * focus. Focus stays at the clicked element, or falls back to the document body\n * for a non-focusable destination.\n * - Because it is modeless, focus is *not* trapped: when `Tab` moves focus out of\n * the controller it closes (detected via bubbling `focusout`) without yanking\n * focus back, so forward and reverse traversal preserve their natural destination.\n * A `focusout` with no destination is ignored because it also occurs for clicks\n * on non-focusable content and when the browser window loses focus.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the panel — the Radix / floating-ui\n * convention. Closes without restoring focus (like the modeless `focusout` path)\n * so the close never fights the user's scroll. Off by default.\n */\nexport class PopoverController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static override values = {\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\", \"toggle\"] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly panelTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasPanelTarget: boolean;\n declare readonly closeOnScrollValue: boolean;\n\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Selector for natively focusable elements used to find the first one. */\n static readonly #FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n /** Starts closed and registers the standing dismissal listeners. */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n }\n\n /**\n * Removes every standing listener registered in {@link connect} plus any active\n * dismiss-on-scroll observers. `removeEventListener` is a no-op when it was\n * never added, so this is safe in the closed state too — no listener outlives\n * the element after a Turbo navigation.\n */\n override disconnect(): void {\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"click\", this.#onOutsideClick);\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Toggles the popover. Bound via `data-action` (click on the trigger). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n }\n }\n\n /** Opens the panel, reflects state, and moves focus inside it. */\n open(): void {\n if (!this.hasPanelTarget || this.#isOpen) return;\n this.panelTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n // Close (no focus restore) so dismissing never fights the user's scroll.\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());\n }\n this.#focusFirst();\n }\n\n /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */\n close(): void {\n // Release listeners first, unconditionally: a consumer may remove the panel\n // target while it is open, and an early return would leak scroll observers.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasPanelTarget) this.panelTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Moves focus to the first focusable element in the panel, or the panel itself. */\n #focusFirst(): void {\n const first = this.panelTarget.querySelector<HTMLElement>(PopoverController.#FOCUSABLE);\n if (first) {\n first.focus();\n return;\n }\n if (!this.panelTarget.hasAttribute(\"tabindex\")) this.panelTarget.tabIndex = -1;\n this.panelTarget.focus();\n }\n\n /** Closes and restores focus to the trigger for explicit keyboard dismissal. */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes without moving focus when a click lands outside the controller element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n const target = event.target;\n if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();\n };\n\n /**\n * Closes when focus leaves the controller for a known external destination\n * (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is\n * not restored — the natural destination is kept, which is the modeless\n * contract. A null/non-Node destination is indeterminate: browsers use it for\n * clicks on non-focusable content and window deactivation, so the later outside\n * click handler decides pointer dismissal instead.\n */\n readonly #onFocusOut = (event: FocusEvent): void => {\n if (!this.#isOpen) return;\n const next = event.relatedTarget;\n if (!(next instanceof Node) || this.element.contains(next)) return;\n this.close();\n };\n\n /** Whether the panel is currently visible. */\n get #isOpen(): boolean {\n return this.hasPanelTarget && !this.panelTarget.hidden;\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/scroll_area_controller.ts"],"names":[],"mappings":"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","file":"scroll_area_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/** A CSS selector for natively focusable / author-focusable descendants. */\nconst FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n \"[tabindex]:not([tabindex='-1'])\",\n \"[contenteditable='true']\",\n].join(\",\");\n\n/** Distance from an edge (px) treated as fully reached; absorbs sub-pixel scroll. */\nconst EDGE_EPSILON = 1;\n\n/**\n * Headless **Scroll Area** behavior: keyboard reachability and scroll-state hooks\n * for a natively scrolling region. No custom scrollbar — the native one is\n * respected; this only adds a11y and CSS state.\n *\n * Markup contract (identifier: `stimeo--scroll-area`):\n * <div data-controller=\"stimeo--scroll-area\"\n * data-stimeo--scroll-area-orientation-value=\"vertical\">\n * <div data-stimeo--scroll-area-target=\"viewport\" aria-label=\"Log output\">\n * <!-- long content -->\n * </div>\n * </div>\n *\n * When the content overflows and the viewport holds no focusable elements of its\n * own, the viewport is made keyboard-scrollable (`tabindex=\"0\"`, plus `role=\"region\"`\n * when it already has an accessible name). Scroll position is published as\n * `data-scroll` (`start`/`middle`/`end`), overflow as `data-overflow`, and progress\n * as `--stimeo-scroll-progress` (0–1) so consumer CSS can draw scroll shadows.\n *\n * @remarks\n * Behavior only. The `scroll` listener and {@link LayoutObserver} (element +\n * viewport resize) are torn down on `disconnect()` (Turbo navigation included).\n * `role=\"region\"` is added only when the viewport is already named, so a scrollable\n * region never becomes an unlabeled landmark.\n */\nexport class ScrollAreaController extends Controller<HTMLElement> {\n static override targets = [\"viewport\"];\n static override values = {\n orientation: { type: String, default: \"vertical\" },\n };\n static events = [\"reach\"] as const;\n\n declare readonly viewportTarget: HTMLElement;\n declare readonly hasViewportTarget: boolean;\n\n declare orientationValue: string;\n\n readonly #layout = new LayoutObserver(() => this.#update());\n /** Last edge reported via `reach`, so the event fires once per arrival. */\n #lastEdge: \"start\" | \"end\" | null = null;\n /** Whether this controller added `tabindex`, so teardown only removes its own. */\n #addedTabindex = false;\n /** Whether this controller added `role=\"region\"`, for symmetric teardown. */\n #addedRole = false;\n\n readonly #onScroll = (): void => {\n this.#update();\n };\n\n override connect(): void {\n if (!this.hasViewportTarget) return;\n this.viewportTarget.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.#layout.observe(this.viewportTarget);\n this.#layout.observeViewport();\n this.#update();\n }\n\n override disconnect(): void {\n if (this.hasViewportTarget) {\n this.viewportTarget.removeEventListener(\"scroll\", this.#onScroll);\n // Remove only the keyboard-reach attributes this controller added, so a\n // Turbo cache snapshot never preserves a controller-owned tab stop /\n // landmark (matches the spec's Turbo-compatibility requirement).\n this.#clearAddedAttributes(this.viewportTarget);\n }\n this.#layout.disconnect();\n this.#lastEdge = null;\n }\n\n /** Re-measures overflow and scroll position and reflects the state hooks. */\n #update(): void {\n if (!this.hasViewportTarget) return;\n const vp = this.viewportTarget;\n const overflowing = this.#measureOverflow(vp);\n\n this.element.setAttribute(\"data-overflow\", overflowing ? \"true\" : \"false\");\n this.#syncKeyboardReach(vp, overflowing);\n\n const { position, progress } = this.#measurePosition(vp);\n this.element.setAttribute(\"data-scroll\", position);\n this.element.style.setProperty(\"--stimeo-scroll-progress\", String(progress));\n\n const edge = position === \"start\" ? \"start\" : position === \"end\" ? \"end\" : null;\n if (overflowing && edge && edge !== this.#lastEdge) {\n this.#lastEdge = edge;\n this.dispatch(\"reach\", { detail: { edge } });\n } else if (!edge) {\n this.#lastEdge = null;\n }\n }\n\n /** Whether the viewport can scroll on the configured axis. */\n #measureOverflow(vp: HTMLElement): boolean {\n const o = this.orientationValue;\n const vertical = o !== \"horizontal\" && vp.scrollHeight > vp.clientHeight + EDGE_EPSILON;\n const horizontal = o !== \"vertical\" && vp.scrollWidth > vp.clientWidth + EDGE_EPSILON;\n return vertical || horizontal;\n }\n\n /**\n * Reports the scroll position bucket and 0–1 progress on the primary axis. For\n * `both`, the vertical axis is used when it overflows, otherwise the horizontal.\n */\n #measurePosition(vp: HTMLElement): {\n position: \"start\" | \"middle\" | \"end\";\n progress: number;\n } {\n const horizontalPrimary =\n this.orientationValue === \"horizontal\" ||\n (this.orientationValue === \"both\" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON);\n\n const scrollPos = horizontalPrimary ? vp.scrollLeft : vp.scrollTop;\n const maxScroll = horizontalPrimary\n ? vp.scrollWidth - vp.clientWidth\n : vp.scrollHeight - vp.clientHeight;\n\n if (maxScroll <= EDGE_EPSILON) return { position: \"start\", progress: 0 };\n\n const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));\n if (scrollPos <= EDGE_EPSILON) return { position: \"start\", progress };\n if (scrollPos >= maxScroll - EDGE_EPSILON) return { position: \"end\", progress };\n return { position: \"middle\", progress };\n }\n\n /**\n * Makes the viewport keyboard-scrollable when it overflows and contains no\n * focusable elements of its own (avoiding a double tab stop). Adds `role=\"region\"`\n * only when the viewport already carries an accessible name.\n */\n #syncKeyboardReach(vp: HTMLElement, overflowing: boolean): void {\n const wantsTabindex = overflowing && !this.#hasFocusableContent(vp);\n\n if (wantsTabindex) {\n if (!vp.hasAttribute(\"tabindex\")) {\n vp.setAttribute(\"tabindex\", \"0\");\n this.#addedTabindex = true;\n }\n if (!vp.hasAttribute(\"role\") && this.#hasAccessibleName(vp)) {\n vp.setAttribute(\"role\", \"region\");\n this.#addedRole = true;\n }\n } else {\n this.#clearAddedAttributes(vp);\n }\n }\n\n /** Removes (and resets the flags for) only the attributes this controller added. */\n #clearAddedAttributes(vp: HTMLElement): void {\n if (this.#addedTabindex) {\n vp.removeAttribute(\"tabindex\");\n this.#addedTabindex = false;\n }\n if (this.#addedRole) {\n vp.removeAttribute(\"role\");\n this.#addedRole = false;\n }\n }\n\n #hasFocusableContent(vp: HTMLElement): boolean {\n return vp.querySelector(FOCUSABLE_SELECTOR) !== null;\n }\n\n #hasAccessibleName(vp: HTMLElement): boolean {\n return vp.hasAttribute(\"aria-label\") || vp.hasAttribute(\"aria-labelledby\");\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/scroll_area_controller.ts"],"names":[],"mappings":"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","file":"scroll_area_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/** A CSS selector for natively focusable / author-focusable descendants. */\nconst FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n \"[tabindex]:not([tabindex='-1'])\",\n \"[contenteditable='true']\",\n].join(\",\");\n\n/** Distance from an edge (px) treated as fully reached; absorbs sub-pixel scroll. */\nconst EDGE_EPSILON = 1;\n\n/**\n * Headless **Scroll Area** behavior: keyboard reachability and scroll-state hooks\n * for a natively scrolling region. No custom scrollbar — the native one is\n * respected; this only adds a11y and CSS state.\n *\n * Markup contract (identifier: `stimeo--scroll-area`):\n * <div data-controller=\"stimeo--scroll-area\"\n * data-stimeo--scroll-area-orientation-value=\"vertical\">\n * <div data-stimeo--scroll-area-target=\"viewport\" aria-label=\"Log output\">\n * <!-- long content -->\n * </div>\n * </div>\n *\n * When the content overflows and the viewport holds no focusable elements of its\n * own, the viewport is made keyboard-scrollable (`tabindex=\"0\"`, plus `role=\"region\"`\n * when it already has an accessible name). Scroll position is published as\n * `data-scroll` (`start`/`middle`/`end`), overflow as `data-overflow`, and progress\n * as `--stimeo-scroll-progress` (0–1) so consumer CSS can draw scroll shadows.\n *\n * @remarks\n * Behavior only. The `scroll` listener and {@link LayoutObserver} (element +\n * viewport resize) are torn down on `disconnect()` (Turbo navigation included).\n * `role=\"region\"` is added only when the viewport is already named, so a scrollable\n * region never becomes an unlabeled landmark.\n */\nexport class ScrollAreaController extends Controller<HTMLElement> {\n static override targets = [\"viewport\"];\n static override values = {\n orientation: { type: String, default: \"vertical\" },\n };\n static events = [\"reach\"] as const;\n\n declare readonly viewportTarget: HTMLElement;\n declare readonly hasViewportTarget: boolean;\n\n declare orientationValue: string;\n\n readonly #layout = new LayoutObserver(() => this.#update());\n /** Last edge reported via `reach`, so the event fires once per arrival. */\n #lastEdge: \"start\" | \"end\" | null = null;\n /** Whether this controller added `tabindex`, so teardown only removes its own. */\n #addedTabindex = false;\n /** Whether this controller added `role=\"region\"`, for symmetric teardown. */\n #addedRole = false;\n\n readonly #onScroll = (): void => {\n this.#update();\n };\n\n override connect(): void {\n if (!this.hasViewportTarget) return;\n this.viewportTarget.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.#layout.observe(this.viewportTarget);\n this.#layout.observeViewport();\n this.#update();\n }\n\n override disconnect(): void {\n if (this.hasViewportTarget) {\n this.viewportTarget.removeEventListener(\"scroll\", this.#onScroll);\n // Remove only the keyboard-reach attributes this controller added, so a\n // Turbo cache snapshot never preserves a controller-owned tab stop /\n // landmark (controller-added state must not outlive the controller).\n this.#clearAddedAttributes(this.viewportTarget);\n }\n this.#layout.disconnect();\n this.#lastEdge = null;\n }\n\n /** Re-measures overflow and scroll position and reflects the state hooks. */\n #update(): void {\n if (!this.hasViewportTarget) return;\n const vp = this.viewportTarget;\n const overflowing = this.#measureOverflow(vp);\n\n this.element.setAttribute(\"data-overflow\", overflowing ? \"true\" : \"false\");\n this.#syncKeyboardReach(vp, overflowing);\n\n const { position, progress } = this.#measurePosition(vp);\n this.element.setAttribute(\"data-scroll\", position);\n this.element.style.setProperty(\"--stimeo-scroll-progress\", String(progress));\n\n const edge = position === \"start\" ? \"start\" : position === \"end\" ? \"end\" : null;\n if (overflowing && edge && edge !== this.#lastEdge) {\n this.#lastEdge = edge;\n this.dispatch(\"reach\", { detail: { edge } });\n } else if (!edge) {\n this.#lastEdge = null;\n }\n }\n\n /** Whether the viewport can scroll on the configured axis. */\n #measureOverflow(vp: HTMLElement): boolean {\n const o = this.orientationValue;\n const vertical = o !== \"horizontal\" && vp.scrollHeight > vp.clientHeight + EDGE_EPSILON;\n const horizontal = o !== \"vertical\" && vp.scrollWidth > vp.clientWidth + EDGE_EPSILON;\n return vertical || horizontal;\n }\n\n /**\n * Reports the scroll position bucket and 0–1 progress on the primary axis. For\n * `both`, the vertical axis is used when it overflows, otherwise the horizontal.\n */\n #measurePosition(vp: HTMLElement): {\n position: \"start\" | \"middle\" | \"end\";\n progress: number;\n } {\n const horizontalPrimary =\n this.orientationValue === \"horizontal\" ||\n (this.orientationValue === \"both\" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON);\n\n const scrollPos = horizontalPrimary ? vp.scrollLeft : vp.scrollTop;\n const maxScroll = horizontalPrimary\n ? vp.scrollWidth - vp.clientWidth\n : vp.scrollHeight - vp.clientHeight;\n\n if (maxScroll <= EDGE_EPSILON) return { position: \"start\", progress: 0 };\n\n const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));\n if (scrollPos <= EDGE_EPSILON) return { position: \"start\", progress };\n if (scrollPos >= maxScroll - EDGE_EPSILON) return { position: \"end\", progress };\n return { position: \"middle\", progress };\n }\n\n /**\n * Makes the viewport keyboard-scrollable when it overflows and contains no\n * focusable elements of its own (avoiding a double tab stop). Adds `role=\"region\"`\n * only when the viewport already carries an accessible name.\n */\n #syncKeyboardReach(vp: HTMLElement, overflowing: boolean): void {\n const wantsTabindex = overflowing && !this.#hasFocusableContent(vp);\n\n if (wantsTabindex) {\n if (!vp.hasAttribute(\"tabindex\")) {\n vp.setAttribute(\"tabindex\", \"0\");\n this.#addedTabindex = true;\n }\n if (!vp.hasAttribute(\"role\") && this.#hasAccessibleName(vp)) {\n vp.setAttribute(\"role\", \"region\");\n this.#addedRole = true;\n }\n } else {\n this.#clearAddedAttributes(vp);\n }\n }\n\n /** Removes (and resets the flags for) only the attributes this controller added. */\n #clearAddedAttributes(vp: HTMLElement): void {\n if (this.#addedTabindex) {\n vp.removeAttribute(\"tabindex\");\n this.#addedTabindex = false;\n }\n if (this.#addedRole) {\n vp.removeAttribute(\"role\");\n this.#addedRole = false;\n }\n }\n\n #hasFocusableContent(vp: HTMLElement): boolean {\n return vp.querySelector(FOCUSABLE_SELECTOR) !== null;\n }\n\n #hasAccessibleName(vp: HTMLElement): boolean {\n return vp.hasAttribute(\"aria-label\") || vp.hasAttribute(\"aria-labelledby\");\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/scrollspy_controller.ts"],"names":[],"mappings":"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","file":"scrollspy_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * These controllers each grew a separate implementation of the same idea;\n * this util is that idea written once — the boilerplate every consumer\n * repeated: the `IntersectionObserver` support guard, root resolution from a\n * selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state (the\n * infinite-scroll \"sentinel never left the viewport\" fix).\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\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 /**\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 */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\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 rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\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 }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless, accessible Scrollspy / Navigation catalog synchronizer.\n *\n * Markup contract (identifier: `stimeo--scrollspy`):\n * <nav data-controller=\"stimeo--scrollspy\"\n * data-stimeo--scrollspy-offset-value=\"80\"\n * aria-label=\"Table of contents\">\n * <a href=\"#intro\" data-stimeo--scrollspy-target=\"link\">Intro</a>\n * <a href=\"#usage\" data-stimeo--scrollspy-target=\"link\">Usage</a>\n * </nav>\n * <section id=\"intro\">…</section>\n * <section id=\"usage\">…</section>\n *\n * Implements clean active location synchronization driven by `aria-current`:\n * - Leverages native `IntersectionObserver` to track viewport visibility.\n * - Robust evaluation algorithm: among all intersecting sections, the one closest\n * to the top offset trigger line (`boundingClientRect.top`) is prioritized as active.\n * - Toggles `aria-current=\"location\"` dynamically on active/inactive links.\n * - Gracefully handles teardown `disconnect()` by severing observer references to prevent leaks.\n *\n * @remarks\n * Behavior only. The controller manages state on catalog links via `aria-current`,\n * respects custom offsets, and dispatches `stimeo--scrollspy:change` events.\n */\nexport class ScrollspyController extends Controller<HTMLElement> {\n static override targets = [\"link\"];\n static override values = {\n offset: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"\" },\n rootSelector: { type: String, default: \"\" },\n };\n static actions = [\"scrollTo\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly linkTargets: HTMLElement[];\n\n declare offsetValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersection(entries));\n #isConnected = false;\n\n /** Track active/intersecting status of each section element by ID. */\n #intersectionStates = new Map<string, { isIntersecting: boolean; top: number }>();\n\n /** Current active section ID, used to avoid duplicate event dispatching. */\n #activeSectionId = \"\";\n\n override connect(): void {\n this.#isConnected = true;\n this.#initializeObserver();\n }\n\n override disconnect(): void {\n this.#isConnected = false;\n this.#watcher.stop();\n this.#intersectionStates.clear();\n this.#activeSectionId = \"\";\n }\n\n /**\n * Re-initializes the observer if the offset or rootMargin values change dynamically.\n */\n offsetValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n rootMarginValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n rootSelectorValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n /**\n * Smoothly scrolls to the target element mapped by the link anchor.\n * Prevents full window scroll jumps when tracking nested scrollable containers.\n */\n scrollTo(event: Event): void {\n const link = event.currentTarget as HTMLElement;\n const id = this.#getAnchorId(link);\n if (!id) return;\n\n event.preventDefault();\n\n const targetElement = document.getElementById(id);\n if (!targetElement) return;\n\n const rootElement = this.#getRootElement();\n if (rootElement) {\n const containerRect = rootElement.getBoundingClientRect();\n const targetRect = targetElement.getBoundingClientRect();\n const scrollPosition =\n rootElement.scrollTop + (targetRect.top - containerRect.top) - this.offsetValue;\n\n rootElement.scrollTo({\n top: scrollPosition,\n behavior: \"smooth\",\n });\n } else {\n const targetRect = targetElement.getBoundingClientRect();\n const scrollPosition = window.scrollY + targetRect.top - this.offsetValue;\n\n window.scrollTo({\n top: scrollPosition,\n behavior: \"smooth\",\n });\n }\n }\n\n #getRootElement(): HTMLElement | null {\n if (!this.rootSelectorValue) return null;\n return document.querySelector(this.rootSelectorValue);\n }\n\n #initializeObserver(): void {\n this.#watcher.stop();\n this.#intersectionStates.clear();\n this.#activeSectionId = \"\";\n\n if (this.linkTargets.length === 0) return;\n\n // Build the dynamic rootMargin string based on offset value\n const margin = this.rootMarginValue || `-${this.offsetValue}px 0px -80% 0px`;\n\n // Observe each target section mapped by the href anchors.\n const sections: Element[] = [];\n for (const link of this.linkTargets) {\n const id = this.#getAnchorId(link);\n if (!id) continue;\n const section = document.getElementById(id);\n if (section) sections.push(section);\n }\n\n this.#watcher.start(sections, {\n root: this.#getRootElement(),\n rootMargin: margin,\n threshold: [0, 0.2, 0.4, 0.6, 0.8, 1], // Multiple thresholds handle large sections safely\n });\n }\n\n readonly #onIntersection = (entries: IntersectionObserverEntry[]): void => {\n // Ignore any callback delivered after teardown: the browser may flush a\n // final queued batch right after `disconnect()`, and a detached controller\n // must not mutate `aria-current` on (possibly cached) links.\n if (!this.#isConnected) return;\n\n for (const entry of entries) {\n const id = entry.target.id;\n if (!id) continue;\n\n this.#intersectionStates.set(id, {\n isIntersecting: entry.isIntersecting,\n top: entry.boundingClientRect.top,\n });\n }\n\n this.#evaluateActiveSection();\n };\n\n #evaluateActiveSection(): void {\n // The trigger line is `offset` px below the top of the scroll root. When a\n // nested `rootSelector` container is used it is not at the viewport top, so\n // the line must be measured from the container's current top — comparing the\n // viewport-based `boundingClientRect.top` against a bare `offset` would\n // otherwise pick the section nearest the viewport top, not the container's.\n const rootEl = this.#getRootElement();\n const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offsetValue;\n\n let bestId = \"\";\n let closestTop = Number.MAX_VALUE;\n\n // Evaluate among all intersecting elements\n for (const [id, state] of this.#intersectionStates.entries()) {\n if (state.isIntersecting) {\n // Evaluate the one closest to the offset trigger line\n const distance = Math.abs(state.top - triggerLine);\n if (distance < closestTop) {\n closestTop = distance;\n bestId = id;\n }\n }\n }\n\n // Fallback: when no section currently intersects (e.g. scrolled past the\n // bottom, or between sections), pick the tracked section whose top is closest\n // to the trigger line so something stays highlighted.\n if (!bestId && this.#intersectionStates.size > 0) {\n let absoluteClosestId = \"\";\n let absoluteClosestTop = Number.MAX_VALUE;\n\n for (const [id, state] of this.#intersectionStates.entries()) {\n const distance = Math.abs(state.top - triggerLine);\n if (distance < absoluteClosestTop) {\n absoluteClosestTop = distance;\n absoluteClosestId = id;\n }\n }\n bestId = absoluteClosestId;\n }\n\n if (bestId && bestId !== this.#activeSectionId) {\n this.#activeSectionId = bestId;\n this.#syncActiveStates();\n }\n }\n\n #syncActiveStates(): void {\n const activeLink = this.linkTargets.find((l) => this.#getAnchorId(l) === this.#activeSectionId);\n\n for (const link of this.linkTargets) {\n const isActive = link === activeLink;\n if (isActive) {\n link.setAttribute(\"aria-current\", \"location\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n }\n\n if (activeLink) {\n this.dispatch(\"change\", { detail: { id: this.#activeSectionId, link: activeLink } });\n }\n }\n\n #getAnchorId(link: HTMLElement): string | null {\n const href = link.getAttribute(\"href\") || link.getAttribute(\"data-href\");\n if (!href?.startsWith(\"#\")) return null;\n return href.substring(1);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/scrollspy_controller.ts"],"names":[],"mappings":"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","file":"scrollspy_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\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 /**\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 */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\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 rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\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 }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless, accessible Scrollspy / Navigation catalog synchronizer.\n *\n * Markup contract (identifier: `stimeo--scrollspy`):\n * <nav data-controller=\"stimeo--scrollspy\"\n * data-stimeo--scrollspy-offset-value=\"80\"\n * aria-label=\"Table of contents\">\n * <a href=\"#intro\" data-stimeo--scrollspy-target=\"link\">Intro</a>\n * <a href=\"#usage\" data-stimeo--scrollspy-target=\"link\">Usage</a>\n * </nav>\n * <section id=\"intro\">…</section>\n * <section id=\"usage\">…</section>\n *\n * Implements clean active location synchronization driven by `aria-current`:\n * - Leverages native `IntersectionObserver` to track viewport visibility.\n * - Robust evaluation algorithm: among all intersecting sections, the one closest\n * to the top offset trigger line (`boundingClientRect.top`) is prioritized as active.\n * - Toggles `aria-current=\"location\"` dynamically on active/inactive links.\n * - Gracefully handles teardown `disconnect()` by severing observer references to prevent leaks.\n *\n * @remarks\n * Behavior only. The controller manages state on catalog links via `aria-current`,\n * respects custom offsets, and dispatches `stimeo--scrollspy:change` events.\n */\nexport class ScrollspyController extends Controller<HTMLElement> {\n static override targets = [\"link\"];\n static override values = {\n offset: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"\" },\n rootSelector: { type: String, default: \"\" },\n };\n static actions = [\"scrollTo\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly linkTargets: HTMLElement[];\n\n declare offsetValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersection(entries));\n #isConnected = false;\n\n /** Track active/intersecting status of each section element by ID. */\n #intersectionStates = new Map<string, { isIntersecting: boolean; top: number }>();\n\n /** Current active section ID, used to avoid duplicate event dispatching. */\n #activeSectionId = \"\";\n\n override connect(): void {\n this.#isConnected = true;\n this.#initializeObserver();\n }\n\n override disconnect(): void {\n this.#isConnected = false;\n this.#watcher.stop();\n this.#intersectionStates.clear();\n this.#activeSectionId = \"\";\n }\n\n /**\n * Re-initializes the observer if the offset or rootMargin values change dynamically.\n */\n offsetValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n rootMarginValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n rootSelectorValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n /**\n * Smoothly scrolls to the target element mapped by the link anchor.\n * Prevents full window scroll jumps when tracking nested scrollable containers.\n */\n scrollTo(event: Event): void {\n const link = event.currentTarget as HTMLElement;\n const id = this.#getAnchorId(link);\n if (!id) return;\n\n event.preventDefault();\n\n const targetElement = document.getElementById(id);\n if (!targetElement) return;\n\n const rootElement = this.#getRootElement();\n if (rootElement) {\n const containerRect = rootElement.getBoundingClientRect();\n const targetRect = targetElement.getBoundingClientRect();\n const scrollPosition =\n rootElement.scrollTop + (targetRect.top - containerRect.top) - this.offsetValue;\n\n rootElement.scrollTo({\n top: scrollPosition,\n behavior: \"smooth\",\n });\n } else {\n const targetRect = targetElement.getBoundingClientRect();\n const scrollPosition = window.scrollY + targetRect.top - this.offsetValue;\n\n window.scrollTo({\n top: scrollPosition,\n behavior: \"smooth\",\n });\n }\n }\n\n #getRootElement(): HTMLElement | null {\n if (!this.rootSelectorValue) return null;\n return document.querySelector(this.rootSelectorValue);\n }\n\n #initializeObserver(): void {\n this.#watcher.stop();\n this.#intersectionStates.clear();\n this.#activeSectionId = \"\";\n\n if (this.linkTargets.length === 0) return;\n\n // Build the dynamic rootMargin string based on offset value\n const margin = this.rootMarginValue || `-${this.offsetValue}px 0px -80% 0px`;\n\n // Observe each target section mapped by the href anchors.\n const sections: Element[] = [];\n for (const link of this.linkTargets) {\n const id = this.#getAnchorId(link);\n if (!id) continue;\n const section = document.getElementById(id);\n if (section) sections.push(section);\n }\n\n this.#watcher.start(sections, {\n root: this.#getRootElement(),\n rootMargin: margin,\n threshold: [0, 0.2, 0.4, 0.6, 0.8, 1], // Multiple thresholds handle large sections safely\n });\n }\n\n readonly #onIntersection = (entries: IntersectionObserverEntry[]): void => {\n // Ignore any callback delivered after teardown: the browser may flush a\n // final queued batch right after `disconnect()`, and a detached controller\n // must not mutate `aria-current` on (possibly cached) links.\n if (!this.#isConnected) return;\n\n for (const entry of entries) {\n const id = entry.target.id;\n if (!id) continue;\n\n this.#intersectionStates.set(id, {\n isIntersecting: entry.isIntersecting,\n top: entry.boundingClientRect.top,\n });\n }\n\n this.#evaluateActiveSection();\n };\n\n #evaluateActiveSection(): void {\n // The trigger line is `offset` px below the top of the scroll root. When a\n // nested `rootSelector` container is used it is not at the viewport top, so\n // the line must be measured from the container's current top — comparing the\n // viewport-based `boundingClientRect.top` against a bare `offset` would\n // otherwise pick the section nearest the viewport top, not the container's.\n const rootEl = this.#getRootElement();\n const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offsetValue;\n\n let bestId = \"\";\n let closestTop = Number.MAX_VALUE;\n\n // Evaluate among all intersecting elements\n for (const [id, state] of this.#intersectionStates.entries()) {\n if (state.isIntersecting) {\n // Evaluate the one closest to the offset trigger line\n const distance = Math.abs(state.top - triggerLine);\n if (distance < closestTop) {\n closestTop = distance;\n bestId = id;\n }\n }\n }\n\n // Fallback: when no section currently intersects (e.g. scrolled past the\n // bottom, or between sections), pick the tracked section whose top is closest\n // to the trigger line so something stays highlighted.\n if (!bestId && this.#intersectionStates.size > 0) {\n let absoluteClosestId = \"\";\n let absoluteClosestTop = Number.MAX_VALUE;\n\n for (const [id, state] of this.#intersectionStates.entries()) {\n const distance = Math.abs(state.top - triggerLine);\n if (distance < absoluteClosestTop) {\n absoluteClosestTop = distance;\n absoluteClosestId = id;\n }\n }\n bestId = absoluteClosestId;\n }\n\n if (bestId && bestId !== this.#activeSectionId) {\n this.#activeSectionId = bestId;\n this.#syncActiveStates();\n }\n }\n\n #syncActiveStates(): void {\n const activeLink = this.linkTargets.find((l) => this.#getAnchorId(l) === this.#activeSectionId);\n\n for (const link of this.linkTargets) {\n const isActive = link === activeLink;\n if (isActive) {\n link.setAttribute(\"aria-current\", \"location\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n }\n\n if (activeLink) {\n this.dispatch(\"change\", { detail: { id: this.#activeSectionId, link: activeLink } });\n }\n }\n\n #getAnchorId(link: HTMLElement): string | null {\n const href = link.getAttribute(\"href\") || link.getAttribute(\"data-href\");\n if (!href?.startsWith(\"#\")) return null;\n return href.substring(1);\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":"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","file":"sticky_observer_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * These controllers each grew a separate implementation of the same idea;\n * this util is that idea written once — the boilerplate every consumer\n * repeated: the `IntersectionObserver` support guard, root resolution from a\n * selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state (the\n * infinite-scroll \"sentinel never left the viewport\" fix).\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\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 /**\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 */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\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 rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\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 }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The `change` event fires on each transition.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` feeds a negative top `rootMargin`\n * and must match the sticky element's CSS `top`. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n /** Shared IO plumbing (support guard, root resolution, active guard). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n const entry = entries[entries.length - 1];\n if (!entry) return;\n // The sentinel sits above the sticky element; once it is no longer\n // intersecting the (top-inset) root, the sticky element has stuck.\n this.#setStuck(!entry.isIntersecting);\n }\n\n override connect(): void {\n if (!this.hasSentinelTarget) return;\n this.#watcher.start(this.sentinelTarget, {\n rootSelector: this.rootSelectorValue,\n rootMargin: `-${this.offsetValue}px 0px 0px 0px`,\n threshold: [0],\n });\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n this.#stuck = null;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":"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","file":"sticky_observer_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\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 /**\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 */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\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 rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\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 }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The `change` event fires on each transition.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` feeds a negative top `rootMargin`\n * and must match the sticky element's CSS `top`. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n /** Shared IO plumbing (support guard, root resolution, active guard). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n const entry = entries[entries.length - 1];\n if (!entry) return;\n // The sentinel sits above the sticky element; once it is no longer\n // intersecting the (top-inset) root, the sticky element has stuck.\n this.#setStuck(!entry.isIntersecting);\n }\n\n override connect(): void {\n if (!this.hasSentinelTarget) return;\n this.#watcher.start(this.sentinelTarget, {\n rootSelector: this.rootSelectorValue,\n rootMargin: `-${this.offsetValue}px 0px 0px 0px`,\n threshold: [0],\n });\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n this.#stuck = null;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}
@@ -26,8 +26,7 @@ import { Controller } from '@hotwired/stimulus';
26
26
  * (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;
27
27
  * the consumer owns all styling. The optional `list` target marks the tablist
28
28
  * container as part of the semantic contract — the Inspector requires its
29
- * `role="tablist"` and future orientation support has an anchor; the controller
30
- * performs no runtime work on it.
29
+ * `role="tablist"`; the controller performs no runtime work on it.
31
30
  *
32
31
  * Behavior provided:
33
32
  * - Click a tab to select it.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/tabs_controller.ts"],"names":[],"mappings":";;;AAoCO,IAAM,cAAA,GAAN,cAA6B,UAAA,CAAwB;AAAA,EAC1D,OAAgB,OAAA,GAAU,CAAC,KAAA,EAAO,SAAS,MAAM,CAAA;AAAA,EACjD,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA;AAAA,EAM9B,OAAA,GAAgB;AACvB,IAAA,MAAM,WAAA,GAAc,KAAK,UAAA,CAAW,SAAA;AAAA,MAClC,CAAC,GAAA,KAAQ,GAAA,CAAI,YAAA,CAAa,eAAe,CAAA,KAAM;AAAA,KACjD;AACA,IAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,EAAA,GAAK,CAAA,GAAI,aAAa,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC1E;AAAA;AAAA,EAGA,OAAO,KAAA,EAAoB;AACzB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,MAAM,aAAkC,CAAA;AAC9E,IAAA,IAAI,KAAA,KAAU,IAAI,IAAA,CAAK,YAAA,CAAa,OAAO,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,MAAM,OAAO,IAAA,CAAK,UAAA;AAClB,IAAA,MAAM,YAAA,GAAe,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,aAAkC,CAAA;AAC1E,IAAA,IAAI,iBAAiB,EAAA,EAAI;AAEzB,IAAA,IAAI,SAAA,GAA2B,IAAA;AAC/B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,KAAK,IAAA,CAAK,MAAA;AACtC,QAAA;AAAA,MACF,KAAK,WAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,CAAA,GAAI,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,MAAA;AACpD,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,SAAA,GAAY,CAAA;AACZ,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,SAAA,GAAY,KAAK,MAAA,GAAS,CAAA;AAC1B,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAGJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,YAAA,CAAa,SAAA,EAAW,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAA,CAAa,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AAC/D,IAAA,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,CAAC,GAAA,EAAK,CAAA,KAAM;AAClC,MAAA,MAAM,WAAW,CAAA,KAAM,KAAA;AACvB,MAAA,GAAA,CAAI,YAAA,CAAa,eAAA,EAAiB,QAAA,GAAW,MAAA,GAAS,OAAO,CAAA;AAC7D,MAAA,GAAA,CAAI,QAAA,GAAW,WAAW,CAAA,GAAI,EAAA;AAAA,IAChC,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,CAAC,KAAA,EAAO,CAAA,KAAM;AACtC,MAAA,KAAA,CAAM,SAAS,CAAA,KAAM,KAAA;AAAA,IACvB,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,KAAK,GAAG,KAAA,EAAM;AAAA,EAC3C;AACF","file":"tabs_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible tabs behavior.\n *\n * Markup contract (identifier: `stimeo--tabs`):\n * <div data-controller=\"stimeo--tabs\">\n * <div role=\"tablist\" aria-label=\"…\" data-stimeo--tabs-target=\"list\">\n * <button role=\"tab\" id=\"tab-1\" aria-controls=\"panel-1\"\n * data-stimeo--tabs-target=\"tab\"\n * data-action=\"stimeo--tabs#select\n * keydown->stimeo--tabs#onKeydown\">Tab 1</button>\n * <!-- more tabs -->\n * </div>\n * <div role=\"tabpanel\" id=\"panel-1\" aria-labelledby=\"tab-1\"\n * data-stimeo--tabs-target=\"panel\">…</div>\n * <!-- more panels -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Tabs** pattern with **automatic activation**:\n * moving focus with the arrow keys immediately selects the focused tab. Tabs are\n * paired to panels by index (the Nth tab controls the Nth panel).\n *\n * @remarks\n * Behavior only. State is exposed through `aria-selected`, roving `tabindex`\n * (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;\n * the consumer owns all styling. The optional `list` target marks the tablist\n * container as part of the semantic contract — the Inspector requires its\n * `role=\"tablist\"` and future orientation support has an anchor; the controller\n * performs no runtime work on it.\n *\n * Behavior provided:\n * - Click a tab to select it.\n * - `ArrowRight`/`ArrowLeft` move to and select the next/previous tab (wrapping);\n * `Home`/`End` select the first/last tab.\n */\nexport class TabsController extends Controller<HTMLElement> {\n static override targets = [\"tab\", \"panel\", \"list\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n\n declare readonly tabTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Selects the initially active tab (the pre-selected one, else the first). */\n override connect(): void {\n const preselected = this.tabTargets.findIndex(\n (tab) => tab.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });\n }\n\n /** Selects the clicked tab. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.tabTargets.indexOf(event.currentTarget as HTMLButtonElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Implements arrow/Home/End navigation with automatic activation. */\n onKeydown(event: KeyboardEvent): void {\n const tabs = this.tabTargets;\n const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement);\n if (currentIndex === -1) return;\n\n let nextIndex: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n nextIndex = (currentIndex + 1) % tabs.length;\n break;\n case \"ArrowLeft\":\n nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;\n break;\n case \"Home\":\n nextIndex = 0;\n break;\n case \"End\":\n nextIndex = tabs.length - 1;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(nextIndex, { focus: true });\n }\n\n /**\n * Activates the tab/panel pair at `index`: updates `aria-selected`, the roving\n * `tabindex`, and panel visibility. Optionally moves focus to the new tab.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n this.tabTargets.forEach((tab, i) => {\n const selected = i === index;\n tab.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n tab.tabIndex = selected ? 0 : -1;\n });\n this.panelTargets.forEach((panel, i) => {\n panel.hidden = i !== index;\n });\n if (focus) this.tabTargets[index]?.focus();\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/controllers/tabs_controller.ts"],"names":[],"mappings":";;;AAmCO,IAAM,cAAA,GAAN,cAA6B,UAAA,CAAwB;AAAA,EAC1D,OAAgB,OAAA,GAAU,CAAC,KAAA,EAAO,SAAS,MAAM,CAAA;AAAA,EACjD,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,QAAQ,CAAA;AAAA;AAAA,EAM9B,OAAA,GAAgB;AACvB,IAAA,MAAM,WAAA,GAAc,KAAK,UAAA,CAAW,SAAA;AAAA,MAClC,CAAC,GAAA,KAAQ,GAAA,CAAI,YAAA,CAAa,eAAe,CAAA,KAAM;AAAA,KACjD;AACA,IAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,EAAA,GAAK,CAAA,GAAI,aAAa,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC1E;AAAA;AAAA,EAGA,OAAO,KAAA,EAAoB;AACzB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,MAAM,aAAkC,CAAA;AAC9E,IAAA,IAAI,KAAA,KAAU,IAAI,IAAA,CAAK,YAAA,CAAa,OAAO,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,MAAM,OAAO,IAAA,CAAK,UAAA;AAClB,IAAA,MAAM,YAAA,GAAe,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,aAAkC,CAAA;AAC1E,IAAA,IAAI,iBAAiB,EAAA,EAAI;AAEzB,IAAA,IAAI,SAAA,GAA2B,IAAA;AAC/B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,KAAK,IAAA,CAAK,MAAA;AACtC,QAAA;AAAA,MACF,KAAK,WAAA;AACH,QAAA,SAAA,GAAA,CAAa,YAAA,GAAe,CAAA,GAAI,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,MAAA;AACpD,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,SAAA,GAAY,CAAA;AACZ,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,SAAA,GAAY,KAAK,MAAA,GAAS,CAAA;AAC1B,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAGJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,YAAA,CAAa,SAAA,EAAW,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAA,CAAa,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AAC/D,IAAA,IAAA,CAAK,UAAA,CAAW,OAAA,CAAQ,CAAC,GAAA,EAAK,CAAA,KAAM;AAClC,MAAA,MAAM,WAAW,CAAA,KAAM,KAAA;AACvB,MAAA,GAAA,CAAI,YAAA,CAAa,eAAA,EAAiB,QAAA,GAAW,MAAA,GAAS,OAAO,CAAA;AAC7D,MAAA,GAAA,CAAI,QAAA,GAAW,WAAW,CAAA,GAAI,EAAA;AAAA,IAChC,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,YAAA,CAAa,OAAA,CAAQ,CAAC,KAAA,EAAO,CAAA,KAAM;AACtC,MAAA,KAAA,CAAM,SAAS,CAAA,KAAM,KAAA;AAAA,IACvB,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,IAAA,CAAK,UAAA,CAAW,KAAK,GAAG,KAAA,EAAM;AAAA,EAC3C;AACF","file":"tabs_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible tabs behavior.\n *\n * Markup contract (identifier: `stimeo--tabs`):\n * <div data-controller=\"stimeo--tabs\">\n * <div role=\"tablist\" aria-label=\"…\" data-stimeo--tabs-target=\"list\">\n * <button role=\"tab\" id=\"tab-1\" aria-controls=\"panel-1\"\n * data-stimeo--tabs-target=\"tab\"\n * data-action=\"stimeo--tabs#select\n * keydown->stimeo--tabs#onKeydown\">Tab 1</button>\n * <!-- more tabs -->\n * </div>\n * <div role=\"tabpanel\" id=\"panel-1\" aria-labelledby=\"tab-1\"\n * data-stimeo--tabs-target=\"panel\">…</div>\n * <!-- more panels -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Tabs** pattern with **automatic activation**:\n * moving focus with the arrow keys immediately selects the focused tab. Tabs are\n * paired to panels by index (the Nth tab controls the Nth panel).\n *\n * @remarks\n * Behavior only. State is exposed through `aria-selected`, roving `tabindex`\n * (`0` for the active tab, `-1` for the rest), and the panel `hidden` attribute;\n * the consumer owns all styling. The optional `list` target marks the tablist\n * container as part of the semantic contract — the Inspector requires its\n * `role=\"tablist\"`; the controller performs no runtime work on it.\n *\n * Behavior provided:\n * - Click a tab to select it.\n * - `ArrowRight`/`ArrowLeft` move to and select the next/previous tab (wrapping);\n * `Home`/`End` select the first/last tab.\n */\nexport class TabsController extends Controller<HTMLElement> {\n static override targets = [\"tab\", \"panel\", \"list\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n\n declare readonly tabTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Selects the initially active tab (the pre-selected one, else the first). */\n override connect(): void {\n const preselected = this.tabTargets.findIndex(\n (tab) => tab.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });\n }\n\n /** Selects the clicked tab. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.tabTargets.indexOf(event.currentTarget as HTMLButtonElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Implements arrow/Home/End navigation with automatic activation. */\n onKeydown(event: KeyboardEvent): void {\n const tabs = this.tabTargets;\n const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement);\n if (currentIndex === -1) return;\n\n let nextIndex: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n nextIndex = (currentIndex + 1) % tabs.length;\n break;\n case \"ArrowLeft\":\n nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;\n break;\n case \"Home\":\n nextIndex = 0;\n break;\n case \"End\":\n nextIndex = tabs.length - 1;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(nextIndex, { focus: true });\n }\n\n /**\n * Activates the tab/panel pair at `index`: updates `aria-selected`, the roving\n * `tabindex`, and panel visibility. Optionally moves focus to the new tab.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n this.tabTargets.forEach((tab, i) => {\n const selected = i === index;\n tab.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n tab.tabIndex = selected ? 0 : -1;\n });\n this.panelTargets.forEach((panel, i) => {\n panel.hidden = i !== index;\n });\n if (focus) this.tabTargets[index]?.focus();\n }\n}\n"]}
@@ -16,15 +16,9 @@ import { Controller } from '@hotwired/stimulus';
16
16
  * <div role="region" aria-label="Notifications">
17
17
  * <ol data-stimeo--toast-target="list"></ol>
18
18
  * <template data-stimeo--toast-target="template">
19
- * <li role="status" data-stimeo--toast-target="item"
20
- * data-action="mouseenter->stimeo--toast#pause
21
- * mouseleave->stimeo--toast#resume
22
- * focusin->stimeo--toast#pause
23
- * focusout->stimeo--toast#resume
24
- * keydown->stimeo--toast#onKeydown"
25
- * tabindex="0">
26
- * <span data-toast-slot="body"></span>
27
- * <button type="button" data-action="stimeo--toast#dismiss">Dismiss</button>
19
+ * <li data-stimeo--toast-target="item" tabindex="0">
20
+ * <span role="status" data-toast-slot="body"></span>
21
+ * <button type="button" data-toast-dismiss>Dismiss</button>
28
22
  * </li>
29
23
  * </template>
30
24
  * </div>
@@ -61,6 +55,10 @@ declare class ToastController extends Controller<HTMLElement> {
61
55
  maxValue: number;
62
56
  connect(): void;
63
57
  disconnect(): void;
58
+ /** Rebinds delegated interaction when Turbo replaces the list target in place. */
59
+ listTargetConnected(element: HTMLElement): void;
60
+ /** Releases delegation only when the removed target is its current owner. */
61
+ listTargetDisconnected(element: HTMLElement): void;
64
62
  durationValueChanged(): void;
65
63
  maxValueChanged(): void;
66
64
  /**