stimeo-ui 0.1.0-beta.2 → 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.
- package/CHANGELOG.md +107 -0
- package/README.md +6 -6
- package/dist/controllers/auto_submit_controller.js +56 -21
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.js +52 -15
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +6 -2
- package/dist/controllers/combobox_controller.js +64 -3
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +13 -6
- package/dist/controllers/command_palette_controller.js +256 -39
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +107 -8
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +11 -6
- package/dist/controllers/context_menu_controller.js +191 -13
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +2 -2
- package/dist/controllers/countdown_controller.js +2 -2
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +107 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -6
- package/dist/controllers/dropdown_controller.js +139 -16
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +107 -8
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +2 -2
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +15 -12
- package/dist/controllers/hover_card_controller.js +103 -27
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js +1 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +18 -11
- package/dist/controllers/menu_controller.js +181 -8
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +62 -22
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +3 -2
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +3 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +18 -10
- package/dist/controllers/popover_controller.js +118 -27
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +2 -2
- package/dist/controllers/sortable_controller.js +1 -1
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +1 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +7 -9
- package/dist/controllers/toast_controller.js +108 -29
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +15 -18
- package/dist/controllers/tooltip_controller.js +148 -42
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/index.d.ts +44 -14
- package/dist/index.js +847 -349
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +6 -6
- package/dist/inspector/manifest.json +16 -3
- package/package.json +7 -9
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/form_validation_controller.ts"],"names":[],"mappings":"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","file":"form_validation_controller.js","sourcesContent":["/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / drawer).\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never closes itself). The trap calls\n * `preventDefault()` before invoking it.\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n document.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n if (this.#options.onEscape) {\n event.preventDefault();\n this.#options.onEscape();\n }\n return;\n }\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FOCUSABLE } from \"../utils/focus_trap\";\nimport type { FormFieldController } from \"./form_field_controller\";\n\n/** Native form controls that participate in constraint validation. */\ntype ValidatableControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;\n\n/**\n * Maps each `ValidityState` flag (in priority order) to the kebab-case suffix of\n * its per-constraint message-override attribute. The first flag that is `true`\n * wins, so e.g. `valueMissing` is reported before a stale `patternMismatch`.\n */\nconst CONSTRAINT_MESSAGE_KEYS: ReadonlyArray<readonly [keyof ValidityState, string]> = [\n [\"valueMissing\", \"value-missing\"],\n [\"typeMismatch\", \"type-mismatch\"],\n [\"patternMismatch\", \"pattern-mismatch\"],\n [\"tooShort\", \"too-short\"],\n [\"tooLong\", \"too-long\"],\n [\"rangeUnderflow\", \"range-underflow\"],\n [\"rangeOverflow\", \"range-overflow\"],\n [\"stepMismatch\", \"step-mismatch\"],\n [\"badInput\", \"bad-input\"],\n];\n\n/** Attribute prefix for a per-constraint message override, authored on the control. */\nconst MESSAGE_ATTR_PREFIX = \"data-stimeo--form-field-message-\";\n/** Attribute for a generic message override applied to any failing constraint. */\nconst MESSAGE_ATTR_GENERIC = \"data-stimeo--form-field-message\";\n/** Attribute opting a control into a declarative custom rule (`\"whitespace\"`). */\nconst DISALLOW_ATTR = \"data-stimeo--form-field-disallow\";\n/** Default message when `disallow=\"whitespace\"` fails and no override is given. */\nconst DISALLOW_WHITESPACE_DEFAULT = \"Please enter a value that is not only whitespace.\";\n\n/** A field's validatable controls, grouped so the whole field validates together. */\ninterface FieldGroup {\n readonly field: FormFieldController | undefined;\n readonly controls: ValidatableControl[];\n}\n\n/**\n * Headless, accessible **form-validation orchestration**.\n *\n * Markup contract (identifier: `stimeo--form-validation`):\n * <form data-controller=\"stimeo--form-validation\"\n * data-stimeo--form-validation-stimeo--form-field-outlet=\"[data-controller~='stimeo--form-field']\">\n * <div data-controller=\"stimeo--form-field\">\n * <label for=\"email\">Email</label>\n * <input id=\"email\" type=\"email\" required\n * data-stimeo--form-field-target=\"control\" />\n * <p role=\"alert\" hidden data-stimeo--form-field-target=\"error\"></p>\n * </div>\n * <button type=\"submit\">Save</button>\n * </form>\n *\n * Not an APG widget pattern — this is the *timing* layer for **error\n * identification** (WCAG 3.3.1) and **error suggestion** (3.3.3): it decides\n * *when* each control is checked and routes the browser's native\n * `validationMessage` into the field's {@link FormFieldController} error region.\n * The per-field ARIA wiring (`aria-invalid` / `aria-errormessage` /\n * `aria-describedby`) therefore lives in exactly one place — `stimeo--form-field`,\n * reached through a Stimulus **outlet** — and is never re-implemented here.\n *\n * @remarks\n * Behavior only — validation **rules** stay in the markup (native HTML\n * constraints: `required`, `type`, `pattern`, `min`/`max`, …) or in the consumer's\n * own `setCustomValidity()` calls, which `checkValidity()` surfaces transparently.\n * It sets the form's `novalidate` so it can replace the browser's default error\n * bubbles with the accessible, in-page `role=\"alert\"` regions, and restores the\n * attribute on disconnect.\n *\n * Two declarative escape hatches let a field **exceed** native validation with no\n * consumer JS (author them on the control):\n * - **Per-constraint messages** — `data-stimeo--form-field-message-<constraint>`\n * (`value-missing`, `too-short`, `too-long`, `pattern-mismatch`, `type-mismatch`,\n * `range-overflow`, `range-underflow`, `step-mismatch`, `bad-input`), or a generic\n * `data-stimeo--form-field-message` fallback, override the shown text per failing\n * `ValidityState` flag — controlled, localizable wording that also fixes headless\n * browsers returning an empty native `validationMessage`. Falls back to native.\n * - **`data-stimeo--form-field-disallow=\"whitespace\"`** — a built-in custom rule\n * rejecting a value that is blank after trimming (which slips past `required` /\n * `minlength`), wired through `setCustomValidity` so it blocks submit like any\n * native constraint.\n *\n * Behavior provided:\n * - On connect, suppresses native bubbles (`novalidate`, restored on disconnect)\n * and intercepts the form's `submit` in the **capture phase** so an invalid form\n * is cancelled before any other submit handler (e.g. `stimeo--submit-once`)\n * reacts to a submission that will never happen.\n * - On submit, validates every control; if any is invalid it blocks submission,\n * moves focus to the first invalid control (unless `focusInvalid` is `false`),\n * and dispatches `stimeo--form-validation:invalid`. An all-valid form dispatches\n * `:valid` and submits normally.\n * - Validates a field on blur once it has been interacted with (`validateOnBlur`),\n * and re-validates it on input while it is already touched\n * (`revalidateOnInput`) so a shown message clears the moment the value becomes\n * valid — but a pristine field is never eagerly flagged mid-typing.\n *\n * A control with no owning `stimeo--form-field` outlet is still validated (it can\n * block submit and receive focus) but renders no message.\n *\n * Radio groups work unchanged: point the field's `control` target at the\n * `role=\"radiogroup\"` container so the invalid state lands on the group, and the\n * group is reported as a single invalid entry (not one per radio).\n *\n * Rich widgets (listbox, time-picker, …) that keep their committed value in a\n * hidden holder participate by making that holder a **validatable mirror**:\n * `<input type=\"text\" hidden required>` — the `hidden` *attribute*, not\n * `type=\"hidden\"`, which is barred from constraint validation. Native\n * constraints then govern the widget's value with no extra JavaScript. The\n * widget dispatches a bubbling `change` on the mirror when a value is committed\n * (a completed interaction, so it validates immediately), and focus for an\n * invalid mirror is delegated to the field's visible `control` target — the\n * target itself when focusable, else its first focusable descendant.\n *\n * No-JS caveat: a `required` mirror also gates the browser's own pre-Stimulus\n * validation, which cannot surface UI on an invisible control. When the no-JS\n * fallback matters, author `novalidate` on the form (this controller preserves\n * an author-set attribute) so the submission reaches the server's validation.\n */\nexport class FormValidationController extends Controller<HTMLFormElement> {\n static override outlets = [\"stimeo--form-field\"];\n static override values = {\n validateOnBlur: { type: Boolean, default: true },\n validateOnChange: { type: Boolean, default: true },\n revalidateOnInput: { type: Boolean, default: true },\n focusInvalid: { type: Boolean, default: true },\n };\n static actions = [\"validate\"] as const;\n static events = [\"valid\", \"invalid\"] as const;\n\n declare readonly stimeoFormFieldOutlets: FormFieldController[];\n declare readonly stimeoFormFieldOutletElements: HTMLElement[];\n\n declare validateOnBlurValue: boolean;\n declare validateOnChangeValue: boolean;\n declare revalidateOnInputValue: boolean;\n declare focusInvalidValue: boolean;\n\n /** Marker recording that we added `novalidate`, so we only remove our own. */\n static readonly #NOVALIDATE_MARKER = \"data-stimeo--form-validation-novalidate\";\n\n /** Controls already interacted with — the gate for blur / input (re)validation. */\n readonly #touched = new WeakSet<ValidatableControl>();\n\n /**\n * Controls whose `customError` *we* set via the `disallow` rule. Tracked so we\n * only ever clear our own custom validity — a consumer's `setCustomValidity` on\n * the same control survives once our rule passes (don't-clobber-authored-state).\n */\n readonly #ownedCustomError = new WeakSet<ValidatableControl>();\n\n readonly #onSubmit = (event: SubmitEvent): void => {\n if (event.target !== this.element) return;\n const invalid = this.#validateAll();\n if (invalid.length === 0) {\n this.dispatch(\"valid\", { detail: {} });\n return;\n }\n // Cancel the whole submit: preventDefault stops native/Turbo navigation;\n // stopImmediatePropagation keeps later submit handlers (e.g. submit-once's\n // busy state) from acting on a submission that will never happen.\n event.preventDefault();\n event.stopImmediatePropagation();\n const first = invalid[0];\n if (this.focusInvalidValue && first) this.#focusTargetFor(first)?.focus();\n this.dispatch(\"invalid\", { detail: { invalid } });\n };\n\n readonly #onFocusOut = (event: FocusEvent): void => {\n if (!this.validateOnBlurValue) return;\n const control = this.#controlFrom(event.target);\n if (!control) return;\n // Focus moving *within* the same field (e.g. between members of a radio\n // group) is not leaving it — defer validation until focus actually exits.\n const field = this.#fieldFor(control);\n const related = event.relatedTarget;\n if (field && related instanceof Node && field.element.contains(related)) return;\n this.#touched.add(control);\n this.#validateControl(control);\n };\n\n readonly #onInput = (event: Event): void => {\n if (!this.revalidateOnInputValue) return;\n const control = this.#controlFrom(event.target);\n // Only re-validate a field the user has already left once, so the first\n // keystroke never eagerly flags a control they are still filling in.\n if (!control || !this.#touched.has(control)) return;\n this.#validateControl(control);\n };\n\n readonly #onChange = (event: Event): void => {\n if (!this.validateOnChangeValue) return;\n const control = this.#controlFrom(event.target);\n if (!control) return;\n // change marks a *committed* interaction (a picked option, a toggled box, a\n // widget writing its mirror), so unlike input it both touches and validates.\n this.#touched.add(control);\n this.#validateControl(control);\n };\n\n /** Suppresses native bubbles and binds the submit / blur / input listeners. */\n override connect(): void {\n if (!this.element.hasAttribute(\"novalidate\")) {\n this.element.setAttribute(\"novalidate\", \"\");\n this.element.setAttribute(FormValidationController.#NOVALIDATE_MARKER, \"\");\n }\n // Capture phase on the document so we run before any submit listener bound to\n // the form itself (Stimulus actions, submit-once), whose relative order in the\n // target phase would otherwise be unpredictable.\n document.addEventListener(\"submit\", this.#onSubmit, true);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n this.element.addEventListener(\"input\", this.#onInput);\n this.element.addEventListener(\"change\", this.#onChange);\n }\n\n /** Tears down listeners and restores `novalidate` if we added it. */\n override disconnect(): void {\n document.removeEventListener(\"submit\", this.#onSubmit, true);\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n this.element.removeEventListener(\"input\", this.#onInput);\n this.element.removeEventListener(\"change\", this.#onChange);\n if (this.element.hasAttribute(FormValidationController.#NOVALIDATE_MARKER)) {\n this.element.removeAttribute(\"novalidate\");\n this.element.removeAttribute(FormValidationController.#NOVALIDATE_MARKER);\n }\n }\n\n /**\n * Validates every control now, rendering or clearing each field's message, and\n * returns whether the whole form is valid. Marks every control touched so a\n * later input re-validates it. Bound via `data-action`\n * (`#validate`) or callable directly (e.g. before a programmatic submit).\n */\n validate(): boolean {\n return this.#validateAll().length === 0;\n }\n\n /**\n * Validates every control and returns one invalid control per field. Controls\n * are grouped by field first (see {@link #keyFor}) so a field with several\n * controls — a radio group, or a mirror plus its visible control — reflects\n * *all* of them: a valid sibling must never clear an invalid one's message.\n * Each group's first invalid control supplies the message and the focus target.\n */\n #validateAll(): ValidatableControl[] {\n const groups = new Map<unknown, FieldGroup>();\n for (const control of this.#controls) {\n this.#touched.add(control);\n const field = this.#fieldFor(control);\n const key = this.#keyFor(control, field);\n const group = groups.get(key);\n if (group) {\n group.controls.push(control);\n } else {\n groups.set(key, { field, controls: [control] });\n }\n }\n\n const invalid: ValidatableControl[] = [];\n for (const group of groups.values()) {\n const firstInvalid = this.#applyGroup(group);\n if (firstInvalid) invalid.push(firstInvalid);\n }\n return invalid;\n }\n\n /** Re-validates the whole field a single control belongs to (or that control). */\n #validateControl(control: ValidatableControl): void {\n const field = this.#fieldFor(control);\n const key = this.#keyFor(control, field);\n const controls = this.#controls.filter(\n (other) => this.#keyFor(other, this.#fieldFor(other)) === key,\n );\n this.#applyGroup({ field, controls });\n }\n\n /**\n * Runs native constraint validation across a field's controls and routes the\n * result to its `stimeo--form-field` outlet: the first invalid control's\n * `validationMessage` is shown, an all-valid field is cleared. Returns the\n * first invalid control (for the invalid list / focus), or `null` when valid.\n * Routing goes through the outlet, so the ARIA wiring is never duplicated here.\n */\n #applyGroup(group: FieldGroup): ValidatableControl | null {\n // Apply declarative custom rules (e.g. disallow=\"whitespace\") before reading\n // validity so they participate in checkValidity() like a native constraint.\n for (const control of group.controls) this.#syncCustomValidity(control);\n const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;\n if (group.field) {\n if (firstInvalid) {\n group.field.setError(this.#messageFor(firstInvalid));\n } else {\n group.field.clearError();\n }\n }\n return firstInvalid;\n }\n\n /**\n * Resolves the message to show for an invalid control: a per-constraint\n * override (`data-stimeo--form-field-message-<constraint>`) for the first failing\n * `ValidityState` flag, then a generic `data-stimeo--form-field-message`\n * override, then the browser's native `validationMessage`. Authoring an override\n * gives controlled, localizable, theme-able wording with **no consumer JS** —\n * and sidesteps headless browsers that return an empty native message.\n */\n #messageFor(control: ValidatableControl): string {\n for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {\n if (control.validity[flag]) {\n return (\n control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ??\n control.getAttribute(MESSAGE_ATTR_GENERIC) ??\n control.validationMessage\n );\n }\n }\n // customError (our `disallow` rule, or a consumer's setCustomValidity): for our\n // rule the message was already resolved with per-constraint > generic > default\n // precedence when set, and a consumer error carries its own text — so return the\n // live validationMessage as-is, falling back to the generic override then \"\".\n return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || \"\";\n }\n\n /**\n * Applies (or clears) a declarative custom constraint via `setCustomValidity`,\n * for controls that opt in with `data-stimeo--form-field-disallow`. The one\n * supported rule today is `\"whitespace\"` — a value that is non-empty but blank\n * after trimming (which slips past `required` / `minlength`); its message follows\n * the per-constraint (`value-missing`) → generic → default chain.\n *\n * Don't-clobber-authored-state: an unknown/absent rule is never touched, and a\n * custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),\n * so a consumer's own `setCustomValidity` on the same control survives.\n */\n #syncCustomValidity(control: ValidatableControl): void {\n const violates =\n control.getAttribute(DISALLOW_ATTR) === \"whitespace\" &&\n control.value.length > 0 &&\n control.value.trim() === \"\";\n if (violates) {\n control.setCustomValidity(\n control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ??\n control.getAttribute(MESSAGE_ATTR_GENERIC) ??\n DISALLOW_WHITESPACE_DEFAULT,\n );\n this.#ownedCustomError.add(control);\n } else if (this.#ownedCustomError.has(control)) {\n // Only clear the custom error we previously set; leave a consumer's intact.\n this.#ownedCustomError.delete(control);\n control.setCustomValidity(\"\");\n }\n }\n\n /**\n * A grouping key that collects controls belonging to the same field: the owning\n * `stimeo--form-field` when present, else a radio group's shared `name`, else\n * the control itself (always distinct).\n */\n #keyFor(control: ValidatableControl, field: FormFieldController | undefined): unknown {\n if (field) return field;\n if (control instanceof HTMLInputElement && control.type === \"radio\" && control.name) {\n return `radio:${control.name}`;\n }\n return control;\n }\n\n /**\n * Where focus should land for an invalid control. A visible control is focused\n * directly (status quo for native fields and radios). A validatable mirror\n * (the `hidden` attribute) cannot receive focus, so focus is delegated to the\n * visible widget: the owning field's `control` target when it is itself\n * focusable, else its first focusable descendant (e.g. a roving-tabindex\n * member). Resolved structurally — never by probing `focus()` — so behavior\n * is deterministic and CSS-independent.\n */\n #focusTargetFor(control: ValidatableControl): HTMLElement | null {\n if (!control.hidden) return control;\n const field = this.#fieldFor(control);\n if (!field?.hasControlTarget) return null;\n const root = field.controlTarget;\n if (root.matches(FOCUSABLE)) return root;\n return root.querySelector<HTMLElement>(FOCUSABLE);\n }\n\n /** The `stimeo--form-field` outlet whose element contains `control`, if any. */\n #fieldFor(control: ValidatableControl): FormFieldController | undefined {\n const elements = this.stimeoFormFieldOutletElements;\n for (let index = 0; index < elements.length; index++) {\n if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];\n }\n return undefined;\n }\n\n /** This form's native controls that participate in constraint validation. */\n get #controls(): ValidatableControl[] {\n const controls: ValidatableControl[] = [];\n for (const element of Array.from(this.element.elements)) {\n if (this.#isValidatable(element)) controls.push(element);\n }\n return controls;\n }\n\n /** Narrows an event target to a validatable control. */\n #controlFrom(target: EventTarget | null): ValidatableControl | null {\n return target instanceof Element && this.#isValidatable(target) ? target : null;\n }\n\n #isValidatable(element: Element): element is ValidatableControl {\n return (\n (element instanceof HTMLInputElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement) &&\n // `willValidate` already excludes disabled, read-only, hidden, and button\n // controls — the exact set barred from constraint validation.\n element.willValidate\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/form_validation_controller.ts"],"names":[],"mappings":"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","file":"form_validation_controller.js","sourcesContent":["import { EscapeLayer } from \"./escape_layer\";\n\n/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / confirm / drawer / command-palette / sidebar); the\n * non-modal focus scope (focus) reuses it with the modal side effects opted out.\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog) and kept out of the snapshot Turbo\n * caches (`turbo:before-cache`). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never joins the Escape stack). Dismissal\n * is resolved by the shared {@link EscapeLayer}: an Escape already consumed\n * by an inner handler is ignored, and among active layers the most recently\n * activated claiming one owns the press. The resolver consumes the event\n * before invoking the callback (the shared layered-Escape contract).\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n document.addEventListener(\"turbo:before-cache\", this.#onBeforeCache);\n const onEscape = this.#options.onEscape;\n if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"keydown\", this.#onKeydown);\n document.removeEventListener(\"turbo:before-cache\", this.#onBeforeCache);\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /**\n * Reverts the side effects just before Turbo caches the page snapshot, so an\n * overlay left open does not bake the scroll lock into `body[style]` — a\n * restored page would feed that locked value back into {@link activate} as the\n * baseline, and closing would then never unlock the page. Markup state stays\n * untouched (restore-open designs reopen against a clean baseline), and focus\n * is left alone mid-navigation. The listener lives only while active.\n */\n readonly #onBeforeCache = (): void => {\n this.deactivate({ restoreFocus: false });\n };\n\n /**\n * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the\n * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of\n * which layer currently owns Escape.\n */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FOCUSABLE } from \"../utils/focus_trap\";\nimport type { FormFieldController } from \"./form_field_controller\";\n\n/** Native form controls that participate in constraint validation. */\ntype ValidatableControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;\n\n/**\n * Maps each `ValidityState` flag (in priority order) to the kebab-case suffix of\n * its per-constraint message-override attribute. The first flag that is `true`\n * wins, so e.g. `valueMissing` is reported before a stale `patternMismatch`.\n */\nconst CONSTRAINT_MESSAGE_KEYS: ReadonlyArray<readonly [keyof ValidityState, string]> = [\n [\"valueMissing\", \"value-missing\"],\n [\"typeMismatch\", \"type-mismatch\"],\n [\"patternMismatch\", \"pattern-mismatch\"],\n [\"tooShort\", \"too-short\"],\n [\"tooLong\", \"too-long\"],\n [\"rangeUnderflow\", \"range-underflow\"],\n [\"rangeOverflow\", \"range-overflow\"],\n [\"stepMismatch\", \"step-mismatch\"],\n [\"badInput\", \"bad-input\"],\n];\n\n/** Attribute prefix for a per-constraint message override, authored on the control. */\nconst MESSAGE_ATTR_PREFIX = \"data-stimeo--form-field-message-\";\n/** Attribute for a generic message override applied to any failing constraint. */\nconst MESSAGE_ATTR_GENERIC = \"data-stimeo--form-field-message\";\n/** Attribute opting a control into a declarative custom rule (`\"whitespace\"`). */\nconst DISALLOW_ATTR = \"data-stimeo--form-field-disallow\";\n/** Default message when `disallow=\"whitespace\"` fails and no override is given. */\nconst DISALLOW_WHITESPACE_DEFAULT = \"Please enter a value that is not only whitespace.\";\n\n/** A field's validatable controls, grouped so the whole field validates together. */\ninterface FieldGroup {\n readonly field: FormFieldController | undefined;\n readonly controls: ValidatableControl[];\n}\n\n/**\n * Headless, accessible **form-validation orchestration**.\n *\n * Markup contract (identifier: `stimeo--form-validation`):\n * <form data-controller=\"stimeo--form-validation\"\n * data-stimeo--form-validation-stimeo--form-field-outlet=\"[data-controller~='stimeo--form-field']\">\n * <div data-controller=\"stimeo--form-field\">\n * <label for=\"email\">Email</label>\n * <input id=\"email\" type=\"email\" required\n * data-stimeo--form-field-target=\"control\" />\n * <p role=\"alert\" hidden data-stimeo--form-field-target=\"error\"></p>\n * </div>\n * <button type=\"submit\">Save</button>\n * </form>\n *\n * Not an APG widget pattern — this is the *timing* layer for **error\n * identification** (WCAG 3.3.1) and **error suggestion** (3.3.3): it decides\n * *when* each control is checked and routes the browser's native\n * `validationMessage` into the field's {@link FormFieldController} error region.\n * The per-field ARIA wiring (`aria-invalid` / `aria-errormessage` /\n * `aria-describedby`) therefore lives in exactly one place — `stimeo--form-field`,\n * reached through a Stimulus **outlet** — and is never re-implemented here.\n *\n * @remarks\n * Behavior only — validation **rules** stay in the markup (native HTML\n * constraints: `required`, `type`, `pattern`, `min`/`max`, …) or in the consumer's\n * own `setCustomValidity()` calls, which `checkValidity()` surfaces transparently.\n * It sets the form's `novalidate` so it can replace the browser's default error\n * bubbles with the accessible, in-page `role=\"alert\"` regions, and restores the\n * attribute on disconnect.\n *\n * Two declarative escape hatches let a field **exceed** native validation with no\n * consumer JS (author them on the control):\n * - **Per-constraint messages** — `data-stimeo--form-field-message-<constraint>`\n * (`value-missing`, `too-short`, `too-long`, `pattern-mismatch`, `type-mismatch`,\n * `range-overflow`, `range-underflow`, `step-mismatch`, `bad-input`), or a generic\n * `data-stimeo--form-field-message` fallback, override the shown text per failing\n * `ValidityState` flag — controlled, localizable wording that also fixes headless\n * browsers returning an empty native `validationMessage`. Falls back to native.\n * - **`data-stimeo--form-field-disallow=\"whitespace\"`** — a built-in custom rule\n * rejecting a value that is blank after trimming (which slips past `required` /\n * `minlength`), wired through `setCustomValidity` so it blocks submit like any\n * native constraint.\n *\n * Behavior provided:\n * - On connect, suppresses native bubbles (`novalidate`, restored on disconnect)\n * and intercepts the form's `submit` in the **capture phase** so an invalid form\n * is cancelled before any other submit handler (e.g. `stimeo--submit-once`)\n * reacts to a submission that will never happen.\n * - On submit, validates every control; if any is invalid it blocks submission,\n * moves focus to the first invalid control (unless `focusInvalid` is `false`),\n * and dispatches `stimeo--form-validation:invalid`. An all-valid form dispatches\n * `:valid` and submits normally.\n * - Validates a field on blur once it has been interacted with (`validateOnBlur`),\n * and re-validates it on input while it is already touched\n * (`revalidateOnInput`) so a shown message clears the moment the value becomes\n * valid — but a pristine field is never eagerly flagged mid-typing.\n *\n * A control with no owning `stimeo--form-field` outlet is still validated (it can\n * block submit and receive focus) but renders no message.\n *\n * Radio groups work unchanged: point the field's `control` target at the\n * `role=\"radiogroup\"` container so the invalid state lands on the group, and the\n * group is reported as a single invalid entry (not one per radio).\n *\n * Rich widgets (listbox, time-picker, …) that keep their committed value in a\n * hidden holder participate by making that holder a **validatable mirror**:\n * `<input type=\"text\" hidden required>` — the `hidden` *attribute*, not\n * `type=\"hidden\"`, which is barred from constraint validation. Native\n * constraints then govern the widget's value with no extra JavaScript. The\n * widget dispatches a bubbling `change` on the mirror when a value is committed\n * (a completed interaction, so it validates immediately), and focus for an\n * invalid mirror is delegated to the field's visible `control` target — the\n * target itself when focusable, else its first focusable descendant.\n *\n * No-JS caveat: a `required` mirror also gates the browser's own pre-Stimulus\n * validation, which cannot surface UI on an invisible control. When the no-JS\n * fallback matters, author `novalidate` on the form (this controller preserves\n * an author-set attribute) so the submission reaches the server's validation.\n */\nexport class FormValidationController extends Controller<HTMLFormElement> {\n static override outlets = [\"stimeo--form-field\"];\n static override values = {\n validateOnBlur: { type: Boolean, default: true },\n validateOnChange: { type: Boolean, default: true },\n revalidateOnInput: { type: Boolean, default: true },\n focusInvalid: { type: Boolean, default: true },\n };\n static actions = [\"validate\"] as const;\n static events = [\"valid\", \"invalid\"] as const;\n\n declare readonly stimeoFormFieldOutlets: FormFieldController[];\n declare readonly stimeoFormFieldOutletElements: HTMLElement[];\n\n declare validateOnBlurValue: boolean;\n declare validateOnChangeValue: boolean;\n declare revalidateOnInputValue: boolean;\n declare focusInvalidValue: boolean;\n\n /** Marker recording that we added `novalidate`, so we only remove our own. */\n static readonly #NOVALIDATE_MARKER = \"data-stimeo--form-validation-novalidate\";\n\n /** Controls already interacted with — the gate for blur / input (re)validation. */\n readonly #touched = new WeakSet<ValidatableControl>();\n\n /**\n * Controls whose `customError` *we* set via the `disallow` rule. Tracked so we\n * only ever clear our own custom validity — a consumer's `setCustomValidity` on\n * the same control survives once our rule passes (don't-clobber-authored-state).\n */\n readonly #ownedCustomError = new WeakSet<ValidatableControl>();\n\n readonly #onSubmit = (event: SubmitEvent): void => {\n if (event.target !== this.element) return;\n const invalid = this.#validateAll();\n if (invalid.length === 0) {\n this.dispatch(\"valid\", { detail: {} });\n return;\n }\n // Cancel the whole submit: preventDefault stops native/Turbo navigation;\n // stopImmediatePropagation keeps later submit handlers (e.g. submit-once's\n // busy state) from acting on a submission that will never happen.\n event.preventDefault();\n event.stopImmediatePropagation();\n const first = invalid[0];\n if (this.focusInvalidValue && first) this.#focusTargetFor(first)?.focus();\n this.dispatch(\"invalid\", { detail: { invalid } });\n };\n\n readonly #onFocusOut = (event: FocusEvent): void => {\n if (!this.validateOnBlurValue) return;\n const control = this.#controlFrom(event.target);\n if (!control) return;\n // Focus moving *within* the same field (e.g. between members of a radio\n // group) is not leaving it — defer validation until focus actually exits.\n const field = this.#fieldFor(control);\n const related = event.relatedTarget;\n if (field && related instanceof Node && field.element.contains(related)) return;\n this.#touched.add(control);\n this.#validateControl(control);\n };\n\n readonly #onInput = (event: Event): void => {\n if (!this.revalidateOnInputValue) return;\n const control = this.#controlFrom(event.target);\n // Only re-validate a field the user has already left once, so the first\n // keystroke never eagerly flags a control they are still filling in.\n if (!control || !this.#touched.has(control)) return;\n this.#validateControl(control);\n };\n\n readonly #onChange = (event: Event): void => {\n if (!this.validateOnChangeValue) return;\n const control = this.#controlFrom(event.target);\n if (!control) return;\n // change marks a *committed* interaction (a picked option, a toggled box, a\n // widget writing its mirror), so unlike input it both touches and validates.\n this.#touched.add(control);\n this.#validateControl(control);\n };\n\n /** Suppresses native bubbles and binds the submit / blur / input listeners. */\n override connect(): void {\n if (!this.element.hasAttribute(\"novalidate\")) {\n this.element.setAttribute(\"novalidate\", \"\");\n this.element.setAttribute(FormValidationController.#NOVALIDATE_MARKER, \"\");\n }\n // Capture phase on the document so we run before any submit listener bound to\n // the form itself (Stimulus actions, submit-once), whose relative order in the\n // target phase would otherwise be unpredictable.\n document.addEventListener(\"submit\", this.#onSubmit, true);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n this.element.addEventListener(\"input\", this.#onInput);\n this.element.addEventListener(\"change\", this.#onChange);\n }\n\n /** Tears down listeners and restores `novalidate` if we added it. */\n override disconnect(): void {\n document.removeEventListener(\"submit\", this.#onSubmit, true);\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n this.element.removeEventListener(\"input\", this.#onInput);\n this.element.removeEventListener(\"change\", this.#onChange);\n if (this.element.hasAttribute(FormValidationController.#NOVALIDATE_MARKER)) {\n this.element.removeAttribute(\"novalidate\");\n this.element.removeAttribute(FormValidationController.#NOVALIDATE_MARKER);\n }\n }\n\n /**\n * Validates every control now, rendering or clearing each field's message, and\n * returns whether the whole form is valid. Marks every control touched so a\n * later input re-validates it. Bound via `data-action`\n * (`#validate`) or callable directly (e.g. before a programmatic submit).\n */\n validate(): boolean {\n return this.#validateAll().length === 0;\n }\n\n /**\n * Validates every control and returns one invalid control per field. Controls\n * are grouped by field first (see {@link #keyFor}) so a field with several\n * controls — a radio group, or a mirror plus its visible control — reflects\n * *all* of them: a valid sibling must never clear an invalid one's message.\n * Each group's first invalid control supplies the message and the focus target.\n */\n #validateAll(): ValidatableControl[] {\n const groups = new Map<unknown, FieldGroup>();\n for (const control of this.#controls) {\n this.#touched.add(control);\n const field = this.#fieldFor(control);\n const key = this.#keyFor(control, field);\n const group = groups.get(key);\n if (group) {\n group.controls.push(control);\n } else {\n groups.set(key, { field, controls: [control] });\n }\n }\n\n const invalid: ValidatableControl[] = [];\n for (const group of groups.values()) {\n const firstInvalid = this.#applyGroup(group);\n if (firstInvalid) invalid.push(firstInvalid);\n }\n return invalid;\n }\n\n /** Re-validates the whole field a single control belongs to (or that control). */\n #validateControl(control: ValidatableControl): void {\n const field = this.#fieldFor(control);\n const key = this.#keyFor(control, field);\n const controls = this.#controls.filter(\n (other) => this.#keyFor(other, this.#fieldFor(other)) === key,\n );\n this.#applyGroup({ field, controls });\n }\n\n /**\n * Runs native constraint validation across a field's controls and routes the\n * result to its `stimeo--form-field` outlet: the first invalid control's\n * `validationMessage` is shown, an all-valid field is cleared. Returns the\n * first invalid control (for the invalid list / focus), or `null` when valid.\n * Routing goes through the outlet, so the ARIA wiring is never duplicated here.\n */\n #applyGroup(group: FieldGroup): ValidatableControl | null {\n // Apply declarative custom rules (e.g. disallow=\"whitespace\") before reading\n // validity so they participate in checkValidity() like a native constraint.\n for (const control of group.controls) this.#syncCustomValidity(control);\n const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;\n if (group.field) {\n if (firstInvalid) {\n group.field.setError(this.#messageFor(firstInvalid));\n } else {\n group.field.clearError();\n }\n }\n return firstInvalid;\n }\n\n /**\n * Resolves the message to show for an invalid control: a per-constraint\n * override (`data-stimeo--form-field-message-<constraint>`) for the first failing\n * `ValidityState` flag, then a generic `data-stimeo--form-field-message`\n * override, then the browser's native `validationMessage`. Authoring an override\n * gives controlled, localizable, theme-able wording with **no consumer JS** —\n * and sidesteps headless browsers that return an empty native message.\n */\n #messageFor(control: ValidatableControl): string {\n for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {\n if (control.validity[flag]) {\n return (\n control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ??\n control.getAttribute(MESSAGE_ATTR_GENERIC) ??\n control.validationMessage\n );\n }\n }\n // customError (our `disallow` rule, or a consumer's setCustomValidity): for our\n // rule the message was already resolved with per-constraint > generic > default\n // precedence when set, and a consumer error carries its own text — so return the\n // live validationMessage as-is, falling back to the generic override then \"\".\n return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || \"\";\n }\n\n /**\n * Applies (or clears) a declarative custom constraint via `setCustomValidity`,\n * for controls that opt in with `data-stimeo--form-field-disallow`. The supported\n * rule is `\"whitespace\"` — a value that is non-empty but blank\n * after trimming (which slips past `required` / `minlength`); its message follows\n * the per-constraint (`value-missing`) → generic → default chain.\n *\n * Don't-clobber-authored-state: an unknown/absent rule is never touched, and a\n * custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),\n * so a consumer's own `setCustomValidity` on the same control survives.\n */\n #syncCustomValidity(control: ValidatableControl): void {\n const violates =\n control.getAttribute(DISALLOW_ATTR) === \"whitespace\" &&\n control.value.length > 0 &&\n control.value.trim() === \"\";\n if (violates) {\n control.setCustomValidity(\n control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ??\n control.getAttribute(MESSAGE_ATTR_GENERIC) ??\n DISALLOW_WHITESPACE_DEFAULT,\n );\n this.#ownedCustomError.add(control);\n } else if (this.#ownedCustomError.has(control)) {\n // Only clear the custom error we previously set; leave a consumer's intact.\n this.#ownedCustomError.delete(control);\n control.setCustomValidity(\"\");\n }\n }\n\n /**\n * A grouping key that collects controls belonging to the same field: the owning\n * `stimeo--form-field` when present, else a radio group's shared `name`, else\n * the control itself (always distinct).\n */\n #keyFor(control: ValidatableControl, field: FormFieldController | undefined): unknown {\n if (field) return field;\n if (control instanceof HTMLInputElement && control.type === \"radio\" && control.name) {\n return `radio:${control.name}`;\n }\n return control;\n }\n\n /**\n * Where focus should land for an invalid control. A visible control is focused\n * directly (status quo for native fields and radios). A validatable mirror\n * (the `hidden` attribute) cannot receive focus, so focus is delegated to the\n * visible widget: the owning field's `control` target when it is itself\n * focusable, else its first focusable descendant (e.g. a roving-tabindex\n * member). Resolved structurally — never by probing `focus()` — so behavior\n * is deterministic and CSS-independent.\n */\n #focusTargetFor(control: ValidatableControl): HTMLElement | null {\n if (!control.hidden) return control;\n const field = this.#fieldFor(control);\n if (!field?.hasControlTarget) return null;\n const root = field.controlTarget;\n if (root.matches(FOCUSABLE)) return root;\n return root.querySelector<HTMLElement>(FOCUSABLE);\n }\n\n /** The `stimeo--form-field` outlet whose element contains `control`, if any. */\n #fieldFor(control: ValidatableControl): FormFieldController | undefined {\n const elements = this.stimeoFormFieldOutletElements;\n for (let index = 0; index < elements.length; index++) {\n if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];\n }\n return undefined;\n }\n\n /** This form's native controls that participate in constraint validation. */\n get #controls(): ValidatableControl[] {\n const controls: ValidatableControl[] = [];\n for (const element of Array.from(this.element.elements)) {\n if (this.#isValidatable(element)) controls.push(element);\n }\n return controls;\n }\n\n /** Narrows an event target to a validatable control. */\n #controlFrom(target: EventTarget | null): ValidatableControl | null {\n return target instanceof Element && this.#isValidatable(target) ? target : null;\n }\n\n #isValidatable(element: Element): element is ValidatableControl {\n return (\n (element instanceof HTMLInputElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement) &&\n // `willValidate` already excludes disabled, read-only, hidden, and button\n // controls — the exact set barred from constraint validation.\n element.willValidate\n );\n }\n}\n"]}
|
|
@@ -10,11 +10,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
10
10
|
* data-action="mouseenter->stimeo--hover-card#open
|
|
11
11
|
* mouseleave->stimeo--hover-card#close
|
|
12
12
|
* focusin->stimeo--hover-card#open
|
|
13
|
-
* focusout->stimeo--hover-card#close
|
|
14
|
-
* keydown->stimeo--hover-card#onKeydown">@jane</a>
|
|
13
|
+
* focusout->stimeo--hover-card#close">@jane</a>
|
|
15
14
|
* <div id="hc" data-stimeo--hover-card-target="card"
|
|
16
15
|
* data-action="mouseenter->stimeo--hover-card#open
|
|
17
|
-
* mouseleave->stimeo--hover-card#close
|
|
16
|
+
* mouseleave->stimeo--hover-card#close
|
|
17
|
+
* focusin->stimeo--hover-card#open
|
|
18
|
+
* focusout->stimeo--hover-card#close" hidden>…</div>
|
|
18
19
|
* </span>
|
|
19
20
|
*
|
|
20
21
|
* There is no dedicated APG pattern; this follows the **Disclosure** convention
|
|
@@ -32,10 +33,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
32
33
|
* - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by
|
|
33
34
|
* `openDelay`/`closeDelay` to prevent accidental flicker.
|
|
34
35
|
* - **Hoverable bridge**: binding open/close on the card cancels a pending close
|
|
35
|
-
* when the pointer crosses into it
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* when the pointer crosses into it. Matching focus actions on the card cancel
|
|
37
|
+
* the trigger's pending close while focus is inside, then schedule close once
|
|
38
|
+
* focus leaves the whole controller.
|
|
39
|
+
* - **Dismissible**: while open, the card joins the shared {@link EscapeLayer}
|
|
40
|
+
* stack, so `Escape` closes it regardless of where focus sits (card, trigger,
|
|
41
|
+
* or elsewhere). The resolver ignores an Escape already consumed by an inner
|
|
42
|
+
* handler and lets the most recently shown layer own the press, so one
|
|
43
|
+
* keypress closes exactly one layer (the shared layered-Escape contract).
|
|
39
44
|
* - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a
|
|
40
45
|
* `data-state` (`open`/`closed`). Focus is never stolen on open.
|
|
41
46
|
* - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked
|
|
@@ -60,7 +65,7 @@ declare class HoverCardController extends Controller<HTMLElement> {
|
|
|
60
65
|
default: boolean;
|
|
61
66
|
};
|
|
62
67
|
};
|
|
63
|
-
static actions: readonly ["close", "
|
|
68
|
+
static actions: readonly ["close", "open"];
|
|
64
69
|
readonly triggerTarget: HTMLElement;
|
|
65
70
|
readonly cardTarget: HTMLElement;
|
|
66
71
|
readonly hasTriggerTarget: boolean;
|
|
@@ -68,9 +73,9 @@ declare class HoverCardController extends Controller<HTMLElement> {
|
|
|
68
73
|
readonly openDelayValue: number;
|
|
69
74
|
readonly closeDelayValue: number;
|
|
70
75
|
readonly closeOnScrollValue: boolean;
|
|
71
|
-
/** Starts closed. */
|
|
76
|
+
/** Starts closed and discards any stale pending state from a prior connection. */
|
|
72
77
|
connect(): void;
|
|
73
|
-
/** Clears timers
|
|
78
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
74
79
|
disconnect(): void;
|
|
75
80
|
/** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
|
|
76
81
|
open(): void;
|
|
@@ -81,8 +86,6 @@ declare class HoverCardController extends Controller<HTMLElement> {
|
|
|
81
86
|
* traversal that the pointer-only hoverable bridge cannot.
|
|
82
87
|
*/
|
|
83
88
|
close(): void;
|
|
84
|
-
/** Closes immediately on `Escape` while open (keyboard dismissal from the trigger). */
|
|
85
|
-
onKeydown(event: KeyboardEvent): void;
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export { HoverCardController };
|
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/hover_card_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
5
89
|
// src/utils/safe_timeout.ts
|
|
6
90
|
var TimerRegistry = class {
|
|
7
91
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -88,21 +172,26 @@ var HoverCardController = class extends Controller {
|
|
|
88
172
|
closeDelay: { type: Number, default: 200 },
|
|
89
173
|
closeOnScroll: { type: Boolean, default: false }
|
|
90
174
|
};
|
|
91
|
-
static actions = ["close", "
|
|
92
|
-
/** Pending open/close timers,
|
|
175
|
+
static actions = ["close", "open"];
|
|
176
|
+
/** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
|
|
93
177
|
#timers = new SafeTimeout();
|
|
178
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
179
|
+
#escapeLayer = new EscapeLayer();
|
|
94
180
|
#pendingOpen = null;
|
|
95
181
|
#pendingClose = null;
|
|
96
182
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
97
183
|
#stopScrollDismiss = null;
|
|
98
|
-
/** Starts closed. */
|
|
184
|
+
/** Starts closed and discards any stale pending state from a prior connection. */
|
|
99
185
|
connect() {
|
|
186
|
+
this.#cancelOpen();
|
|
187
|
+
this.#cancelClose();
|
|
100
188
|
this.#conceal();
|
|
101
189
|
}
|
|
102
|
-
/** Clears timers
|
|
190
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
103
191
|
disconnect() {
|
|
104
|
-
this.#
|
|
105
|
-
|
|
192
|
+
this.#cancelOpen();
|
|
193
|
+
this.#cancelClose();
|
|
194
|
+
this.#escapeLayer.deactivate();
|
|
106
195
|
this.#stopScrollDismiss?.();
|
|
107
196
|
this.#stopScrollDismiss = null;
|
|
108
197
|
}
|
|
@@ -134,32 +223,26 @@ var HoverCardController = class extends Controller {
|
|
|
134
223
|
this.#conceal();
|
|
135
224
|
}, this.closeDelayValue);
|
|
136
225
|
}
|
|
137
|
-
/**
|
|
138
|
-
onKeydown(event) {
|
|
139
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
140
|
-
event.preventDefault();
|
|
141
|
-
this.#dismiss();
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
|
|
226
|
+
/** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
|
|
145
227
|
#reveal() {
|
|
146
228
|
if (!this.hasCardTarget) return;
|
|
147
229
|
this.cardTarget.hidden = false;
|
|
148
230
|
this.cardTarget.setAttribute("data-state", "open");
|
|
149
231
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
150
|
-
|
|
232
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
151
233
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
152
234
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
153
235
|
}
|
|
154
236
|
}
|
|
155
|
-
/** Hides the card, reflects state, and
|
|
237
|
+
/** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
|
|
156
238
|
#conceal() {
|
|
157
|
-
|
|
239
|
+
this.#escapeLayer.deactivate();
|
|
158
240
|
this.#stopScrollDismiss?.();
|
|
159
241
|
this.#stopScrollDismiss = null;
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
242
|
+
if (this.hasCardTarget) {
|
|
243
|
+
this.cardTarget.hidden = true;
|
|
244
|
+
this.cardTarget.setAttribute("data-state", "closed");
|
|
245
|
+
}
|
|
163
246
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
164
247
|
}
|
|
165
248
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
@@ -168,13 +251,6 @@ var HoverCardController = class extends Controller {
|
|
|
168
251
|
this.#cancelClose();
|
|
169
252
|
this.#conceal();
|
|
170
253
|
}
|
|
171
|
-
/** Document-level `Escape` watcher (active only while open). */
|
|
172
|
-
#onDocumentKeydown = (event) => {
|
|
173
|
-
if (event.key === "Escape") {
|
|
174
|
-
event.preventDefault();
|
|
175
|
-
this.#dismiss();
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
254
|
/** Cancels any pending open timer. */
|
|
179
255
|
#cancelOpen() {
|
|
180
256
|
if (this.#pendingOpen !== null) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/hover_card_controller.ts"],"names":[],"mappings":"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","file":"hover_card_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * 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 { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **hover card** behavior.\n *\n * Markup contract (identifier: `stimeo--hover-card`):\n * <span data-controller=\"stimeo--hover-card\">\n * <a href=\"/users/jane\" data-stimeo--hover-card-target=\"trigger\"\n * aria-expanded=\"false\" aria-controls=\"hc\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\n * keydown->stimeo--hover-card#onKeydown\">@jane</a>\n * <div id=\"hc\" data-stimeo--hover-card-target=\"card\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\" hidden>…</div>\n * </span>\n *\n * There is no dedicated APG pattern; this follows the **Disclosure** convention\n * (`aria-expanded`) for a hover/focus-opened, non-modal popover that *may* hold\n * interactive content (unlike a tooltip). The card is **not** a `role=\"dialog\"`:\n * it is supplementary, so its content must also be reachable from the trigger\n * itself. For a short text hint use `stimeo--tooltip`; for a click-opened\n * action panel use `stimeo--popover`.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by\n * `openDelay`/`closeDelay` to prevent accidental flicker.\n * - **Hoverable bridge**: binding open/close on the card cancels a pending close\n * when the pointer crosses into it; the delayed close also re-checks focus, so\n * tabbing into a link inside the card keeps it open.\n * - **Dismissible**: while open, `Escape` is watched at the `document` level, so it\n * closes regardless of where focus sits (card, trigger, or elsewhere).\n * - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a\n * `data-state` (`open`/`closed`). Focus is never stolen on open.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the card — the Radix / floating-ui\n * convention. Covers keyboard/programmatic scroll and scrollbar-drag, which the\n * pointer-leave close cannot. Off by default.\n */\nexport class HoverCardController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"card\"];\n static override values = {\n openDelay: { type: Number, default: 300 },\n closeDelay: { type: Number, default: 200 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"onKeydown\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly cardTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasCardTarget: boolean;\n declare readonly openDelayValue: number;\n declare readonly closeDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending open/close timers, torn down together on disconnect. */\n readonly #timers = new SafeTimeout();\n #pendingOpen: number | null = null;\n #pendingClose: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts closed. */\n override connect(): void {\n this.#conceal();\n }\n\n /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#timers.clearAll();\n document.removeEventListener(\"keydown\", this.#onDocumentKeydown);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */\n open(): void {\n this.#cancelClose();\n if (this.#isOpen || this.#pendingOpen !== null) return;\n if (this.openDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingOpen = this.#timers.set(() => {\n this.#pendingOpen = null;\n this.#reveal();\n }, this.openDelayValue);\n }\n\n /**\n * Schedules the card to close after `closeDelay`. Cancels a pending open. The\n * delayed callback re-checks whether focus has landed inside the controller\n * (e.g. a link in the card) and, if so, aborts the close — covering keyboard\n * traversal that the pointer-only hoverable bridge cannot.\n */\n close(): void {\n this.#cancelOpen();\n if (!this.#isOpen || this.#pendingClose !== null) return;\n this.#pendingClose = this.#timers.set(() => {\n this.#pendingClose = null;\n if (this.element.contains(document.activeElement)) return;\n this.#conceal();\n }, this.closeDelayValue);\n }\n\n /** Closes immediately on `Escape` while open (keyboard dismissal from the trigger). */\n onKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\" && this.#isOpen) {\n event.preventDefault();\n this.#dismiss();\n }\n }\n\n /** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */\n #reveal(): void {\n if (!this.hasCardTarget) return;\n this.cardTarget.hidden = false;\n this.cardTarget.setAttribute(\"data-state\", \"open\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n document.addEventListener(\"keydown\", this.#onDocumentKeydown);\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());\n }\n }\n\n /** Hides the card, reflects state, and stops watching for `Escape`/scroll. */\n #conceal(): void {\n // Release listeners first, unconditionally: if the card target was removed\n // from the DOM while open, an early return would leak the document keydown\n // and scroll-dismiss listeners.\n document.removeEventListener(\"keydown\", this.#onDocumentKeydown);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (!this.hasCardTarget) return;\n this.cardTarget.hidden = true;\n this.cardTarget.setAttribute(\"data-state\", \"closed\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape path). */\n #dismiss(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Document-level `Escape` watcher (active only while open). */\n readonly #onDocumentKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n this.#dismiss();\n }\n };\n\n /** Cancels any pending open timer. */\n #cancelOpen(): void {\n if (this.#pendingOpen !== null) {\n this.#timers.clear(this.#pendingOpen);\n this.#pendingOpen = null;\n }\n }\n\n /** Cancels any pending close timer. */\n #cancelClose(): void {\n if (this.#pendingClose !== null) {\n this.#timers.clear(this.#pendingClose);\n this.#pendingClose = null;\n }\n }\n\n /** Whether the card is currently visible. */\n get #isOpen(): boolean {\n return this.hasCardTarget && !this.cardTarget.hidden;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/hover_card_controller.ts"],"names":[],"mappings":"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","file":"hover_card_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 * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * 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 { EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **hover card** behavior.\n *\n * Markup contract (identifier: `stimeo--hover-card`):\n * <span data-controller=\"stimeo--hover-card\">\n * <a href=\"/users/jane\" data-stimeo--hover-card-target=\"trigger\"\n * aria-expanded=\"false\" aria-controls=\"hc\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\">@jane</a>\n * <div id=\"hc\" data-stimeo--hover-card-target=\"card\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\" hidden>…</div>\n * </span>\n *\n * There is no dedicated APG pattern; this follows the **Disclosure** convention\n * (`aria-expanded`) for a hover/focus-opened, non-modal popover that *may* hold\n * interactive content (unlike a tooltip). The card is **not** a `role=\"dialog\"`:\n * it is supplementary, so its content must also be reachable from the trigger\n * itself. For a short text hint use `stimeo--tooltip`; for a click-opened\n * action panel use `stimeo--popover`.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by\n * `openDelay`/`closeDelay` to prevent accidental flicker.\n * - **Hoverable bridge**: binding open/close on the card cancels a pending close\n * when the pointer crosses into it. Matching focus actions on the card cancel\n * the trigger's pending close while focus is inside, then schedule close once\n * focus leaves the whole controller.\n * - **Dismissible**: while open, the card joins the shared {@link EscapeLayer}\n * stack, so `Escape` closes it regardless of where focus sits (card, trigger,\n * or elsewhere). The resolver ignores an Escape already consumed by an inner\n * handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer (the shared layered-Escape contract).\n * - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a\n * `data-state` (`open`/`closed`). Focus is never stolen on open.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the card — the Radix / floating-ui\n * convention. Covers keyboard/programmatic scroll and scrollbar-drag, which the\n * pointer-leave close cannot. Off by default.\n */\nexport class HoverCardController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"card\"];\n static override values = {\n openDelay: { type: Number, default: 300 },\n closeDelay: { type: Number, default: 200 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly cardTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasCardTarget: boolean;\n declare readonly openDelayValue: number;\n declare readonly closeDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n #pendingOpen: number | null = null;\n #pendingClose: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts closed and discards any stale pending state from a prior connection. */\n override connect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */\n open(): void {\n this.#cancelClose();\n if (this.#isOpen || this.#pendingOpen !== null) return;\n if (this.openDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingOpen = this.#timers.set(() => {\n this.#pendingOpen = null;\n this.#reveal();\n }, this.openDelayValue);\n }\n\n /**\n * Schedules the card to close after `closeDelay`. Cancels a pending open. The\n * delayed callback re-checks whether focus has landed inside the controller\n * (e.g. a link in the card) and, if so, aborts the close — covering keyboard\n * traversal that the pointer-only hoverable bridge cannot.\n */\n close(): void {\n this.#cancelOpen();\n if (!this.#isOpen || this.#pendingClose !== null) return;\n this.#pendingClose = this.#timers.set(() => {\n this.#pendingClose = null;\n if (this.element.contains(document.activeElement)) return;\n this.#conceal();\n }, this.closeDelayValue);\n }\n\n /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */\n #reveal(): void {\n if (!this.hasCardTarget) return;\n this.cardTarget.hidden = false;\n this.cardTarget.setAttribute(\"data-state\", \"open\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n // No claims predicate: hover-revealed content is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while open.\n this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());\n }\n }\n\n /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */\n #conceal(): void {\n // Release the layer and observers first, unconditionally: if the card\n // target was removed from the DOM while open, an early return would leak\n // the stack entry and the scroll-dismiss listeners.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasCardTarget) {\n this.cardTarget.hidden = true;\n this.cardTarget.setAttribute(\"data-state\", \"closed\");\n }\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape path). */\n #dismiss(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Cancels any pending open timer. */\n #cancelOpen(): void {\n if (this.#pendingOpen !== null) {\n this.#timers.clear(this.#pendingOpen);\n this.#pendingOpen = null;\n }\n }\n\n /** Cancels any pending close timer. */\n #cancelClose(): void {\n if (this.#pendingClose !== null) {\n this.#timers.clear(this.#pendingClose);\n this.#pendingClose = null;\n }\n }\n\n /** Whether the card is currently visible. */\n get #isOpen(): boolean {\n return this.hasCardTarget && !this.cardTarget.hidden;\n }\n}\n"]}
|