stimeo-ui 0.14.0 → 0.16.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 +212 -0
- package/README.md +120 -0
- package/dist/cable/index.js +123 -29
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +30 -3
- package/dist/controllers/accordion_controller.js +98 -9
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +8 -7
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +3 -2
- package/dist/controllers/announcer_controller.js +96 -62
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.js +83 -7
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +1 -1
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +10 -7
- package/dist/controllers/breadcrumb_controller.js +38 -11
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +4 -4
- package/dist/controllers/bulk_select_controller.js +7 -6
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +101 -23
- package/dist/controllers/calendar_controller.js +340 -123
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +67 -22
- package/dist/controllers/carousel_controller.js +263 -38
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +1 -1
- package/dist/controllers/character_counter_controller.js +40 -2
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.js +81 -12
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +28 -2
- package/dist/controllers/clipboard_controller.js +63 -5
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +25 -1
- package/dist/controllers/collapsible_controller.js +99 -14
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +29 -8
- package/dist/controllers/color_picker_controller.js +80 -34
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +10 -1
- package/dist/controllers/combobox_controller.js +106 -16
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +3 -3
- package/dist/controllers/command_palette_controller.js +35 -3
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.js +85 -17
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +3 -0
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +6 -0
- package/dist/controllers/context_menu_controller.js +32 -12
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +30 -1
- package/dist/controllers/countdown_controller.js +129 -26
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +77 -16
- package/dist/controllers/currency_input_controller.js +221 -67
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +63 -18
- package/dist/controllers/data_grid_controller.js +195 -29
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +41 -6
- package/dist/controllers/date_range_picker_controller.js +151 -30
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +10 -3
- package/dist/controllers/dialog_controller.js +35 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.js +22 -4
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.d.ts +2 -2
- package/dist/controllers/dirty_form_controller.js +46 -13
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js +1 -0
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.d.ts +18 -10
- package/dist/controllers/drawer_controller.js +54 -19
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -3
- package/dist/controllers/dropdown_controller.js +36 -9
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +34 -0
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.js +144 -51
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +10 -4
- package/dist/controllers/filter_controller.js +20 -6
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +26 -6
- package/dist/controllers/flash_controller.js +432 -71
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +1 -0
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +7 -5
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +19 -13
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.js +45 -8
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +82 -25
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +10 -2
- package/dist/controllers/hover_card_controller.js +40 -14
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +16 -3
- package/dist/controllers/idle_controller.js +90 -5
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +5 -2
- package/dist/controllers/input_mask_controller.js +65 -9
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +3 -0
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +11 -2
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +50 -8
- package/dist/controllers/listbox_controller.js +203 -45
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +10 -5
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +3 -3
- package/dist/controllers/masonry_controller.js +31 -15
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +9 -3
- package/dist/controllers/menu_controller.js +45 -16
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +11 -0
- package/dist/controllers/menubar_controller.js +58 -24
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.js +9 -5
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +18 -3
- package/dist/controllers/multi_select_controller.js +278 -104
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +11 -0
- package/dist/controllers/navigation_menu_controller.js +48 -15
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.js +37 -8
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.js +9 -1
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +36 -8
- package/dist/controllers/number_input_controller.js +124 -21
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.js +42 -5
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +22 -7
- package/dist/controllers/otp_controller.js +198 -55
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +8 -12
- package/dist/controllers/overflow_indicator_controller.js +115 -21
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +26 -6
- package/dist/controllers/overflow_menu_controller.js +141 -44
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +19 -11
- package/dist/controllers/pagination_controller.js +74 -28
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +15 -1
- package/dist/controllers/password_reveal_controller.js +59 -2
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/persist_controller.js +30 -8
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +131 -52
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +9 -3
- package/dist/controllers/popover_controller.js +45 -11
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +1 -1
- package/dist/controllers/portal_controller.js +6 -2
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.js +16 -1
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js +8 -4
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +6 -4
- package/dist/controllers/radio_group_controller.js +42 -17
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +49 -1
- package/dist/controllers/range_slider_controller.js +88 -42
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +14 -3
- package/dist/controllers/rating_controller.js +39 -15
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +24 -2
- package/dist/controllers/read_more_controller.js +100 -7
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js +65 -19
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.js +10 -5
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +16 -2
- package/dist/controllers/resizable_controller.js +82 -22
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +75 -27
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +37 -16
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.js +49 -30
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +3 -2
- package/dist/controllers/scrollspy_controller.js +71 -26
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +41 -14
- package/dist/controllers/separator_controller.js +66 -37
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +20 -3
- package/dist/controllers/sidebar_controller.js +77 -18
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.js +6 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +45 -7
- package/dist/controllers/slider_controller.js +82 -47
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.js +60 -26
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.js +17 -2
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +10 -2
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +19 -17
- package/dist/controllers/step_indicator_controller.js +18 -17
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +34 -9
- package/dist/controllers/stepper_controller.js +101 -19
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +19 -0
- package/dist/controllers/stick_to_bottom_controller.js +104 -8
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +3 -2
- package/dist/controllers/submit_once_controller.js +45 -9
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +29 -7
- package/dist/controllers/switch_controller.js +101 -10
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +12 -0
- package/dist/controllers/tabs_controller.js +21 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +15 -3
- package/dist/controllers/tags_input_controller.js +209 -59
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.js +29 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +20 -3
- package/dist/controllers/theme_controller.js +64 -14
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +23 -8
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +55 -15
- package/dist/controllers/toast_controller.js +451 -105
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +49 -7
- package/dist/controllers/toggle_group_controller.js +159 -23
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +32 -0
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +8 -0
- package/dist/controllers/tooltip_controller.js +39 -13
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.js +4 -0
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +39 -8
- package/dist/controllers/tree_view_controller.js +169 -16
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +5002 -1911
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +72 -6
- package/dist/inspector/cli.js +262 -51
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +309 -51
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +40 -40
- package/dist/inspector/manifest.json +529 -61
- package/dist/positioning/index.js +2 -0
- package/dist/positioning/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/detach_gate.ts","../../src/controllers/optimistic_controller.ts"],"names":[],"mappings":"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","file":"optimistic_controller.js","sourcesContent":["/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { DetachGate } from \"../utils/detach_gate\";\n\n/** Records that this controller wrote `hidden`, and the authored value to put back. */\nconst HIDDEN_MARKER = \"data-optimistic-toggled\";\n\n/** The same record for `aria-busy` on the controller element. */\nconst BUSY_MARKER = \"data-optimistic-busy\";\n\n/** Marker value standing for \"the attribute was not there\". */\nconst AUTHORED_ABSENT = \"absent\";\n\n/** Marker prefix carrying the authored attribute value that follows it. */\nconst AUTHORED_VALUE = \"value:\";\n\n/**\n * Stands in for the submission a synthetic lifecycle event does not name. Turbo\n * puts a `formSubmission` on both events, so this only ever pairs hand-dispatched\n * events with each other — one shared instance, so a start and its end match.\n */\nconst SYNTHETIC_SUBMISSION = {};\n\n/** The submission a Turbo lifecycle event belongs to. */\nfunction submissionOf(event: Event): unknown {\n const detail = (event as CustomEvent<{ formSubmission?: unknown }>).detail;\n return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;\n}\n\n/**\n * Headless **optimistic UI** for Turbo form submissions — the one server-bound\n * behavior that needs no Action Cable: it wraps a Turbo form\n * and applies a *declared* optimistic state the moment the submission starts,\n * keeps it when the server confirms, and **rolls it back** when the submission\n * fails. The pattern: flip the like button instantly; the success response (a\n * Turbo Stream) replaces the fragment with the server truth anyway, so the\n * client only needs the instant flip and the failure rollback. Core (zero\n * dependencies — it rides Turbo's own `turbo:submit-*` events).\n *\n * Markup contract (identifier: `stimeo--optimistic`):\n * <form data-controller=\"stimeo--optimistic\" method=\"post\" action=\"/likes\">\n * <button type=\"submit\" aria-label=\"Like\">\n * <span data-stimeo--optimistic-target=\"hide\">♡</span>\n * <span hidden data-stimeo--optimistic-target=\"show\">♥</span>\n * </button>\n * </form>\n *\n * On `turbo:submit-start`, every `show` target is unhidden and every `hide`\n * target hidden, the element gains `data-optimistic=\"true\"` + `aria-busy=\"true\"`,\n * and the submission named by the event takes ownership of that state. On\n * `turbo:submit-end`: the terminal of any *other* submission is ignored; the\n * owner's terminal clears the pending hook and, on success, dispatches `commit`\n * (the toggled state stays — the server response owns the final DOM), or on\n * failure restores the authored markup and dispatches `rollback`.\n *\n * **Every write records what it displaced.** A target this controller hides or\n * reveals carries the authored `hidden` value in `data-optimistic-toggled`, and\n * the element carries the authored `aria-busy` in `data-optimistic-busy`, so a\n * rollback puts back exactly what the author wrote — not the inverse of whatever\n * the attribute says at that moment. The record lives in the DOM rather than in\n * memory because a Turbo cache snapshot has to carry it: `connect()` is what\n * rewinds a restored page.\n *\n * @remarks\n * Behavior only — what \"optimistic\" looks like is the author's markup (the\n * show/hide pair) and CSS (`[data-optimistic=\"true\"]`). Pairs with\n * `stimeo--submit-once` (double-submit guard) and `stimeo--live-counter`\n * (optimistic numbers). The listeners are delegated on the element, so a form\n * nested under it (or swapped for a new one) keeps working, and the submission\n * ownership is what keeps a sibling form's terminal from resolving this state.\n * With several submissions in flight the newest start owns the optimistic state,\n * so a submission whose terminal never arrives — its form replaced mid-flight —\n * cannot strand the element. `connect()` is idempotent: a Turbo cache snapshot\n * taken mid-submit is rewound, since no submission can be in flight across a\n * restore, while an in-page move (where the submission *does* survive) keeps it.\n * On a real detach the listeners go with the element.\n */\nexport class OptimisticController extends Controller<HTMLElement> {\n static override targets = [\"show\", \"hide\"];\n static events = [\"commit\", \"rollback\"] as const;\n\n declare readonly showTargets: HTMLElement[];\n declare readonly hideTargets: HTMLElement[];\n\n /** The submission that owns the applied optimistic state, if any. */\n #pending: unknown = null;\n\n readonly #gate = new DetachGate();\n\n /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */\n #connected = false;\n\n override connect(): void {\n // Only a page this instance did not apply the state to can be holding a stale\n // one: a restored snapshot. An in-page move and a repeat connect both leave a\n // live submission behind, so neither may rewind — the gate names the first and\n // the connected flag the second.\n const restored = !this.#connected && !this.#gate.pending;\n this.#gate.cancel();\n this.#connected = true;\n if (restored && this.element.hasAttribute(\"data-optimistic\")) this.#revert();\n this.element.addEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.element.removeEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n // Ownership outlives an in-page move: only a real detach forgets it.\n this.#gate.disconnected(this, () => {\n this.#pending = null;\n });\n }\n\n readonly #onSubmitStart = (event: Event): void => {\n this.#pending = submissionOf(event);\n // Turbo owns `aria-busy` on a form and marks it before this event, so what is\n // there now is Turbo's, not the author's. Recording it would put Turbo's value\n // back on a page restored mid-submit, where no terminal ever arrives to clear it.\n if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {\n this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);\n }\n this.#write(this.element, \"aria-busy\", \"true\", BUSY_MARKER);\n this.element.setAttribute(\"data-optimistic\", \"true\");\n for (const target of this.showTargets) this.#setHidden(target, false);\n for (const target of this.hideTargets) this.#setHidden(target, true);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n // A terminal always names a submission, so an element holding none (nothing\n // started, or this one already resolved) falls out here too.\n if (submissionOf(event) !== this.#pending) return;\n this.#pending = null;\n\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success === true;\n if (success) {\n // Keep the toggled faces (the server response owns the final DOM) but drop\n // their records, so a later failure cannot revert a confirmed state.\n this.element.removeAttribute(\"data-optimistic\");\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n for (const target of [...this.showTargets, ...this.hideTargets]) {\n target.removeAttribute(HIDDEN_MARKER);\n }\n this.dispatch(\"commit\");\n } else {\n this.#revert();\n this.dispatch(\"rollback\");\n }\n };\n\n /** Hides or reveals a target, recording the authored `hidden` on first write. */\n #setHidden(target: HTMLElement, hidden: boolean): void {\n if (target.hasAttribute(\"hidden\") === hidden) return;\n this.#write(target, \"hidden\", hidden ? \"\" : null, HIDDEN_MARKER);\n }\n\n /** Writes an attribute, recording what it displaced under `marker` once. */\n #write(element: Element, attribute: string, value: string | null, marker: string): void {\n if (!element.hasAttribute(marker)) {\n const authored = element.getAttribute(attribute);\n element.setAttribute(\n marker,\n authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`,\n );\n }\n if (value === null) element.removeAttribute(attribute);\n else element.setAttribute(attribute, value);\n }\n\n /**\n * Puts back the value `marker` recorded and drops the record.\n *\n * `written` is what this controller last put in the attribute. Anything else\n * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and\n * clears it before the terminal arrives, and a consumer may have moved a face\n * mid-flight — so the record is dropped without touching the attribute. A\n * marker this build did not write names no authored value, so the attribute\n * goes back to being absent.\n */\n #restore(element: Element, attribute: string, marker: string, written: string | null): void {\n const recorded = element.getAttribute(marker);\n if (recorded === null) return;\n element.removeAttribute(marker);\n if (element.getAttribute(attribute) !== written) return;\n if (recorded.startsWith(AUTHORED_VALUE)) {\n element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));\n return;\n }\n element.removeAttribute(attribute);\n }\n\n /**\n * Puts back exactly the markup this controller displaced (record-owned only).\n *\n * A face can be registered on both sides, and the hide pass runs last, so what\n * this controller wrote there is `hidden` — matching it against the reveal's\n * absent value instead would read as someone else's write and leave the face\n * hidden. Each target is therefore restored once, against the write it last\n * received.\n */\n #revert(): void {\n this.element.removeAttribute(\"data-optimistic\");\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n const hidden = new Set(this.hideTargets);\n for (const target of new Set([...this.showTargets, ...this.hideTargets])) {\n this.#restore(target, \"hidden\", HIDDEN_MARKER, hidden.has(target) ? \"\" : null);\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/detach_gate.ts","../../src/utils/listener_set.ts","../../src/utils/transient_hooks.ts","../../src/controllers/optimistic_controller.ts"],"names":[],"mappings":"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","file":"optimistic_controller.js","sourcesContent":["/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","/**\n * Owns every listener a controller attaches for one connected lifetime and\n * releases them together, so an event name is spelled once and the release\n * cannot drift from the registration.\n *\n * Detaching by hand repeats three things at the release site — the type, the\n * handler reference and the capture flag — and any one of them drifting leaves\n * a listener alive on an element the controller no longer drives. Aborting the\n * signal a registration was created with releases it whatever it was made of,\n * so the release site names nothing.\n *\n * One set per lifetime, held as a field and never rebuilt. {@link\n * ListenerSet.dispose} is the only thing that closes a generation, and it opens\n * the next one in the same step, so the lifecycle hooks can call `add` and\n * `dispose` in any order any number of times, and no `add` ever meets an\n * already aborted signal — a DOM that registers a listener on such a signal\n * would keep it forever. Rebuilding the set instead would leak:\n * `addEventListener` matches an existing registration on target, type, callback\n * and capture alone, so re-adding the same handler while the first registration\n * is live is discarded, and that live registration keeps the signal it was\n * attached with — a set built by a second `connect()` can never release what it\n * appears to own.\n *\n * Scope is the registration only. Timers, observers, leases and the order they\n * are unwound in stay with the controller, so `dispose()` belongs exactly where\n * the `removeEventListener` block stood. A receiver that changes at runtime (a\n * swapped target) is rebound by calling `dispose()` and then `add()` again from\n * the rebind, not by a second set.\n *\n * The set must own every registration of a tuple it holds: a listener the same\n * target already carries for the same type, callback and capture flag keeps the\n * right to remove it, and this set's `add` for that tuple is discarded. A\n * subscription shared between instances behind a participant count, or one that\n * lives for less than the connection, keeps its own pair of calls.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #listeners = new ListenerSet();\n *\n * connect(): void {\n * this.#listeners.add(this.element, \"submit\", this.#onNativeSubmit, { capture: true });\n * this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n * this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n * }\n *\n * disconnect(): void {\n * this.#listeners.dispose();\n * }\n * ```\n */\nexport class ListenerSet {\n /** The generation every `add` joins until the next `dispose()`. */\n #abort = new AbortController();\n\n /**\n * Attaches `handler` to the open generation, exactly as the caller spelled it.\n *\n * The set supplies the signal, so `options` carries everything else the DOM\n * accepts — `capture` included, which has to match at release time and no\n * longer has a second place to drift from.\n */\n add(\n target: EventTarget,\n type: string,\n handler: EventListener,\n options?: Omit<AddEventListenerOptions, \"signal\">,\n ): void {\n target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });\n }\n\n /**\n * Releases every listener of the open generation, synchronously, and opens the\n * next one. Idempotent, and safe before anything has been added.\n */\n dispose(): void {\n this.#abort.abort();\n this.#abort = new AbortController();\n }\n}\n","/**\n * Drops the transient state hooks a connection inherited, so the cycle it opens\n * starts from a DOM that claims nothing.\n *\n * A transient hook is an attribute a controller writes to say \"this is happening\n * right now\" — a drag in progress, a form with unsaved edits, a refused\n * subscription. Its lifetime belongs to the interaction, not to the element, and\n * the two come apart whenever the element outlives the instance that wrote it: a\n * Turbo cache snapshot taken mid-interaction restores the hook with the markup,\n * and an element re-inserted elsewhere in the page carries it along. Nothing\n * takes such a hook off, because the instance that owned it is gone; consumer\n * CSS then keeps claiming a state no one is in.\n *\n * **`turbo:before-cache` cannot do this job**, which is why the pass belongs in\n * `connect()`: that event fires for a navigation only, so an in-page move — the\n * other half of the population — never reaches it, and a hook that arrived with\n * a moved element would survive. `connect()` covers both, because both end in a\n * connection.\n *\n * Scope is the **drop** only. What the fresh cycle should say instead is the\n * consumer's: a value re-read from the live DOM, a roster refilled by the\n * stream, a measurement retaken from the current scroll position, a slot emptied\n * of the last result. Those answers differ per consumer and none of them is a\n * hook removal, so the pass stops where every consumer agrees.\n *\n * A declaration names one group of hooks and carries no per-instance state, so\n * it belongs at module scope, shared by every instance the page connects. A\n * consumer writes as many as it has groups — hooks it drops under different\n * conditions cannot share one. The exception is a hook whose name is only known\n * at runtime, such as one built from the registered identifier: that\n * declaration is a field, because the name is.\n *\n * **A drop is not always right.** The population splits on one question: can the\n * state a hook describes outlive the instance that wrote it? Where it cannot — a\n * drag ends with the pointer, a subscription dies with its socket — dropping on\n * every connection is correct. Where it can — a submission in flight is not\n * interrupted by an in-page move — only a connection that follows a real restore\n * may drop, and the consumer answers \"did a session survive?\" with the signal it\n * already holds for its own teardown (a live-session field, a `DetachGate`\n * probe). The drop then sits behind that answer, as the second example shows.\n *\n * @example Unconditional — the state cannot outlive the instance.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-sortable-dragging\"] });\n *\n * connect(): void {\n * TRANSIENT.reset(this.element);\n * }\n * ```\n *\n * @example Gated — an in-page move must not drop a live state.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n *\n * connect(): void {\n * const restored = !this.#connected && !this.#gate.pending;\n * this.#gate.cancel();\n * this.#connected = true;\n * if (restored) TRANSIENT.reset(this.element);\n * }\n * ```\n */\n\n/** What a consumer declares once about the hooks its interaction owns. */\nexport interface TransientHooksOptions {\n /**\n * The attribute names a connection may find written by an earlier one. Hooks\n * a consumer drops under different conditions belong to different\n * declarations, so each one can be reset on its own.\n */\n readonly attributes: readonly string[];\n}\n\n/**\n * One consumer's declaration of the hooks its interaction owns, dropped from an\n * element by {@link TransientHooks.reset}.\n *\n * Every member comment inside this class body is inlined into each consumer\n * bundle (`tsup` builds with `splitting: false`), so rationale belongs in this\n * file's own block, which `dist` drops, and only the contract belongs inside.\n */\nexport class TransientHooks {\n readonly #attributes: readonly string[];\n\n constructor(options: TransientHooksOptions) {\n this.#attributes = options.attributes;\n }\n\n /** Drops the declared hooks from `element`; what it carries besides stays. */\n reset(element: Element): void {\n for (const attribute of this.#attributes) element.removeAttribute(attribute);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { DetachGate } from \"../utils/detach_gate\";\nimport { ListenerSet } from \"../utils/listener_set\";\nimport { TransientHooks } from \"../utils/transient_hooks\";\n\n/** The hook a connection may find written by an earlier, now-gone one. */\nconst TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n\n/** Records that this controller wrote `hidden`, and the authored value to put back. */\nconst HIDDEN_MARKER = \"data-optimistic-toggled\";\n\n/** The same record for `aria-busy` on the controller element. */\nconst BUSY_MARKER = \"data-optimistic-busy\";\n\n/** Marker value standing for \"the attribute was not there\". */\nconst AUTHORED_ABSENT = \"absent\";\n\n/** Marker prefix carrying the authored attribute value that follows it. */\nconst AUTHORED_VALUE = \"value:\";\n\n/**\n * Stands in for the submission a synthetic lifecycle event does not name. Turbo\n * puts a `formSubmission` on both events, so this only ever pairs hand-dispatched\n * events with each other — one shared instance, so a start and its end match.\n */\nconst SYNTHETIC_SUBMISSION = {};\n\n/** The submission a Turbo lifecycle event belongs to. */\nfunction submissionOf(event: Event): unknown {\n const detail = (event as CustomEvent<{ formSubmission?: unknown }>).detail;\n return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;\n}\n\n/**\n * Headless **optimistic UI** for Turbo form submissions — the one server-bound\n * behavior that needs no Action Cable: it wraps a Turbo form\n * and applies a *declared* optimistic state the moment the submission starts,\n * keeps it when the server confirms, and **rolls it back** when the submission\n * fails. The pattern: flip the like button instantly; the success response (a\n * Turbo Stream) replaces the fragment with the server truth anyway, so the\n * client only needs the instant flip and the failure rollback. Core (zero\n * dependencies — it rides Turbo's own `turbo:submit-*` events).\n *\n * Markup contract (identifier: `stimeo--optimistic`):\n * <form data-controller=\"stimeo--optimistic\" method=\"post\" action=\"/likes\">\n * <button type=\"submit\" aria-label=\"Like\">\n * <span data-stimeo--optimistic-target=\"hide\">♡</span>\n * <span hidden data-stimeo--optimistic-target=\"show\">♥</span>\n * </button>\n * </form>\n *\n * On `turbo:submit-start`, every `show` target is unhidden and every `hide`\n * target hidden, the element gains `data-optimistic=\"true\"` + `aria-busy=\"true\"`,\n * and the submission named by the event takes ownership of that state. On\n * `turbo:submit-end`: the terminal of any *other* submission is ignored; the\n * owner's terminal clears the pending hook and, on success, dispatches `commit`\n * (the toggled state stays — the server response owns the final DOM), or on\n * failure restores the authored markup and dispatches `rollback`.\n *\n * **Every write records what it displaced.** A target this controller hides or\n * reveals carries the authored `hidden` value in `data-optimistic-toggled`, and\n * the element carries the authored `aria-busy` in `data-optimistic-busy`, so a\n * rollback puts back exactly what the author wrote — not the inverse of whatever\n * the attribute says at that moment. The record lives in the DOM rather than in\n * memory because a Turbo cache snapshot has to carry it: `connect()` is what\n * rewinds a restored page.\n *\n * @remarks\n * Behavior only — what \"optimistic\" looks like is the author's markup (the\n * show/hide pair) and CSS (`[data-optimistic=\"true\"]`). Pairs with\n * `stimeo--submit-once` (double-submit guard) and `stimeo--live-counter`\n * (optimistic numbers). The listeners are delegated on the element, so a form\n * nested under it (or swapped for a new one) keeps working, and the submission\n * ownership is what keeps a sibling form's terminal from resolving this state.\n * With several submissions in flight the newest start owns the optimistic state,\n * so a submission whose terminal never arrives — its form replaced mid-flight —\n * cannot strand the element. `connect()` is idempotent: a Turbo cache snapshot\n * taken mid-submit is rewound, since no submission can be in flight across a\n * restore, while an in-page move (where the submission *does* survive) keeps it.\n * On a real detach the listeners go with the element.\n */\nexport class OptimisticController extends Controller<HTMLElement> {\n static override targets = [\"show\", \"hide\"];\n static events = [\"commit\", \"rollback\"] as const;\n\n declare readonly showTargets: HTMLElement[];\n declare readonly hideTargets: HTMLElement[];\n\n /** The submission that owns the applied optimistic state, if any. */\n #pending: unknown = null;\n\n readonly #gate = new DetachGate();\n readonly #listeners = new ListenerSet();\n\n /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */\n #connected = false;\n\n override connect(): void {\n // Only a page this instance did not apply the state to can be holding a stale\n // one: a restored snapshot. An in-page move and a repeat connect both leave a\n // live submission behind, so neither may rewind — the gate names the first and\n // the connected flag the second.\n const restored = !this.#connected && !this.#gate.pending;\n this.#gate.cancel();\n this.#connected = true;\n if (restored && this.element.hasAttribute(\"data-optimistic\")) this.#revert();\n this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#listeners.dispose();\n // Ownership outlives an in-page move: only a real detach forgets it.\n this.#gate.disconnected(this, () => {\n this.#pending = null;\n });\n }\n\n readonly #onSubmitStart = (event: Event): void => {\n this.#pending = submissionOf(event);\n // Turbo owns `aria-busy` on a form and marks it before this event, so what is\n // there now is Turbo's, not the author's. Recording it would put Turbo's value\n // back on a page restored mid-submit, where no terminal ever arrives to clear it.\n if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {\n this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);\n }\n this.#write(this.element, \"aria-busy\", \"true\", BUSY_MARKER);\n this.element.setAttribute(\"data-optimistic\", \"true\");\n for (const target of this.showTargets) this.#setHidden(target, false);\n for (const target of this.hideTargets) this.#setHidden(target, true);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n // A terminal always names a submission, so an element holding none (nothing\n // started, or this one already resolved) falls out here too.\n if (submissionOf(event) !== this.#pending) return;\n this.#pending = null;\n\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success === true;\n if (success) {\n // Keep the toggled faces (the server response owns the final DOM) but drop\n // their records, so a later failure cannot revert a confirmed state.\n this.element.removeAttribute(\"data-optimistic\");\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n for (const target of [...this.showTargets, ...this.hideTargets]) {\n target.removeAttribute(HIDDEN_MARKER);\n }\n this.dispatch(\"commit\");\n } else {\n this.#revert();\n this.dispatch(\"rollback\");\n }\n };\n\n /** Hides or reveals a target, recording the authored `hidden` on first write. */\n #setHidden(target: HTMLElement, hidden: boolean): void {\n if (target.hasAttribute(\"hidden\") === hidden) return;\n this.#write(target, \"hidden\", hidden ? \"\" : null, HIDDEN_MARKER);\n }\n\n /** Writes an attribute, recording what it displaced under `marker` once. */\n #write(element: Element, attribute: string, value: string | null, marker: string): void {\n if (!element.hasAttribute(marker)) {\n const authored = element.getAttribute(attribute);\n element.setAttribute(\n marker,\n authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`,\n );\n }\n if (value === null) element.removeAttribute(attribute);\n else element.setAttribute(attribute, value);\n }\n\n /**\n * Puts back the value `marker` recorded and drops the record.\n *\n * `written` is what this controller last put in the attribute. Anything else\n * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and\n * clears it before the terminal arrives, and a consumer may have moved a face\n * mid-flight — so the record is dropped without touching the attribute. A\n * marker this build did not write names no authored value, so the attribute\n * goes back to being absent.\n */\n #restore(element: Element, attribute: string, marker: string, written: string | null): void {\n const recorded = element.getAttribute(marker);\n if (recorded === null) return;\n element.removeAttribute(marker);\n if (element.getAttribute(attribute) !== written) return;\n if (recorded.startsWith(AUTHORED_VALUE)) {\n element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));\n return;\n }\n element.removeAttribute(attribute);\n }\n\n /**\n * Puts back exactly the markup this controller displaced (record-owned only).\n *\n * A face can be registered on both sides, and the hide pass runs last, so what\n * this controller wrote there is `hidden` — matching it against the reveal's\n * absent value instead would read as someone else's write and leave the face\n * hidden. Each target is therefore restored once, against the write it last\n * received.\n */\n #revert(): void {\n TRANSIENT.reset(this.element);\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n const hidden = new Set(this.hideTargets);\n for (const target of new Set([...this.showTargets, ...this.hideTargets])) {\n this.#restore(target, \"hidden\", HIDDEN_MARKER, hidden.has(target) ? \"\" : null);\n }\n }\n}\n"]}
|
|
@@ -39,8 +39,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
39
39
|
* `change` and `complete` dispatch `{ value: string }` and fire only when the
|
|
40
40
|
* combined value actually moves — one confirmed IME character emits one event,
|
|
41
41
|
* and a passcode re-completed with a different digit reports the new value.
|
|
42
|
-
* State moved by adding or removing fields
|
|
43
|
-
*
|
|
42
|
+
* State moved by adding or removing fields, by a `pattern` change dropping
|
|
43
|
+
* entered digits it no longer accepts, or by a native form reset restoring the
|
|
44
|
+
* fields is not an edit of the passcode, so it is reported as `reconcile` with
|
|
45
|
+
* the same `{ value: string }` — once per change, after it settles — and never
|
|
46
|
+
* as `change` or `complete`. While a field is composing, the IME's uncommitted
|
|
47
|
+
* text is neither read nor written: a change already in the DOM — fields added
|
|
48
|
+
* or removed, a reset — is reported at once, counting that field by the
|
|
49
|
+
* character it had committed, while a `pattern` change's drop waits for the
|
|
50
|
+
* composition to end and is then applied and reported before the commit is
|
|
51
|
+
* taken. A composition that ends on the text the field held when it began was
|
|
52
|
+
* cancelled: no edit, so it reports nothing and focus stays.
|
|
44
53
|
* Completeness belongs to that state: dropping a trailing empty field completes
|
|
45
54
|
* a passcode whose combined value never moved, and that transition is reported
|
|
46
55
|
* too, so a consumer reading `data-state` is never left behind a silent move.
|
|
@@ -49,15 +58,18 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
49
58
|
* Controller-owned output: `data-filled` on each entered field, `data-state`
|
|
50
59
|
* (`empty` / `partial` / `complete`) on the root, and — while input is being
|
|
51
60
|
* reported invalid — `aria-invalid`, `aria-errormessage`, `aria-describedby`,
|
|
52
|
-
* and the `error` target's `hidden`.
|
|
53
|
-
*
|
|
61
|
+
* and the `error` target's `hidden`. Authored values return on teardown and
|
|
62
|
+
* before the page is cached: the three ARIA attributes keep their lease on the
|
|
63
|
+
* field itself, so a connection that adopts a restored DOM can give them back
|
|
64
|
+
* too, and the `hidden` is leased.
|
|
54
65
|
*
|
|
55
66
|
* @remarks
|
|
56
67
|
* Behavior only. `connect()` reads the fields back as the source of truth, which
|
|
57
68
|
* is what restores consistency after a `type="password"` field returns from the
|
|
58
|
-
* Turbo cache emptied
|
|
59
|
-
*
|
|
60
|
-
* declaration rather than per
|
|
69
|
+
* Turbo cache emptied; a native form reset is read back the same way once the
|
|
70
|
+
* browser has restored the fields. A `pattern` that cannot compile falls back to
|
|
71
|
+
* `[0-9]`; the compiled matcher is built once per declaration rather than per
|
|
72
|
+
* keystroke.
|
|
61
73
|
*/
|
|
62
74
|
declare class OtpController extends Controller<HTMLElement> {
|
|
63
75
|
#private;
|
|
@@ -89,6 +101,9 @@ declare class OtpController extends Controller<HTMLElement> {
|
|
|
89
101
|
/**
|
|
90
102
|
* Re-validates a changed `pattern` declaration once and drops any entered digit
|
|
91
103
|
* the new pattern no longer accepts, so the combined value stays interpretable.
|
|
104
|
+
* The page changed the declaration, so the move joins the same reconciliation
|
|
105
|
+
* as fields added or removed in that mutation. While a field is composing, its
|
|
106
|
+
* text is the IME's, and the drop waits for the composition to end.
|
|
92
107
|
*/
|
|
93
108
|
patternValueChanged(): void;
|
|
94
109
|
/** Handles keystroke inputs, distributes autofilled text, and advances focus. */
|
|
@@ -106,11 +106,19 @@ var BeforeCacheReset = class _BeforeCacheReset {
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
// src/utils/composition_tracker.ts
|
|
109
|
+
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
110
|
+
"insertCompositionText",
|
|
111
|
+
"insertFromComposition",
|
|
112
|
+
"deleteCompositionText",
|
|
113
|
+
"deleteByComposition"
|
|
114
|
+
]);
|
|
109
115
|
var CompositionTracker = class {
|
|
110
116
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
111
117
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
112
118
|
#onStart;
|
|
113
119
|
#onEnd;
|
|
120
|
+
/** The field whose confirming `input` is still owed, while the window is open. */
|
|
121
|
+
#confirmedTarget = null;
|
|
114
122
|
constructor(options = {}) {
|
|
115
123
|
this.#onStart = options.onStart;
|
|
116
124
|
this.#onEnd = options.onEnd;
|
|
@@ -120,6 +128,7 @@ var CompositionTracker = class {
|
|
|
120
128
|
if (this.#observedTargets.has(target)) return;
|
|
121
129
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
122
130
|
target.addEventListener("compositionend", this.#handleEnd);
|
|
131
|
+
target.addEventListener("keydown", this.#handleKeydown);
|
|
123
132
|
this.#observedTargets.add(target);
|
|
124
133
|
}
|
|
125
134
|
/** Stops tracking one target and clears any active composition it owned. */
|
|
@@ -127,29 +136,52 @@ var CompositionTracker = class {
|
|
|
127
136
|
if (!this.#observedTargets.delete(target)) return;
|
|
128
137
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
129
138
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
139
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
130
140
|
this.#activeTargets.delete(target);
|
|
141
|
+
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
131
142
|
}
|
|
132
143
|
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
133
144
|
disconnect() {
|
|
134
145
|
for (const target of this.#observedTargets) {
|
|
135
146
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
136
147
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
148
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
137
149
|
}
|
|
138
150
|
this.#observedTargets.clear();
|
|
139
151
|
this.#activeTargets.clear();
|
|
152
|
+
this.#confirmedTarget = null;
|
|
140
153
|
}
|
|
141
154
|
/** True when lifecycle tracking or the current event reports composition. */
|
|
142
155
|
isComposing(event) {
|
|
143
156
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
144
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Whether `event` is the `input` echoing the composition just confirmed.
|
|
160
|
+
*
|
|
161
|
+
* Asking closes the window either way, so one confirmation is folded at most
|
|
162
|
+
* once and a consumer asks once per `input`.
|
|
163
|
+
*/
|
|
164
|
+
consumesConfirmedInput(event) {
|
|
165
|
+
const confirmed = this.#confirmedTarget;
|
|
166
|
+
this.#confirmedTarget = null;
|
|
167
|
+
if (confirmed === null || confirmed !== event.target) return false;
|
|
168
|
+
const inputType = event.inputType;
|
|
169
|
+
return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
|
|
170
|
+
}
|
|
145
171
|
#handleStart = (event) => {
|
|
172
|
+
this.#confirmedTarget = null;
|
|
146
173
|
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
147
174
|
this.#onStart?.(event);
|
|
148
175
|
};
|
|
149
176
|
#handleEnd = (event) => {
|
|
150
177
|
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
178
|
+
this.#confirmedTarget = event.target;
|
|
151
179
|
this.#onEnd?.(event);
|
|
152
180
|
};
|
|
181
|
+
/** A key on an observed field opens an edit of its own, so no echo is owed. */
|
|
182
|
+
#handleKeydown = () => {
|
|
183
|
+
this.#confirmedTarget = null;
|
|
184
|
+
};
|
|
153
185
|
};
|
|
154
186
|
|
|
155
187
|
// src/utils/declared_value.ts
|
|
@@ -171,6 +203,54 @@ function compileRegExp(source, anchor = "none") {
|
|
|
171
203
|
);
|
|
172
204
|
}
|
|
173
205
|
|
|
206
|
+
// src/utils/form_reset_watcher.ts
|
|
207
|
+
var FormResetWatcher = class {
|
|
208
|
+
#owns;
|
|
209
|
+
#onReset;
|
|
210
|
+
#frames = /* @__PURE__ */ new Set();
|
|
211
|
+
#generation = 0;
|
|
212
|
+
/**
|
|
213
|
+
* @param owns - whether `form` owns a control this controller derives from.
|
|
214
|
+
* @param onReset - the reconciliation pass, run once per honoured reset.
|
|
215
|
+
*/
|
|
216
|
+
constructor(owns, onReset) {
|
|
217
|
+
this.#owns = owns;
|
|
218
|
+
this.#onReset = onReset;
|
|
219
|
+
}
|
|
220
|
+
/** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
|
|
221
|
+
observe() {
|
|
222
|
+
document.addEventListener("reset", this.#handleReset, true);
|
|
223
|
+
}
|
|
224
|
+
/** Unsubscribes and drops every pending pass; call from `disconnect()`. */
|
|
225
|
+
disconnect() {
|
|
226
|
+
document.removeEventListener("reset", this.#handleReset, true);
|
|
227
|
+
this.#generation += 1;
|
|
228
|
+
for (const frame of this.#frames) cancelAnimationFrame(frame);
|
|
229
|
+
this.#frames.clear();
|
|
230
|
+
}
|
|
231
|
+
#handleReset = (event) => {
|
|
232
|
+
const form = event.target;
|
|
233
|
+
if (!(form instanceof HTMLFormElement) || !this.#owns(form)) return;
|
|
234
|
+
const generation = this.#generation;
|
|
235
|
+
queueMicrotask(() => {
|
|
236
|
+
if (generation !== this.#generation) return;
|
|
237
|
+
if (event.eventPhase === Event.NONE) {
|
|
238
|
+
this.#settle(event);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const frame = requestAnimationFrame(() => {
|
|
242
|
+
this.#frames.delete(frame);
|
|
243
|
+
this.#settle(event);
|
|
244
|
+
});
|
|
245
|
+
this.#frames.add(frame);
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
/** Runs the pass unless a listener cancelled the reset before it landed. */
|
|
249
|
+
#settle(event) {
|
|
250
|
+
if (!event.defaultPrevented) this.#onReset();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
174
254
|
// src/utils/half_width.ts
|
|
175
255
|
var FULL_WIDTH_SHIFT = 65248;
|
|
176
256
|
function halfWidthChar(char) {
|
|
@@ -219,6 +299,27 @@ var MicrotaskCoalescer = class {
|
|
|
219
299
|
};
|
|
220
300
|
|
|
221
301
|
// src/controllers/otp_controller.ts
|
|
302
|
+
var LEASE_MARKERS = {
|
|
303
|
+
"aria-invalid": "data-otp-invalid-lease",
|
|
304
|
+
"aria-errormessage": "data-otp-errormessage-lease",
|
|
305
|
+
"aria-describedby": "data-otp-describedby-lease"
|
|
306
|
+
};
|
|
307
|
+
function isErrorLease(value) {
|
|
308
|
+
const lease = value;
|
|
309
|
+
return typeof lease?.written === "string" && (lease.authored === null || typeof lease.authored === "string");
|
|
310
|
+
}
|
|
311
|
+
function readLease(field, marker) {
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(field.getAttribute(marker) ?? "null");
|
|
314
|
+
return isErrorLease(parsed) ? parsed : null;
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function describedByWith(described, errorId) {
|
|
320
|
+
const tokens = (described ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
|
|
321
|
+
return [...tokens, errorId].join(" ");
|
|
322
|
+
}
|
|
222
323
|
var DEFAULT_PATTERN = "[0-9]";
|
|
223
324
|
var DEFAULT_MATCHER = new RegExp(`^(?:${DEFAULT_PATTERN})$`);
|
|
224
325
|
function hasModifier(event) {
|
|
@@ -240,38 +341,59 @@ var OtpController = class extends Controller {
|
|
|
240
341
|
#patternSource = DEFAULT_PATTERN;
|
|
241
342
|
/** Public state carried by the last dispatch; keeps a no-op sync silent. */
|
|
242
343
|
#published = null;
|
|
243
|
-
/** Field whose confirming `input` after `compositionend` is already handled. */
|
|
244
|
-
#confirmedField = null;
|
|
245
344
|
/** True between connect and disconnect, so pre-connect Value changes stay silent. */
|
|
246
345
|
#connected = false;
|
|
247
346
|
/** Digit each field last committed, restored when rejected input replaced it. */
|
|
248
347
|
#committed = /* @__PURE__ */ new WeakMap();
|
|
249
|
-
/**
|
|
348
|
+
/** The field being composed, and the text it held when the composition began. */
|
|
349
|
+
#composing = null;
|
|
350
|
+
/** A `pattern` change whose rejected characters wait for the composition to end. */
|
|
351
|
+
#dropHeld = false;
|
|
352
|
+
/**
|
|
353
|
+
* Collapses one batch of page changes — field target and `pattern` callbacks, a
|
|
354
|
+
* form reset — into a single reconciliation.
|
|
355
|
+
*/
|
|
250
356
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
|
|
251
|
-
#ariaInvalid = new AttributeLease("aria-invalid");
|
|
252
|
-
#ariaErrorMessage = new AttributeLease("aria-errormessage");
|
|
253
|
-
#ariaDescribedBy = new AttributeLease("aria-describedby");
|
|
254
357
|
#errorHidden = new AttributeLease("hidden");
|
|
255
358
|
#state = new AttributeLease("data-state");
|
|
256
359
|
/** Rewinds the transient error surface before Turbo freezes the page. */
|
|
257
360
|
#beforeCache = new BeforeCacheReset(() => this.#clearError());
|
|
258
|
-
/**
|
|
361
|
+
/** Reads the fields back once a native reset restored them, as a reconciliation. */
|
|
362
|
+
#formReset = new FormResetWatcher(
|
|
363
|
+
(form) => this.#ownedBy(form),
|
|
364
|
+
() => {
|
|
365
|
+
this.#adopt();
|
|
366
|
+
this.#reconcile.schedule();
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
/**
|
|
370
|
+
* Owns IME lifecycle state across every digit field. When a composition ends, a
|
|
371
|
+
* `pattern` drop it held back applies first — it was declared first — and the
|
|
372
|
+
* user's commit is taken after it.
|
|
373
|
+
*/
|
|
259
374
|
#composition = new CompositionTracker({
|
|
260
|
-
onStart: () => {
|
|
261
|
-
|
|
375
|
+
onStart: (event) => {
|
|
376
|
+
const field = this.#fieldFrom(event);
|
|
377
|
+
if (field) this.#composing = { field, before: field.value };
|
|
262
378
|
},
|
|
263
379
|
onEnd: (event) => {
|
|
380
|
+
const composing = this.#composing;
|
|
381
|
+
this.#composing = null;
|
|
264
382
|
const input = this.#fieldFrom(event);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
this.#
|
|
383
|
+
const data = event.data ?? "";
|
|
384
|
+
const text = input && data.length <= input.value.length ? input.value : data;
|
|
385
|
+
const commit = input !== null && (composing?.field !== input || text !== composing.before);
|
|
386
|
+
if (this.#dropHeld) {
|
|
387
|
+
if (input && commit) input.value = this.#committed.get(input) ?? "";
|
|
388
|
+
this.#reconcileFields();
|
|
389
|
+
}
|
|
390
|
+
if (input && commit) this.#accept(input, text);
|
|
269
391
|
}
|
|
270
392
|
});
|
|
271
393
|
connect() {
|
|
272
394
|
this.#connected = true;
|
|
273
395
|
for (const field of this.fieldTargets) this.#bind(field);
|
|
274
|
-
|
|
396
|
+
this.#formReset.observe();
|
|
275
397
|
this.#beforeCache.activate();
|
|
276
398
|
this.#reconcile.activate();
|
|
277
399
|
this.#adopt();
|
|
@@ -281,9 +403,10 @@ var OtpController = class extends Controller {
|
|
|
281
403
|
this.#connected = false;
|
|
282
404
|
for (const field of this.fieldTargets) this.#unbind(field);
|
|
283
405
|
this.#composition.disconnect();
|
|
284
|
-
|
|
406
|
+
this.#formReset.disconnect();
|
|
285
407
|
this.#beforeCache.deactivate();
|
|
286
408
|
this.#reconcile.cancel();
|
|
409
|
+
this.#dropHeld = false;
|
|
287
410
|
this.#clearError();
|
|
288
411
|
this.#state.return(this.element);
|
|
289
412
|
}
|
|
@@ -300,18 +423,29 @@ var OtpController = class extends Controller {
|
|
|
300
423
|
/** Releases a dropped field's listeners and leases, then reconciles the rest. */
|
|
301
424
|
fieldTargetDisconnected(element) {
|
|
302
425
|
this.#unbind(element);
|
|
303
|
-
this.#
|
|
426
|
+
this.#returnLeases(element);
|
|
304
427
|
this.#reconcile.schedule();
|
|
305
428
|
}
|
|
306
429
|
/**
|
|
307
430
|
* Re-validates a changed `pattern` declaration once and drops any entered digit
|
|
308
431
|
* the new pattern no longer accepts, so the combined value stays interpretable.
|
|
432
|
+
* The page changed the declaration, so the move joins the same reconciliation
|
|
433
|
+
* as fields added or removed in that mutation. While a field is composing, its
|
|
434
|
+
* text is the IME's, and the drop waits for the composition to end.
|
|
309
435
|
*/
|
|
310
436
|
patternValueChanged() {
|
|
311
437
|
const compiled = compileRegExp(this.patternValue, "exact");
|
|
312
438
|
this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
|
|
313
439
|
this.#pattern = compiled ?? DEFAULT_MATCHER;
|
|
314
440
|
if (!this.#connected) return;
|
|
441
|
+
if (this.#composing !== null) {
|
|
442
|
+
this.#dropHeld = true;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (this.#dropRejected()) this.#reconcile.schedule();
|
|
446
|
+
}
|
|
447
|
+
/** Empties every writable field holding a character the pattern rejects; whether one did. */
|
|
448
|
+
#dropRejected() {
|
|
315
449
|
let dropped = false;
|
|
316
450
|
for (const field of this.fieldTargets) {
|
|
317
451
|
if (field.value === "" || !this.#isWritable(field)) continue;
|
|
@@ -319,15 +453,13 @@ var OtpController = class extends Controller {
|
|
|
319
453
|
this.#writeField(field, "");
|
|
320
454
|
dropped = true;
|
|
321
455
|
}
|
|
322
|
-
|
|
456
|
+
return dropped;
|
|
323
457
|
}
|
|
324
458
|
/** Handles keystroke inputs, distributes autofilled text, and advances focus. */
|
|
325
459
|
onInput(event) {
|
|
326
460
|
const input = this.#fieldFrom(event);
|
|
327
461
|
if (!input) return;
|
|
328
|
-
|
|
329
|
-
this.#confirmedField = null;
|
|
330
|
-
if (confirmed === input) return;
|
|
462
|
+
if (this.#composition.consumesConfirmedInput(event)) return;
|
|
331
463
|
if (this.#composition.isComposing(event)) return;
|
|
332
464
|
this.#accept(input);
|
|
333
465
|
}
|
|
@@ -336,7 +468,6 @@ var OtpController = class extends Controller {
|
|
|
336
468
|
if (isReservedArrowChord(event)) return;
|
|
337
469
|
const input = this.#fieldFrom(event);
|
|
338
470
|
if (!input) return;
|
|
339
|
-
this.#confirmedField = null;
|
|
340
471
|
if (this.#composition.isComposing(event)) return;
|
|
341
472
|
const index = this.fieldTargets.indexOf(input);
|
|
342
473
|
const fields = this.fieldTargets;
|
|
@@ -400,7 +531,6 @@ var OtpController = class extends Controller {
|
|
|
400
531
|
const input = this.#fieldFrom(event);
|
|
401
532
|
if (!input) return;
|
|
402
533
|
event.preventDefault();
|
|
403
|
-
this.#confirmedField = null;
|
|
404
534
|
this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
|
|
405
535
|
}
|
|
406
536
|
/**
|
|
@@ -431,16 +561,6 @@ var OtpController = class extends Controller {
|
|
|
431
561
|
input.select();
|
|
432
562
|
}
|
|
433
563
|
};
|
|
434
|
-
/** Reconciles derived state after a non-cancelled reset restores the fields. */
|
|
435
|
-
#onReset = (event) => {
|
|
436
|
-
const form = event.target;
|
|
437
|
-
if (!(form instanceof HTMLFormElement) || !this.#ownedBy(form)) return;
|
|
438
|
-
queueMicrotask(() => {
|
|
439
|
-
if (event.defaultPrevented) return;
|
|
440
|
-
this.#adopt();
|
|
441
|
-
this.#syncAndDispatch();
|
|
442
|
-
});
|
|
443
|
-
};
|
|
444
564
|
/** Whether a form owns at least one field or the hidden combined value. */
|
|
445
565
|
#ownedBy(form) {
|
|
446
566
|
if (this.fieldTargets.some((field) => field.form === form)) return true;
|
|
@@ -453,7 +573,7 @@ var OtpController = class extends Controller {
|
|
|
453
573
|
#unbind(field) {
|
|
454
574
|
field.removeEventListener("focus", this.#onFieldFocus);
|
|
455
575
|
this.#composition.unobserve(field);
|
|
456
|
-
if (this.#
|
|
576
|
+
if (this.#composing?.field === field) this.#composing = null;
|
|
457
577
|
}
|
|
458
578
|
/** Reads every field back so a restored or reset group starts consistent. */
|
|
459
579
|
#adopt() {
|
|
@@ -461,25 +581,55 @@ var OtpController = class extends Controller {
|
|
|
461
581
|
this.#clearError();
|
|
462
582
|
if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
|
|
463
583
|
}
|
|
584
|
+
/** Writes one error attribute, keeping the first authored value it displaces. */
|
|
585
|
+
#lease(field, attribute, value) {
|
|
586
|
+
const marker = LEASE_MARKERS[attribute];
|
|
587
|
+
const held = readLease(field, marker);
|
|
588
|
+
const authored = held ? held.authored : field.getAttribute(attribute);
|
|
589
|
+
field.setAttribute(marker, JSON.stringify({ authored, written: value }));
|
|
590
|
+
field.setAttribute(attribute, value);
|
|
591
|
+
}
|
|
592
|
+
/** Gives one field's error attributes back where they still hold what this wrote. */
|
|
593
|
+
#returnLeases(field) {
|
|
594
|
+
for (const [attribute, marker] of Object.entries(LEASE_MARKERS)) {
|
|
595
|
+
const held = readLease(field, marker);
|
|
596
|
+
if (!held) continue;
|
|
597
|
+
field.removeAttribute(marker);
|
|
598
|
+
if (field.getAttribute(attribute) !== held.written) continue;
|
|
599
|
+
if (held.authored === null) field.removeAttribute(attribute);
|
|
600
|
+
else field.setAttribute(attribute, held.authored);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
464
603
|
/** Takes one field's current value as the truth behind its derived state. */
|
|
465
604
|
#adoptField(field) {
|
|
466
605
|
this.#committed.set(field, field.value);
|
|
467
606
|
this.#markFilled(field, field.value);
|
|
468
607
|
}
|
|
469
608
|
/**
|
|
470
|
-
* Absorbs
|
|
609
|
+
* Absorbs one batch of page changes — fields added or removed, entered digits a
|
|
610
|
+
* new `pattern` rejects, fields a native form reset restored — as one state
|
|
611
|
+
* transition.
|
|
471
612
|
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
613
|
+
* Nobody edited the passcode here, so the move is reported as `reconcile`:
|
|
614
|
+
* automation listening for `change` must not read a re-render or a reset as an
|
|
615
|
+
* edit, and a passcode that happens to end up full must not fire the
|
|
475
616
|
* `complete` that submits it.
|
|
476
617
|
*
|
|
477
618
|
* Completeness moves on its own when the field count changes: dropping a
|
|
478
619
|
* trailing empty field completes a passcode whose combined value never moved,
|
|
479
620
|
* and adding one un-completes it. Comparing the whole derived state, not the
|
|
480
621
|
* string it contains, is what makes those transitions reportable.
|
|
622
|
+
*
|
|
623
|
+
* A change already in the DOM — a field added or removed, a reset — is
|
|
624
|
+
* published at once, even while a field is composing: that field counts by the
|
|
625
|
+
* character it had committed, never by the IME's uncommitted text. Only a
|
|
626
|
+
* `pattern` drop waits for the composition to end.
|
|
481
627
|
*/
|
|
482
628
|
#reconcileFields() {
|
|
629
|
+
if (this.#dropHeld && this.#composing === null) {
|
|
630
|
+
this.#dropHeld = false;
|
|
631
|
+
this.#dropRejected();
|
|
632
|
+
}
|
|
483
633
|
const previous = this.#published;
|
|
484
634
|
const current = this.#sync();
|
|
485
635
|
if (previous && !statesDiffer(previous, current)) return;
|
|
@@ -571,12 +721,16 @@ var OtpController = class extends Controller {
|
|
|
571
721
|
return this.fieldTargets.find((field) => field === input) ?? null;
|
|
572
722
|
}
|
|
573
723
|
#combinedValue() {
|
|
574
|
-
return this.fieldTargets.map((field) => field
|
|
724
|
+
return this.fieldTargets.map((field) => this.#publishedText(field)).join("");
|
|
575
725
|
}
|
|
576
726
|
/** Every field carries a character, and there is at least one field. */
|
|
577
727
|
#isComplete() {
|
|
578
728
|
const fields = this.fieldTargets;
|
|
579
|
-
return fields.length > 0 && fields.every((field) => field.
|
|
729
|
+
return fields.length > 0 && fields.every((field) => this.#publishedText(field).length > 0);
|
|
730
|
+
}
|
|
731
|
+
/** A field's text as the combined value counts it: a composing field by its committed character. */
|
|
732
|
+
#publishedText(field) {
|
|
733
|
+
return field === this.#composing?.field ? this.#committed.get(field) ?? "" : field.value;
|
|
580
734
|
}
|
|
581
735
|
/**
|
|
582
736
|
* Mirrors the combined value into the form and the root's readable state, and
|
|
@@ -610,31 +764,20 @@ var OtpController = class extends Controller {
|
|
|
610
764
|
#showError() {
|
|
611
765
|
const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
|
|
612
766
|
for (const field of this.fieldTargets) {
|
|
613
|
-
this.#
|
|
767
|
+
this.#lease(field, "aria-invalid", "true");
|
|
614
768
|
if (!errorId) continue;
|
|
615
|
-
this.#
|
|
616
|
-
|
|
769
|
+
this.#lease(field, "aria-errormessage", errorId);
|
|
770
|
+
const described = field.getAttribute("aria-describedby");
|
|
771
|
+
this.#lease(field, "aria-describedby", describedByWith(described, errorId));
|
|
617
772
|
}
|
|
618
773
|
if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
|
|
619
774
|
this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
|
|
620
775
|
}
|
|
621
776
|
/** Returns every error lease, restoring the authored error surface. */
|
|
622
777
|
#clearError() {
|
|
623
|
-
this.#
|
|
624
|
-
this.#ariaErrorMessage.returnAll();
|
|
625
|
-
this.#ariaDescribedBy.returnAll();
|
|
778
|
+
for (const field of this.fieldTargets) this.#returnLeases(field);
|
|
626
779
|
this.#errorHidden.returnAll();
|
|
627
780
|
}
|
|
628
|
-
#returnFieldLeases(field) {
|
|
629
|
-
this.#ariaInvalid.return(field);
|
|
630
|
-
this.#ariaErrorMessage.return(field);
|
|
631
|
-
this.#ariaDescribedBy.return(field);
|
|
632
|
-
}
|
|
633
|
-
/** The field's own description tokens with the error id appended once. */
|
|
634
|
-
#describedByWith(field, errorId) {
|
|
635
|
-
const tokens = (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
|
|
636
|
-
return [...tokens, errorId].join(" ");
|
|
637
|
-
}
|
|
638
781
|
};
|
|
639
782
|
|
|
640
783
|
export { OtpController };
|