stimeo-ui 0.15.0 → 0.17.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 +209 -0
- package/README.md +128 -1
- package/dist/cable/index.d.ts +79 -23
- package/dist/cable/index.js +392 -417
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +34 -5
- package/dist/controllers/accordion_controller.js +228 -28
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +20 -9
- package/dist/controllers/alert_dialog_controller.js +862 -221
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +38 -7
- package/dist/controllers/announcer_controller.js +135 -191
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/aspect_ratio_controller.js +0 -10
- package/dist/controllers/aspect_ratio_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.d.ts +30 -6
- package/dist/controllers/auto_submit_controller.js +295 -110
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +172 -106
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +10 -7
- package/dist/controllers/breadcrumb_controller.js +0 -129
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +22 -4
- package/dist/controllers/bulk_select_controller.js +264 -53
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +109 -28
- package/dist/controllers/calendar_controller.js +346 -180
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +99 -27
- package/dist/controllers/carousel_controller.js +587 -300
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +18 -2
- package/dist/controllers/character_counter_controller.js +271 -143
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.d.ts +7 -2
- package/dist/controllers/checkbox_controller.js +77 -75
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +44 -7
- package/dist/controllers/clipboard_controller.js +187 -108
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +41 -5
- package/dist/controllers/collapsible_controller.js +374 -108
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +47 -14
- package/dist/controllers/color_picker_controller.js +183 -127
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +33 -6
- package/dist/controllers/combobox_controller.js +426 -109
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +45 -14
- package/dist/controllers/command_palette_controller.js +994 -350
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.d.ts +15 -2
- package/dist/controllers/conditional_fields_controller.js +212 -125
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.d.ts +49 -12
- package/dist/controllers/confirm_controller.js +939 -245
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +36 -3
- package/dist/controllers/context_menu_controller.js +275 -127
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +13 -1
- package/dist/controllers/count_up_controller.js +73 -21
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +75 -2
- package/dist/controllers/countdown_controller.js +235 -97
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +93 -15
- package/dist/controllers/currency_input_controller.js +241 -118
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +78 -22
- package/dist/controllers/data_grid_controller.js +229 -135
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +64 -11
- package/dist/controllers/date_range_picker_controller.js +359 -165
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +22 -3
- package/dist/controllers/dialog_controller.js +887 -225
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +24 -7
- package/dist/controllers/direct_upload_controller.js +80 -142
- 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 +26 -47
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +19 -0
- package/dist/controllers/dismissible_controller.js +175 -20
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.d.ts +52 -16
- package/dist/controllers/drawer_controller.js +963 -336
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +39 -3
- package/dist/controllers/dropdown_controller.js +342 -85
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +12 -1
- package/dist/controllers/editable_controller.js +182 -58
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.js +67 -53
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +60 -18
- package/dist/controllers/file_dropzone_controller.js +426 -229
- 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 +78 -36
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +57 -14
- package/dist/controllers/flash_controller.js +328 -211
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +12 -3
- package/dist/controllers/focus_controller.js +654 -239
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +153 -136
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +29 -96
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +41 -9
- package/dist/controllers/frame_loading_controller.js +263 -198
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.d.ts +22 -2
- package/dist/controllers/highlight_controller.js +119 -70
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +65 -4
- package/dist/controllers/hover_card_controller.js +335 -118
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +58 -4
- package/dist/controllers/idle_controller.js +316 -65
- 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 +116 -61
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.d.ts +32 -13
- package/dist/controllers/intersection_controller.js +147 -103
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +16 -0
- package/dist/controllers/lazy_frame_controller.js +9 -56
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +84 -10
- package/dist/controllers/listbox_controller.js +413 -153
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +1 -1
- package/dist/controllers/local_time_controller.js +54 -64
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +11 -3
- package/dist/controllers/masonry_controller.js +70 -95
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +40 -9
- package/dist/controllers/menu_controller.js +254 -167
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +15 -4
- package/dist/controllers/menubar_controller.js +206 -331
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +20 -0
- package/dist/controllers/meter_controller.js +159 -71
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +50 -7
- package/dist/controllers/multi_select_controller.js +797 -388
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +19 -1
- package/dist/controllers/navigation_menu_controller.js +156 -231
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +10 -0
- package/dist/controllers/nested_form_controller.js +373 -174
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +23 -0
- package/dist/controllers/network_status_controller.js +241 -45
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +62 -10
- package/dist/controllers/number_input_controller.js +387 -227
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.js +28 -75
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +38 -7
- package/dist/controllers/otp_controller.js +344 -196
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +13 -12
- package/dist/controllers/overflow_indicator_controller.js +167 -93
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +66 -9
- package/dist/controllers/overflow_menu_controller.js +408 -277
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +52 -15
- package/dist/controllers/pagination_controller.js +342 -120
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +60 -9
- package/dist/controllers/password_reveal_controller.js +424 -99
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +14 -5
- package/dist/controllers/password_strength_controller.js +99 -153
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.d.ts +11 -4
- package/dist/controllers/persist_controller.js +252 -96
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +19 -7
- package/dist/controllers/pointer_drag_controller.js +263 -164
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +46 -4
- package/dist/controllers/popover_controller.js +267 -94
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +44 -4
- package/dist/controllers/portal_controller.js +209 -72
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.d.ts +10 -4
- package/dist/controllers/preview_guard_controller.js +243 -112
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +11 -0
- package/dist/controllers/progress_controller.js +132 -59
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +19 -8
- package/dist/controllers/radio_group_controller.js +138 -133
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +92 -4
- package/dist/controllers/range_slider_controller.js +245 -112
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +33 -6
- package/dist/controllers/rating_controller.js +429 -194
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +26 -2
- package/dist/controllers/read_more_controller.js +185 -47
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +1 -1
- package/dist/controllers/reading_progress_controller.js +147 -135
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +18 -4
- package/dist/controllers/relative_time_controller.js +145 -110
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/reset_on_restore_controller.d.ts +85 -0
- package/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- package/dist/controllers/reset_on_restore_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +44 -4
- package/dist/controllers/resizable_controller.js +294 -118
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +39 -84
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +7 -3
- package/dist/controllers/scroll_area_controller.js +428 -221
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +0 -53
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +28 -12
- package/dist/controllers/scroll_visibility_controller.js +395 -146
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +9 -3
- package/dist/controllers/scrollspy_controller.js +181 -236
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +59 -17
- package/dist/controllers/separator_controller.js +288 -132
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +81 -7
- package/dist/controllers/sidebar_controller.js +1071 -375
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +23 -0
- package/dist/controllers/skeleton_controller.js +248 -92
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +72 -8
- package/dist/controllers/slider_controller.js +211 -96
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +21 -3
- package/dist/controllers/smart_sticky_header_controller.js +163 -54
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.js +13 -110
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +39 -4
- package/dist/controllers/spinner_controller.js +277 -161
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +24 -17
- package/dist/controllers/step_indicator_controller.js +107 -53
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +43 -9
- package/dist/controllers/stepper_controller.js +171 -48
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +25 -0
- package/dist/controllers/stick_to_bottom_controller.js +310 -116
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +11 -0
- package/dist/controllers/sticky_observer_controller.js +214 -49
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +22 -6
- package/dist/controllers/submit_once_controller.js +294 -192
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +40 -7
- package/dist/controllers/switch_controller.js +107 -43
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +13 -1
- package/dist/controllers/tabs_controller.js +50 -25
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +27 -3
- package/dist/controllers/tags_input_controller.js +290 -149
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +10 -0
- package/dist/controllers/textarea_autosize_controller.js +71 -58
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +37 -8
- package/dist/controllers/theme_controller.js +148 -80
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +18 -1
- package/dist/controllers/time_picker_controller.js +162 -81
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +85 -23
- package/dist/controllers/toast_controller.js +365 -193
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +60 -9
- package/dist/controllers/toggle_group_controller.js +194 -99
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +92 -90
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +52 -2
- package/dist/controllers/tooltip_controller.js +185 -125
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +22 -4
- package/dist/controllers/transition_controller.js +123 -115
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +59 -18
- package/dist/controllers/tree_view_controller.js +248 -290
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.d.ts +33 -3
- package/dist/index.js +8649 -8572
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +95 -9
- package/dist/inspector/cli.js +343 -77
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +390 -96
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +45 -45
- package/dist/inspector/manifest.json +1308 -155
- package/dist/positioning/index.d.ts +8 -0
- package/dist/positioning/index.js +148 -45
- package/dist/positioning/index.js.map +1 -1
- package/package.json +7 -7
- package/dist/controllers/reset_before_cache_controller.d.ts +0 -76
- package/dist/controllers/reset_before_cache_controller.js.map +0 -1
package/dist/cable/index.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
import { createConsumer } from '@rails/actioncable';
|
|
3
3
|
|
|
4
|
-
// src/cable/live_counter_controller.ts
|
|
5
|
-
|
|
6
|
-
// src/utils/announce.ts
|
|
7
4
|
function announce(message, options = {}) {
|
|
8
5
|
const text = message.trim();
|
|
9
6
|
if (text.length === 0) return;
|
|
@@ -20,7 +17,6 @@ function fillTemplate(template, values) {
|
|
|
20
17
|
});
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
// src/utils/authored_integer.ts
|
|
24
20
|
var TOKEN_BODY = /\d[\d.,'’’ _]*\d|\d/;
|
|
25
21
|
var SIGN_OPENER = /[\s([]/;
|
|
26
22
|
function authoredInteger(text) {
|
|
@@ -42,29 +38,21 @@ function authoredInteger(text) {
|
|
|
42
38
|
return signed ? -magnitude || 0 : magnitude;
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
// src/utils/blur_deferral.ts
|
|
46
41
|
var BlurDeferral = class {
|
|
47
|
-
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
48
42
|
#pending = /* @__PURE__ */ new Map();
|
|
49
|
-
/** Called after a pending element blurs and has been detached. */
|
|
50
43
|
#onRelease;
|
|
51
|
-
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
52
44
|
constructor(onRelease) {
|
|
53
45
|
this.#onRelease = onRelease;
|
|
54
46
|
}
|
|
55
|
-
/** Number of elements currently holding an update back. */
|
|
56
47
|
get size() {
|
|
57
48
|
return this.#pending.size;
|
|
58
49
|
}
|
|
59
|
-
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
60
50
|
get elements() {
|
|
61
51
|
return [...this.#pending.keys()];
|
|
62
52
|
}
|
|
63
|
-
/** Whether `element` is currently holding an update back. */
|
|
64
53
|
has(element) {
|
|
65
54
|
return this.#pending.has(element);
|
|
66
55
|
}
|
|
67
|
-
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
68
56
|
defer(element) {
|
|
69
57
|
if (this.#pending.has(element)) return;
|
|
70
58
|
const onBlur = () => {
|
|
@@ -74,22 +62,18 @@ var BlurDeferral = class {
|
|
|
74
62
|
this.#pending.set(element, onBlur);
|
|
75
63
|
element.addEventListener("blur", onBlur);
|
|
76
64
|
}
|
|
77
|
-
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
78
65
|
deferOnly(element) {
|
|
79
66
|
for (const pending of this.elements) {
|
|
80
67
|
if (pending !== element) this.#detach(pending);
|
|
81
68
|
}
|
|
82
69
|
this.defer(element);
|
|
83
70
|
}
|
|
84
|
-
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
85
71
|
release(element) {
|
|
86
72
|
this.#detach(element);
|
|
87
73
|
}
|
|
88
|
-
/** Cancels every deferral without completing any of them. */
|
|
89
74
|
releaseAll() {
|
|
90
75
|
for (const element of this.elements) this.#detach(element);
|
|
91
76
|
}
|
|
92
|
-
/** Removes the `blur` listener for `element` and forgets it. */
|
|
93
77
|
#detach(element) {
|
|
94
78
|
const onBlur = this.#pending.get(element);
|
|
95
79
|
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
@@ -97,44 +81,90 @@ var BlurDeferral = class {
|
|
|
97
81
|
}
|
|
98
82
|
};
|
|
99
83
|
|
|
100
|
-
|
|
84
|
+
var MicrotaskCoalescer = class {
|
|
85
|
+
#run;
|
|
86
|
+
#queued = false;
|
|
87
|
+
#active = false;
|
|
88
|
+
#generation = 0;
|
|
89
|
+
constructor(run) {
|
|
90
|
+
this.#run = run;
|
|
91
|
+
}
|
|
92
|
+
activate() {
|
|
93
|
+
this.#active = true;
|
|
94
|
+
}
|
|
95
|
+
cancel() {
|
|
96
|
+
this.#active = false;
|
|
97
|
+
this.#queued = false;
|
|
98
|
+
this.#generation += 1;
|
|
99
|
+
}
|
|
100
|
+
schedule() {
|
|
101
|
+
if (!this.#active || this.#queued) return;
|
|
102
|
+
this.#queued = true;
|
|
103
|
+
const generation = this.#generation;
|
|
104
|
+
queueMicrotask(() => {
|
|
105
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
106
|
+
this.#queued = false;
|
|
107
|
+
this.#run();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
var ListenerSet = class {
|
|
113
|
+
#abort = new AbortController();
|
|
114
|
+
add(target, type, handler, options) {
|
|
115
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
116
|
+
}
|
|
117
|
+
dispose() {
|
|
118
|
+
this.#abort.abort();
|
|
119
|
+
this.#abort = new AbortController();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
var MorphRenderWatcher = class {
|
|
124
|
+
#listeners = new ListenerSet();
|
|
125
|
+
#pass;
|
|
126
|
+
#element = null;
|
|
127
|
+
#onMorph = () => this.#pass.schedule();
|
|
128
|
+
constructor(run) {
|
|
129
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
130
|
+
}
|
|
131
|
+
observe(element) {
|
|
132
|
+
if (this.#element === element) return;
|
|
133
|
+
this.disconnect();
|
|
134
|
+
this.#element = element;
|
|
135
|
+
this.#pass.activate();
|
|
136
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
137
|
+
}
|
|
138
|
+
schedule() {
|
|
139
|
+
this.#pass.schedule();
|
|
140
|
+
}
|
|
141
|
+
disconnect() {
|
|
142
|
+
this.#listeners.dispose();
|
|
143
|
+
this.#pass.cancel();
|
|
144
|
+
this.#element = null;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
101
148
|
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
149
|
+
|
|
102
150
|
var TimerRegistry = class {
|
|
103
|
-
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
104
151
|
ids = /* @__PURE__ */ new Set();
|
|
105
|
-
/**
|
|
106
|
-
* Cancels a single tracked timer.
|
|
107
|
-
*
|
|
108
|
-
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
109
|
-
* registry), so callers can clear defensively without guarding.
|
|
110
|
-
*/
|
|
111
152
|
clear(id) {
|
|
112
153
|
if (this.ids.delete(id)) {
|
|
113
154
|
this.cancel(id);
|
|
114
155
|
}
|
|
115
156
|
}
|
|
116
|
-
/**
|
|
117
|
-
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
118
|
-
* to guarantee no timer outlives the element.
|
|
119
|
-
*/
|
|
120
157
|
clearAll() {
|
|
121
158
|
for (const id of this.ids) {
|
|
122
159
|
this.cancel(id);
|
|
123
160
|
}
|
|
124
161
|
this.ids.clear();
|
|
125
162
|
}
|
|
126
|
-
/** Number of timers currently tracked (pending). */
|
|
127
163
|
get size() {
|
|
128
164
|
return this.ids.size;
|
|
129
165
|
}
|
|
130
166
|
};
|
|
131
167
|
var SafeTimeout = class extends TimerRegistry {
|
|
132
|
-
/**
|
|
133
|
-
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
134
|
-
*
|
|
135
|
-
* The id is removed from the registry automatically when the timeout fires,
|
|
136
|
-
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
137
|
-
*/
|
|
138
168
|
set(callback, delay) {
|
|
139
169
|
const id = this.schedule(() => {
|
|
140
170
|
this.ids.delete(id);
|
|
@@ -151,7 +181,6 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
151
181
|
}
|
|
152
182
|
};
|
|
153
183
|
var SafeInterval = class extends TimerRegistry {
|
|
154
|
-
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
155
184
|
set(callback, delay) {
|
|
156
185
|
const id = this.schedule(callback, delay);
|
|
157
186
|
this.ids.add(id);
|
|
@@ -164,6 +193,27 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
164
193
|
window.clearInterval(id);
|
|
165
194
|
}
|
|
166
195
|
};
|
|
196
|
+
|
|
197
|
+
var TransientHooks = class {
|
|
198
|
+
#attributes;
|
|
199
|
+
constructor(options) {
|
|
200
|
+
this.#attributes = options.attributes;
|
|
201
|
+
}
|
|
202
|
+
reset(element) {
|
|
203
|
+
for (const attribute of this.#attributes) element.removeAttribute(attribute);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
function sharedRegistry(key, create) {
|
|
208
|
+
const symbol = Symbol.for(key);
|
|
209
|
+
const scope = globalThis;
|
|
210
|
+
const existing = scope[symbol];
|
|
211
|
+
if (existing !== void 0) return existing;
|
|
212
|
+
const registry = create();
|
|
213
|
+
Object.defineProperty(globalThis, symbol, { value: registry });
|
|
214
|
+
return registry;
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
function parseSubscriptionParams(raw) {
|
|
168
218
|
try {
|
|
169
219
|
const parsed = JSON.parse(raw);
|
|
@@ -173,15 +223,21 @@ function parseSubscriptionParams(raw) {
|
|
|
173
223
|
return {};
|
|
174
224
|
}
|
|
175
225
|
}
|
|
176
|
-
var
|
|
226
|
+
var consumerRegistry = sharedRegistry(
|
|
227
|
+
"stimeo-ui.cable-consumer.registry.v1",
|
|
228
|
+
() => ({ consumer: null })
|
|
229
|
+
);
|
|
177
230
|
function setCableConsumer(consumer) {
|
|
178
|
-
|
|
231
|
+
consumerRegistry.consumer = consumer;
|
|
179
232
|
}
|
|
180
233
|
function getCableConsumer() {
|
|
181
|
-
if (!
|
|
182
|
-
return
|
|
234
|
+
if (!consumerRegistry.consumer) consumerRegistry.consumer = createConsumer();
|
|
235
|
+
return consumerRegistry.consumer;
|
|
183
236
|
}
|
|
184
|
-
var sharedSubscriptions =
|
|
237
|
+
var sharedSubscriptions = sharedRegistry(
|
|
238
|
+
"stimeo-ui.cable-subscriptions.registry.v1",
|
|
239
|
+
() => /* @__PURE__ */ new WeakMap()
|
|
240
|
+
);
|
|
185
241
|
function identifierOf(channel) {
|
|
186
242
|
return JSON.stringify(typeof channel === "string" ? { channel } : channel);
|
|
187
243
|
}
|
|
@@ -245,9 +301,6 @@ function openSharedSubscription(consumer, channel) {
|
|
|
245
301
|
confirmed: false,
|
|
246
302
|
rejected: false,
|
|
247
303
|
subscription: consumer.subscriptions.create(channel, {
|
|
248
|
-
// A refusal is final: Action Cable never confirms a rejected subscription,
|
|
249
|
-
// so a late connected/disconnected (only possible from a misbehaving
|
|
250
|
-
// consumer double) must not reopen the gate `rejected` promised shut.
|
|
251
304
|
connected: () => {
|
|
252
305
|
if (shared.rejected) return;
|
|
253
306
|
shared.confirmed = true;
|
|
@@ -269,7 +322,7 @@ function openSharedSubscription(consumer, channel) {
|
|
|
269
322
|
return shared;
|
|
270
323
|
}
|
|
271
324
|
|
|
272
|
-
|
|
325
|
+
var TRANSIENT = new TransientHooks({ attributes: ["data-live-counter-rejected"] });
|
|
273
326
|
var DISABLED_MARKER = "data-live-counter-disabled";
|
|
274
327
|
var LiveCounterController = class _LiveCounterController extends Controller {
|
|
275
328
|
static targets = ["value", "trigger"];
|
|
@@ -279,80 +332,42 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
279
332
|
id: { type: String, default: "" },
|
|
280
333
|
announceText: { type: String, default: "" }
|
|
281
334
|
};
|
|
282
|
-
/** Collapses a burst of broadcasts into one announcement. */
|
|
283
335
|
static #announceDelay = 200;
|
|
284
336
|
static actions = ["increment"];
|
|
285
337
|
static events = ["change"];
|
|
286
|
-
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
287
|
-
#params = {};
|
|
288
338
|
#subscription = null;
|
|
289
|
-
#
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
* sent them. An echo belongs to the send that caused it, and the amount is what
|
|
293
|
-
* names that send: a counter that guessed nothing — or guessed a different
|
|
294
|
-
* amount — applies the delta like any other, so siblings and other tabs
|
|
295
|
-
* converge whatever step each of them uses.
|
|
296
|
-
*/
|
|
339
|
+
#follow = new MicrotaskCoalescer(() => this.#followDeclaration());
|
|
340
|
+
#identifier = "";
|
|
341
|
+
#morphRender = new MorphRenderWatcher(() => this.#syncTriggers());
|
|
297
342
|
#outstanding = [];
|
|
298
|
-
/** Holds a disable back while the trigger owns focus, so focus is never taken away. */
|
|
299
343
|
#focusedTriggers = new BlurDeferral((trigger) => this.#syncTrigger(trigger));
|
|
300
344
|
#timers = new SafeTimeout();
|
|
301
|
-
/**
|
|
302
|
-
* Re-parses the identifier parameters and moves the subscription to the
|
|
303
|
-
* identifier they now name.
|
|
304
|
-
*
|
|
305
|
-
* A malformed declaration falls back to no parameters, so the identifier keeps
|
|
306
|
-
* naming the channel instead of the subscription never being created at all.
|
|
307
|
-
*/
|
|
308
345
|
paramsValueChanged() {
|
|
309
|
-
this.#
|
|
310
|
-
this.#resubscribe();
|
|
346
|
+
this.#follow.schedule();
|
|
311
347
|
}
|
|
312
|
-
/** Moves the subscription when the declaration names a different channel. */
|
|
313
348
|
channelValueChanged() {
|
|
314
|
-
this.#
|
|
349
|
+
this.#follow.schedule();
|
|
315
350
|
}
|
|
316
351
|
connect() {
|
|
317
|
-
this.#
|
|
352
|
+
this.#morphRender.observe(this.element);
|
|
353
|
+
this.#follow.activate();
|
|
318
354
|
this.#subscribe();
|
|
319
355
|
}
|
|
320
|
-
/**
|
|
321
|
-
* Opens the subscription the declaration names, and reflects the gate.
|
|
322
|
-
*
|
|
323
|
-
* The rejected hook is transient server state: a Turbo cache snapshot must not
|
|
324
|
-
* resurrect it — the fresh subscription re-decides it. An outstanding guess
|
|
325
|
-
* belongs to the identifier that is going away, so it is dropped with it.
|
|
326
|
-
*/
|
|
327
356
|
#subscribe() {
|
|
328
|
-
this.element
|
|
357
|
+
TRANSIENT.reset(this.element);
|
|
329
358
|
this.#outstanding.length = 0;
|
|
359
|
+
const descriptor = this.#descriptor;
|
|
360
|
+
this.#identifier = identifierOf(descriptor);
|
|
330
361
|
if (this.channelValue) {
|
|
331
|
-
this.#subscription = this.#open();
|
|
362
|
+
this.#subscription = this.#open(descriptor);
|
|
332
363
|
}
|
|
333
364
|
this.#syncTriggers();
|
|
334
365
|
}
|
|
335
|
-
|
|
336
|
-
* Opens the wire subscription, or returns null when it cannot be opened.
|
|
337
|
-
*
|
|
338
|
-
* A consumer that cannot be built (no Action Cable meta tag, a boot-order
|
|
339
|
-
* mistake) would otherwise throw out of `connect()` and leave the element
|
|
340
|
-
* half-wired: no gate reflected, and a trigger that looks live. The gate reads
|
|
341
|
-
* the declaration, so a null subscription keeps it shut and the display never
|
|
342
|
-
* moves past what the server can receive.
|
|
343
|
-
*/
|
|
344
|
-
#open() {
|
|
366
|
+
#open(descriptor) {
|
|
345
367
|
try {
|
|
346
|
-
return createConfirmedSubscription(
|
|
368
|
+
return createConfirmedSubscription(descriptor, {
|
|
347
369
|
connected: () => this.#syncTriggers(),
|
|
348
|
-
// A drop closes the send window (the shared subscription tracks it)
|
|
349
|
-
// until Action Cable reconnects and re-confirms — an increment during
|
|
350
|
-
// the outage would bump the display while its perform() is silently
|
|
351
|
-
// discarded by the closed socket.
|
|
352
370
|
disconnected: () => this.#syncTriggers(),
|
|
353
|
-
// The server refused the subscription (auth, bad params): the gate
|
|
354
|
-
// stays shut for good, and the hook lets the consumer's CSS disable
|
|
355
|
-
// or hide the trigger instead of leaving a silently dead button.
|
|
356
371
|
rejected: () => {
|
|
357
372
|
this.element.setAttribute("data-live-counter-rejected", "true");
|
|
358
373
|
this.#syncTriggers();
|
|
@@ -364,47 +379,33 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
364
379
|
return null;
|
|
365
380
|
}
|
|
366
381
|
}
|
|
367
|
-
/**
|
|
368
|
-
* The identifier this declaration names.
|
|
369
|
-
*
|
|
370
|
-
* `channel` comes first so the identifier text matches what Action Cable
|
|
371
|
-
* builds, and a `channel` key inside `params` cannot displace the declared
|
|
372
|
-
* one — `params` names *additional* identifier parameters.
|
|
373
|
-
*/
|
|
374
382
|
get #descriptor() {
|
|
375
|
-
const { channel: _ignored, ...rest } = this
|
|
383
|
+
const { channel: _ignored, ...rest } = parseSubscriptionParams(this.paramsValue);
|
|
376
384
|
return { channel: this.channelValue, ...rest };
|
|
377
385
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (!this.#connected) return;
|
|
386
|
+
#followDeclaration() {
|
|
387
|
+
if (identifierOf(this.#descriptor) === this.#identifier) return;
|
|
381
388
|
this.#subscription?.unsubscribe();
|
|
382
389
|
this.#subscription = null;
|
|
383
390
|
this.#subscribe();
|
|
384
391
|
}
|
|
385
392
|
disconnect() {
|
|
386
|
-
this.#
|
|
393
|
+
this.#morphRender.disconnect();
|
|
394
|
+
this.#follow.cancel();
|
|
387
395
|
this.#timers.clearAll();
|
|
388
396
|
this.#focusedTriggers.releaseAll();
|
|
389
397
|
this.#subscription?.unsubscribe();
|
|
390
398
|
this.#subscription = null;
|
|
391
|
-
this.element
|
|
399
|
+
TRANSIENT.reset(this.element);
|
|
392
400
|
for (const trigger of this.triggerTargets) this.#release(trigger);
|
|
393
401
|
}
|
|
394
|
-
/** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */
|
|
395
402
|
triggerTargetConnected(target) {
|
|
396
403
|
this.#syncTrigger(target);
|
|
397
404
|
}
|
|
398
|
-
/** A trigger that stops being one keeps nothing this controller lent it. */
|
|
399
405
|
triggerTargetDisconnected(target) {
|
|
400
406
|
this.#focusedTriggers.release(target);
|
|
401
407
|
this.#release(target);
|
|
402
408
|
}
|
|
403
|
-
/**
|
|
404
|
-
* Optimistic local increment: bumps the display immediately, then asks the
|
|
405
|
-
* server to persist and broadcast. The step comes from the action param
|
|
406
|
-
* (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.
|
|
407
|
-
*/
|
|
408
409
|
increment(event) {
|
|
409
410
|
if (!this.#ready) return;
|
|
410
411
|
const raw = Number(event?.params?.delta ?? 1);
|
|
@@ -417,15 +418,6 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
417
418
|
}
|
|
418
419
|
this.#subscription?.perform("increment", { id: this.idValue, delta });
|
|
419
420
|
}
|
|
420
|
-
/**
|
|
421
|
-
* Reconciles a broadcast: absolute `count` wins; the echo of this client's own
|
|
422
|
-
* guess is skipped.
|
|
423
|
-
*
|
|
424
|
-
* Only whole numbers reach the display. The wire carries values another client
|
|
425
|
-
* named and the server relayed, and the display is the state this controller
|
|
426
|
-
* reads back — a fraction, an infinity or a NaN would be written as text that
|
|
427
|
-
* reads back as a different number, or as none at all.
|
|
428
|
-
*/
|
|
429
421
|
#onReceived(data) {
|
|
430
422
|
const message = data;
|
|
431
423
|
if (Number.isSafeInteger(message?.count)) {
|
|
@@ -444,30 +436,15 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
444
436
|
this.#reconcile(this.#current + delta);
|
|
445
437
|
}
|
|
446
438
|
}
|
|
447
|
-
/** True when no channel is declared: the local-only mode, with no server to diverge from. */
|
|
448
439
|
get #local() {
|
|
449
440
|
return this.channelValue === "";
|
|
450
441
|
}
|
|
451
|
-
/** True while an increment would go through (local-only counters always are). */
|
|
452
442
|
get #ready() {
|
|
453
443
|
return this.#local || (this.#subscription?.confirmed ?? false);
|
|
454
444
|
}
|
|
455
|
-
/**
|
|
456
|
-
* Reflects the send gate onto the optional `trigger` targets as the real
|
|
457
|
-
* `disabled` attribute — the declarative alternative to styling off the
|
|
458
|
-
* `data-live-counter-rejected` hook (a disabled control is also skipped by
|
|
459
|
-
* keyboard focus and announced by AT, which CSS alone cannot do).
|
|
460
|
-
*/
|
|
461
445
|
#syncTriggers() {
|
|
462
446
|
for (const trigger of this.triggerTargets) this.#syncTrigger(trigger);
|
|
463
447
|
}
|
|
464
|
-
/**
|
|
465
|
-
* `disabled` is a shared attribute: only disable what is currently enabled
|
|
466
|
-
* (marking it ours), and only lift a disabled carrying our marker — so an
|
|
467
|
-
* authored-disabled trigger ("disabled until valid", say) is never
|
|
468
|
-
* re-enabled by the gate. A marked disabled restored from a Turbo cache
|
|
469
|
-
* snapshot is recognized as ours and lifted once the gate opens.
|
|
470
|
-
*/
|
|
471
448
|
#syncTrigger(trigger) {
|
|
472
449
|
if (this.#ready) {
|
|
473
450
|
this.#focusedTriggers.release(trigger);
|
|
@@ -482,47 +459,26 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
482
459
|
trigger.setAttribute("disabled", "");
|
|
483
460
|
trigger.setAttribute(DISABLED_MARKER, "");
|
|
484
461
|
}
|
|
485
|
-
/** Gives back a `disabled` this controller lent, leaving an authored one alone. */
|
|
486
462
|
#release(trigger) {
|
|
487
463
|
if (!trigger.hasAttribute(DISABLED_MARKER)) return;
|
|
488
464
|
trigger.removeAttribute("disabled");
|
|
489
465
|
trigger.removeAttribute(DISABLED_MARKER);
|
|
490
466
|
}
|
|
491
|
-
/** The displayed element: the `value` target, else the controller element. */
|
|
492
467
|
get #display() {
|
|
493
468
|
return this.hasValueTarget ? this.valueTarget : this.element;
|
|
494
469
|
}
|
|
495
|
-
/** The current count, parsed from the DOM (the single source of truth). */
|
|
496
470
|
get #current() {
|
|
497
471
|
return authoredInteger(this.#display.textContent ?? "") ?? 0;
|
|
498
472
|
}
|
|
499
|
-
/**
|
|
500
|
-
* Writes a whole count — every caller has already established that it is one —
|
|
501
|
-
* and reports whether the display actually moved.
|
|
502
|
-
*/
|
|
503
473
|
#write(count) {
|
|
504
474
|
if (count === this.#current) return false;
|
|
505
475
|
this.#display.textContent = String(count);
|
|
506
476
|
this.dispatch("change", { detail: { count } });
|
|
507
477
|
return true;
|
|
508
478
|
}
|
|
509
|
-
/**
|
|
510
|
-
* Settles the display on a count the server stated, announcing what it settled
|
|
511
|
-
* on. Announcing the optimistic bump instead would read a guess out and then
|
|
512
|
-
* correct it, so the announcement rides this path only.
|
|
513
|
-
*/
|
|
514
479
|
#reconcile(count) {
|
|
515
480
|
if (this.#write(count)) this.#announce(count);
|
|
516
481
|
}
|
|
517
|
-
/**
|
|
518
|
-
* Hands a reconciled count to the page's shared announcer.
|
|
519
|
-
*
|
|
520
|
-
* Debounced: a burst of broadcasts is one announcement, not one per message,
|
|
521
|
-
* and only the last one is ever sent. Wording comes from the consumer and
|
|
522
|
-
* {@link announce} drops an empty message, so an undeclared template announces
|
|
523
|
-
* nothing at all — a count that ticks constantly should not interrupt a reader
|
|
524
|
-
* unless the page asked for it.
|
|
525
|
-
*/
|
|
526
482
|
#announce(count) {
|
|
527
483
|
this.#timers.clearAll();
|
|
528
484
|
this.#timers.set(() => {
|
|
@@ -531,16 +487,9 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
531
487
|
}
|
|
532
488
|
};
|
|
533
489
|
|
|
534
|
-
// src/utils/keyed_timers.ts
|
|
535
490
|
var KeyedTimers = class {
|
|
536
491
|
#timers = new SafeTimeout();
|
|
537
|
-
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
538
492
|
#pending = /* @__PURE__ */ new Map();
|
|
539
|
-
/**
|
|
540
|
-
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
541
|
-
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
542
|
-
* the key unarmed and may arm it again for the next round.
|
|
543
|
-
*/
|
|
544
493
|
set(key, callback, delay) {
|
|
545
494
|
this.clear(key);
|
|
546
495
|
const id = this.#timers.set(() => {
|
|
@@ -549,37 +498,80 @@ var KeyedTimers = class {
|
|
|
549
498
|
}, delay);
|
|
550
499
|
this.#pending.set(key, id);
|
|
551
500
|
}
|
|
552
|
-
/**
|
|
553
|
-
* Cancels `key`'s pending timer, if it has one.
|
|
554
|
-
*
|
|
555
|
-
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
556
|
-
* the registry ignores an id it does not own — the unarmed case needs no
|
|
557
|
-
* branch of its own, and no other key's timer can be reached from here.
|
|
558
|
-
*/
|
|
559
501
|
clear(key) {
|
|
560
502
|
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
561
503
|
this.#pending.delete(key);
|
|
562
504
|
}
|
|
563
|
-
/**
|
|
564
|
-
* Cancels every pending timer and forgets every key. Call this from a
|
|
565
|
-
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
566
|
-
*/
|
|
567
505
|
clearAll() {
|
|
568
506
|
this.#timers.clearAll();
|
|
569
507
|
this.#pending.clear();
|
|
570
508
|
}
|
|
571
|
-
/** Whether `key` has a timer pending. */
|
|
572
509
|
has(key) {
|
|
573
510
|
return this.#pending.has(key);
|
|
574
511
|
}
|
|
575
512
|
};
|
|
576
513
|
|
|
577
|
-
|
|
514
|
+
var NUMBER_BOUNDS = {
|
|
515
|
+
nonNegative: { finite: true, min: 0 },
|
|
516
|
+
timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS },
|
|
517
|
+
positiveTimer: { finite: true, exclusiveMin: 0, max: MAX_TIMER_DELAY_MS }
|
|
518
|
+
};
|
|
519
|
+
function matchesNumberBounds(value, bounds) {
|
|
520
|
+
if (!Number.isFinite(value)) {
|
|
521
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
522
|
+
if (direction === null) return false;
|
|
523
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
524
|
+
}
|
|
525
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
526
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
527
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
528
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
529
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function readNumber(raw, fallback, bounds) {
|
|
534
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
var NumberValueReader = class {
|
|
538
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
539
|
+
read(owner, name, raw, fallback, bounds) {
|
|
540
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
541
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
542
|
+
this.#lastRejected.delete(name);
|
|
543
|
+
return resolved;
|
|
544
|
+
}
|
|
545
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
546
|
+
const literal = owner.element.getAttribute(attribute);
|
|
547
|
+
if (literal === null) {
|
|
548
|
+
this.#lastRejected.delete(name);
|
|
549
|
+
return resolved;
|
|
550
|
+
}
|
|
551
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
552
|
+
this.#lastRejected.set(name, literal);
|
|
553
|
+
console.warn(
|
|
554
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return resolved;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
function cloneTemplateRoot(template) {
|
|
562
|
+
const root = template.content.firstElementChild;
|
|
563
|
+
return root instanceof HTMLElement ? root.cloneNode(true) : null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
var TRANSIENT2 = new TransientHooks({
|
|
567
|
+
attributes: ["data-present", "data-present-count", "data-presence-rejected"]
|
|
568
|
+
});
|
|
578
569
|
var BEACON_THROTTLE_MS = 2e3;
|
|
579
570
|
var DEFAULT_HEARTBEAT_MS = 15e3;
|
|
580
571
|
var DEFAULT_TIMEOUT_MS = 4e4;
|
|
581
|
-
var speakers = /* @__PURE__ */ new Map();
|
|
572
|
+
var speakers = sharedRegistry("stimeo-ui.presence.registry.v1", () => /* @__PURE__ */ new Map());
|
|
582
573
|
var PresenceController = class _PresenceController extends Controller {
|
|
574
|
+
#numbers = new NumberValueReader();
|
|
583
575
|
static targets = ["count", "list", "template"];
|
|
584
576
|
static values = {
|
|
585
577
|
channel: { type: String, default: "" },
|
|
@@ -591,170 +583,143 @@ var PresenceController = class _PresenceController extends Controller {
|
|
|
591
583
|
announceJoinText: { type: String, default: "" },
|
|
592
584
|
announceLeaveText: { type: String, default: "" }
|
|
593
585
|
};
|
|
586
|
+
static valueConstraints = {
|
|
587
|
+
heartbeat: NUMBER_BOUNDS.positiveTimer,
|
|
588
|
+
timeout: NUMBER_BOUNDS.positiveTimer
|
|
589
|
+
};
|
|
594
590
|
static events = ["join", "leave", "change"];
|
|
595
|
-
/** Delay (ms) before one roster change is sent to the shared announcer. */
|
|
596
591
|
static #announceDelay = 200;
|
|
597
|
-
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
598
|
-
#params = {};
|
|
599
592
|
#subscription = null;
|
|
600
|
-
|
|
593
|
+
#follow = new MicrotaskCoalescer(() => this.#followDeclaration());
|
|
594
|
+
#identifier = "";
|
|
595
|
+
#period = 0;
|
|
601
596
|
#peers = /* @__PURE__ */ new Map();
|
|
602
|
-
/** Each peer's auto-expiry timer, restarted by every beacon from that peer. */
|
|
603
597
|
#expiry = new KeyedTimers();
|
|
604
598
|
#timers = new SafeTimeout();
|
|
605
599
|
#intervals = new SafeInterval();
|
|
606
|
-
/** Epoch ms of the last outgoing beacon, for the convergence throttle. */
|
|
607
600
|
#lastBeaconAt = 0;
|
|
608
|
-
/** Pending trailing-edge convergence beacon (at most one queued). */
|
|
609
601
|
#pendingBeacon = null;
|
|
610
|
-
/** The one outstanding announcement, so a newer roster change supersedes it. */
|
|
611
602
|
#announceId = null;
|
|
612
|
-
|
|
603
|
+
#connected = false;
|
|
613
604
|
#speakerKey = "";
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
* naming the channel instead of the subscription never being created at all.
|
|
619
|
-
*/
|
|
605
|
+
#speakerId = "";
|
|
606
|
+
channelValueChanged() {
|
|
607
|
+
this.#follow.schedule();
|
|
608
|
+
}
|
|
620
609
|
paramsValueChanged() {
|
|
621
|
-
this.#
|
|
610
|
+
this.#follow.schedule();
|
|
611
|
+
}
|
|
612
|
+
idValueChanged() {
|
|
613
|
+
this.#follow.schedule();
|
|
614
|
+
}
|
|
615
|
+
heartbeatValueChanged() {
|
|
616
|
+
this.#follow.schedule();
|
|
622
617
|
}
|
|
623
|
-
/**
|
|
624
|
-
* The identifier this declaration names. `params` *adds* to the identifier, so a
|
|
625
|
-
* `channel` key inside it names an extra parameter and never replaces the channel
|
|
626
|
-
* the element declares.
|
|
627
|
-
*/
|
|
628
618
|
get #descriptor() {
|
|
629
|
-
const { channel: _declared, ...rest } = this
|
|
619
|
+
const { channel: _declared, ...rest } = parseSubscriptionParams(this.paramsValue);
|
|
630
620
|
return { channel: this.channelValue, ...rest };
|
|
631
621
|
}
|
|
632
|
-
/**
|
|
633
|
-
* The beacon period, in ms: a finite, positive number a timer can hold. Anything
|
|
634
|
-
* else names no interval — `setInterval` reads `NaN`, a negative value, `Infinity`
|
|
635
|
-
* and a value past {@link MAX_TIMER_DELAY_MS} alike as "as often as possible", so
|
|
636
|
-
* the declaration would flood the channel rather than heartbeat on it. Zero is out
|
|
637
|
-
* for the same reason, which is why this bound is tighter than the non-negative one
|
|
638
|
-
* a throttle or a plain delay can use. Such a declaration falls back to the default.
|
|
639
|
-
*/
|
|
640
622
|
get #heartbeat() {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
/**
|
|
645
|
-
* The silence after which a peer is dropped, in ms: a finite, positive number a
|
|
646
|
-
* timer can hold. Anything else names no delay and every peer expires in the task
|
|
647
|
-
* that records it, so the roster could never hold anyone. Such a declaration falls
|
|
648
|
-
* back to the default.
|
|
649
|
-
*/
|
|
623
|
+
return this.#safeHeartbeat;
|
|
624
|
+
}
|
|
650
625
|
get #timeout() {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
/**
|
|
655
|
-
* Draws the roster into a list target, which arrives empty from a Turbo Stream
|
|
656
|
-
* replacement or a morph. Stimulus runs this before `connect()` on the first mount
|
|
657
|
-
* too, where the roster is still empty and clearing whatever a cache snapshot
|
|
658
|
-
* preserved is what `connect()` goes on to do anyway.
|
|
659
|
-
*/
|
|
626
|
+
return this.#safeTimeout;
|
|
627
|
+
}
|
|
660
628
|
listTargetConnected() {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
629
|
+
this.#drawList();
|
|
630
|
+
}
|
|
631
|
+
listTargetDisconnected() {
|
|
632
|
+
if (this.#connected) this.#drawList();
|
|
665
633
|
}
|
|
666
|
-
/** Paints the roster size into a count target that arrives blank, for the same reason. */
|
|
667
634
|
countTargetConnected() {
|
|
668
|
-
this
|
|
635
|
+
this.#drawCount();
|
|
636
|
+
}
|
|
637
|
+
countTargetDisconnected() {
|
|
638
|
+
if (this.#connected) this.#drawCount();
|
|
669
639
|
}
|
|
670
640
|
connect() {
|
|
641
|
+
this.#connected = true;
|
|
642
|
+
this.#follow.activate();
|
|
643
|
+
this.#start();
|
|
644
|
+
}
|
|
645
|
+
disconnect() {
|
|
646
|
+
this.#connected = false;
|
|
647
|
+
this.#follow.cancel();
|
|
648
|
+
this.#close();
|
|
649
|
+
this.#reset();
|
|
650
|
+
}
|
|
651
|
+
#start() {
|
|
671
652
|
this.#reset();
|
|
672
|
-
this.element.removeAttribute("data-presence-rejected");
|
|
673
653
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
|
|
654
|
+
this.#open();
|
|
655
|
+
}
|
|
656
|
+
#open() {
|
|
657
|
+
const descriptor = this.#descriptor;
|
|
658
|
+
this.#identifier = identifierOf(descriptor);
|
|
674
659
|
if (!this.channelValue) return;
|
|
675
660
|
this.#claimSpeaker();
|
|
676
|
-
this.#subscription = createConfirmedSubscription(
|
|
677
|
-
// The first beacon must wait for the confirmed subscription — a
|
|
678
|
-
// perform() before that is silently dropped by Action Cable. Fires
|
|
679
|
-
// again on every reconnect, so the roster self-heals after an outage.
|
|
661
|
+
this.#subscription = createConfirmedSubscription(descriptor, {
|
|
680
662
|
connected: () => this.#beacon(true),
|
|
681
|
-
// The server refused the subscription: no beacon will ever go through,
|
|
682
|
-
// and the hook lets the consumer's CSS reflect the dead stream.
|
|
683
663
|
rejected: () => {
|
|
684
664
|
this.element.setAttribute("data-presence-rejected", "true");
|
|
685
665
|
},
|
|
686
666
|
received: (data) => this.#onReceived(data)
|
|
687
667
|
});
|
|
688
|
-
this.#
|
|
668
|
+
this.#armHeartbeat();
|
|
689
669
|
window.addEventListener("pagehide", this.#onPageHide);
|
|
690
670
|
}
|
|
691
|
-
|
|
671
|
+
#close() {
|
|
692
672
|
window.removeEventListener("pagehide", this.#onPageHide);
|
|
693
|
-
|
|
673
|
+
this.#sendLeaveNotice(this.#releaseSpeaker());
|
|
694
674
|
this.#subscription?.unsubscribe();
|
|
695
675
|
this.#subscription = null;
|
|
696
676
|
this.#intervals.clearAll();
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
677
|
+
}
|
|
678
|
+
#armHeartbeat() {
|
|
679
|
+
this.#intervals.clearAll();
|
|
680
|
+
this.#period = this.#heartbeat;
|
|
681
|
+
this.#intervals.set(() => this.#beacon(true), this.#period);
|
|
682
|
+
}
|
|
683
|
+
#followDeclaration() {
|
|
684
|
+
if (identifierOf(this.#descriptor) !== this.#identifier) {
|
|
685
|
+
this.#close();
|
|
686
|
+
this.#start();
|
|
687
|
+
} else if (this.#subscription) {
|
|
688
|
+
if (this.idValue !== this.#speakerId) this.#moveVoice();
|
|
689
|
+
if (this.#heartbeat !== this.#period) this.#armHeartbeat();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
#moveVoice() {
|
|
693
|
+
this.#sendLeaveNotice(this.#releaseSpeaker());
|
|
694
|
+
this.#claimSpeaker();
|
|
695
|
+
this.#forget(this.#speakerId);
|
|
696
|
+
this.#timers.clear(this.#pendingBeacon ?? -1);
|
|
697
|
+
this.#pendingBeacon = null;
|
|
698
|
+
this.#beacon(true);
|
|
699
|
+
}
|
|
707
700
|
#claimSpeaker() {
|
|
708
|
-
this.#
|
|
709
|
-
|
|
701
|
+
this.#speakerId = this.idValue;
|
|
702
|
+
this.#speakerKey = `${this.#identifier}
|
|
703
|
+
${this.#speakerId}`;
|
|
710
704
|
speakers.set(this.#speakerKey, (speakers.get(this.#speakerKey) ?? 0) + 1);
|
|
711
705
|
}
|
|
712
|
-
/**
|
|
713
|
-
* Gives up the claim, reporting whether this element was the last voice for the
|
|
714
|
-
* peer. The key is spent here: an element that goes on to connect without a
|
|
715
|
-
* channel claims nothing, and a stale key would spend a voice its siblings own.
|
|
716
|
-
*/
|
|
717
706
|
#releaseSpeaker() {
|
|
718
707
|
const key = this.#speakerKey;
|
|
708
|
+
const id = this.#speakerId;
|
|
719
709
|
this.#speakerKey = "";
|
|
710
|
+
this.#speakerId = "";
|
|
720
711
|
const remaining = (speakers.get(key) ?? 1) - 1;
|
|
721
712
|
if (remaining > 0) speakers.set(key, remaining);
|
|
722
713
|
else speakers.delete(key);
|
|
723
|
-
return remaining
|
|
714
|
+
return remaining > 0 ? "" : id;
|
|
715
|
+
}
|
|
716
|
+
#sendLeaveNotice(id) {
|
|
717
|
+
if (!this.#subscription?.confirmed || !id) return;
|
|
718
|
+
this.#subscription.perform("appear", { id, leaving: true });
|
|
724
719
|
}
|
|
725
|
-
/**
|
|
726
|
-
* Sends the best-effort leaving notice (skipped without an own `id`, and
|
|
727
|
-
* outside the confirmed window, where Action Cable would discard it anyway).
|
|
728
|
-
*/
|
|
729
|
-
#sendLeaveNotice() {
|
|
730
|
-
if (!this.#subscription?.confirmed || !this.idValue) return;
|
|
731
|
-
this.#subscription.perform("appear", { id: this.idValue, leaving: true });
|
|
732
|
-
}
|
|
733
|
-
/**
|
|
734
|
-
* `pagehide` covers the leaves `disconnect()` cannot see: closing the tab or
|
|
735
|
-
* a hard (non-Turbo) navigation destroys the page without running Stimulus
|
|
736
|
-
* teardown, so this listener is the only chance to announce them. Best-effort
|
|
737
|
-
* by nature (the socket may close before the frame flushes); peers' expiry
|
|
738
|
-
* timers stay the safety net. If the page enters the bfcache and is restored
|
|
739
|
-
* instead, the next heartbeat re-announces this client, so an over-eager
|
|
740
|
-
* leave self-heals.
|
|
741
|
-
*/
|
|
742
720
|
#onPageHide = () => {
|
|
743
|
-
this.#sendLeaveNotice();
|
|
721
|
+
this.#sendLeaveNotice(this.#speakerId);
|
|
744
722
|
};
|
|
745
|
-
/**
|
|
746
|
-
* Broadcasts this client's beacon (skipped without an own `id`). A throttled
|
|
747
|
-
* convergence answer is deferred to the trailing edge rather than dropped —
|
|
748
|
-
* otherwise a peer joining right after a heartbeat would not learn about
|
|
749
|
-
* this client until the next full heartbeat period.
|
|
750
|
-
*
|
|
751
|
-
* Gated on the confirmed subscription: before confirmation and during an
|
|
752
|
-
* outage Action Cable silently discards perform(), so a beacon sent then is
|
|
753
|
-
* pure waste — worse, it would burn `#lastBeaconAt` and delay the next real
|
|
754
|
-
* convergence answer by up to the throttle window. The heartbeat interval
|
|
755
|
-
* keeps ticking regardless; `connected` re-fires on reconfirm and
|
|
756
|
-
* force-beacons immediately, so a gated tick is never missed for long.
|
|
757
|
-
*/
|
|
758
723
|
#beacon(force) {
|
|
759
724
|
if (!this.#subscription?.confirmed || !this.idValue) return;
|
|
760
725
|
const now = Date.now();
|
|
@@ -771,11 +736,6 @@ ${this.idValue}`;
|
|
|
771
736
|
this.#lastBeaconAt = now;
|
|
772
737
|
this.#subscription.perform("appear", { id: this.idValue, name: this.nameValue });
|
|
773
738
|
}
|
|
774
|
-
/**
|
|
775
|
-
* Tracks a broadcast beacon: upserts the peer (restarting its expiry timer),
|
|
776
|
-
* removes it on a `leaving` notice, and re-announces this client when the
|
|
777
|
-
* peer was unknown (roster convergence — see the class doc).
|
|
778
|
-
*/
|
|
779
739
|
#onReceived(data) {
|
|
780
740
|
const beacon = data;
|
|
781
741
|
const id = beacon?.id;
|
|
@@ -799,7 +759,6 @@ ${this.idValue}`;
|
|
|
799
759
|
this.#render();
|
|
800
760
|
}
|
|
801
761
|
}
|
|
802
|
-
/** Removes a peer (expiry or graceful leave) and reflects the change. */
|
|
803
762
|
#drop(id) {
|
|
804
763
|
const peer = this.#peers.get(id);
|
|
805
764
|
if (peer === void 0) return;
|
|
@@ -810,23 +769,22 @@ ${this.idValue}`;
|
|
|
810
769
|
this.#announce(this.announceLeaveTextValue, peer.name);
|
|
811
770
|
this.dispatch("leave", { detail: { id } });
|
|
812
771
|
}
|
|
813
|
-
|
|
772
|
+
#forget(id) {
|
|
773
|
+
if (!this.#peers.delete(id)) return;
|
|
774
|
+
this.#expiry.clear(id);
|
|
775
|
+
this.#removeClone(id);
|
|
776
|
+
this.#paint();
|
|
777
|
+
}
|
|
814
778
|
#render() {
|
|
779
|
+
this.dispatch("change", { detail: { users: this.#paint() } });
|
|
780
|
+
}
|
|
781
|
+
#paint() {
|
|
815
782
|
const users = [...this.#peers.entries()].map(([id, peer]) => ({ id, name: peer.name }));
|
|
816
783
|
this.element.setAttribute("data-present", users.length > 0 ? "true" : "false");
|
|
817
784
|
this.element.setAttribute("data-present-count", String(users.length));
|
|
818
785
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);
|
|
819
|
-
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* Hands one roster change to the page's shared announcer.
|
|
823
|
-
*
|
|
824
|
-
* Debounced: a burst of arrivals is one announcement, not one per peer — reading
|
|
825
|
-
* out every entry and exit of a busy room is the noise the default avoids. Wording
|
|
826
|
-
* comes from the consumer (`{name}` / `{count}`, where the count is the roster
|
|
827
|
-
* after the change), and an undeclared template does nothing at all — not even
|
|
828
|
-
* cancel an announcement already pending for the other direction.
|
|
829
|
-
*/
|
|
786
|
+
return users;
|
|
787
|
+
}
|
|
830
788
|
#announce(template, name) {
|
|
831
789
|
const message = fillTemplate(template, { name, count: this.#peers.size });
|
|
832
790
|
if (message.trim() === "") return;
|
|
@@ -836,21 +794,25 @@ ${this.idValue}`;
|
|
|
836
794
|
this.#announceId = null;
|
|
837
795
|
}, _PresenceController.#announceDelay);
|
|
838
796
|
}
|
|
839
|
-
/**
|
|
840
|
-
* Builds the count copy. Localizable through `data-zero` / `data-one` /
|
|
841
|
-
* `data-other` templates on the count target (`%{count}`); the bare number is
|
|
842
|
-
* the fallback (copy-free, so nothing to localize by default).
|
|
843
|
-
*/
|
|
844
797
|
#countMessage(count) {
|
|
845
798
|
const templates = this.countTarget.dataset;
|
|
846
799
|
const template = (count === 0 ? templates.zero : count === 1 ? templates.one : templates.other) ?? templates.other;
|
|
847
800
|
return template ? template.replace("%{count}", String(count)) : String(count);
|
|
848
801
|
}
|
|
849
|
-
|
|
802
|
+
#drawList() {
|
|
803
|
+
if (!this.hasListTarget) return;
|
|
804
|
+
for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
|
|
805
|
+
child.remove();
|
|
806
|
+
}
|
|
807
|
+
for (const [id, peer] of this.#peers) this.#appendClone(id, peer.name);
|
|
808
|
+
}
|
|
809
|
+
#drawCount() {
|
|
810
|
+
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(this.#peers.size);
|
|
811
|
+
}
|
|
850
812
|
#appendClone(id, name) {
|
|
851
813
|
if (!this.hasListTarget || !this.hasTemplateTarget) return;
|
|
852
|
-
const root = this.templateTarget
|
|
853
|
-
if (!
|
|
814
|
+
const root = cloneTemplateRoot(this.templateTarget);
|
|
815
|
+
if (!root) return;
|
|
854
816
|
root.setAttribute("data-presence-id", id);
|
|
855
817
|
this.#fillName(root, name);
|
|
856
818
|
this.listTarget.appendChild(root);
|
|
@@ -869,7 +831,6 @@ ${this.idValue}`;
|
|
|
869
831
|
}
|
|
870
832
|
return null;
|
|
871
833
|
}
|
|
872
|
-
/** Writes the peer's name into the clone's `data-presence-name` slots. */
|
|
873
834
|
#fillName(root, name) {
|
|
874
835
|
const slots = root.querySelectorAll("[data-presence-name]");
|
|
875
836
|
for (const slot of slots) slot.textContent = name;
|
|
@@ -877,7 +838,6 @@ ${this.idValue}`;
|
|
|
877
838
|
root.textContent = name;
|
|
878
839
|
}
|
|
879
840
|
}
|
|
880
|
-
/** Clears the transient roster state (connect reset + disconnect teardown). */
|
|
881
841
|
#reset() {
|
|
882
842
|
this.#timers.clearAll();
|
|
883
843
|
this.#expiry.clearAll();
|
|
@@ -885,8 +845,7 @@ ${this.idValue}`;
|
|
|
885
845
|
this.#announceId = null;
|
|
886
846
|
this.#peers.clear();
|
|
887
847
|
this.#lastBeaconAt = 0;
|
|
888
|
-
this.element
|
|
889
|
-
this.element.removeAttribute("data-present-count");
|
|
848
|
+
TRANSIENT2.reset(this.element);
|
|
890
849
|
if (this.hasCountTarget) this.countTarget.textContent = "";
|
|
891
850
|
if (this.hasListTarget) {
|
|
892
851
|
for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
|
|
@@ -894,92 +853,129 @@ ${this.idValue}`;
|
|
|
894
853
|
}
|
|
895
854
|
}
|
|
896
855
|
}
|
|
856
|
+
get #safeHeartbeat() {
|
|
857
|
+
return this.#numbers.read(
|
|
858
|
+
this,
|
|
859
|
+
"heartbeat",
|
|
860
|
+
this.heartbeatValue,
|
|
861
|
+
_PresenceController.values.heartbeat.default,
|
|
862
|
+
_PresenceController.valueConstraints.heartbeat
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
get #safeTimeout() {
|
|
866
|
+
return this.#numbers.read(
|
|
867
|
+
this,
|
|
868
|
+
"timeout",
|
|
869
|
+
this.timeoutValue,
|
|
870
|
+
_PresenceController.values.timeout.default,
|
|
871
|
+
_PresenceController.valueConstraints.timeout
|
|
872
|
+
);
|
|
873
|
+
}
|
|
897
874
|
};
|
|
875
|
+
var TRANSIENT3 = new TransientHooks({
|
|
876
|
+
attributes: ["data-typing", "data-typing-indicator-rejected"]
|
|
877
|
+
});
|
|
898
878
|
var DEFAULT_TIMEOUT = 3e3;
|
|
899
879
|
var DEFAULT_THROTTLE = 2e3;
|
|
900
880
|
var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
|
|
901
881
|
var TypingIndicatorController = class _TypingIndicatorController extends Controller {
|
|
882
|
+
#numbers = new NumberValueReader();
|
|
902
883
|
static targets = ["input", "status"];
|
|
903
884
|
static values = {
|
|
904
885
|
channel: { type: String, default: "" },
|
|
905
886
|
params: { type: String, default: "" },
|
|
906
887
|
name: { type: String, default: "" },
|
|
907
888
|
timeout: { type: Number, default: DEFAULT_TIMEOUT },
|
|
908
|
-
// Must stay below `timeout`: the throttle is leading-edge only (no trailing
|
|
909
|
-
// send), so a receiver's display survives continuous typing only while a
|
|
910
|
-
// fresh signal lands within its timeout window.
|
|
911
889
|
throttle: { type: Number, default: DEFAULT_THROTTLE },
|
|
912
890
|
announceOneText: { type: String, default: "" },
|
|
913
891
|
announceManyText: { type: String, default: "" }
|
|
914
892
|
};
|
|
893
|
+
static valueConstraints = {
|
|
894
|
+
timeout: NUMBER_BOUNDS.timer,
|
|
895
|
+
throttle: NUMBER_BOUNDS.nonNegative
|
|
896
|
+
};
|
|
915
897
|
static events = ["change"];
|
|
916
|
-
/** Delay (ms) before one settled typer set is sent to the shared announcer. */
|
|
917
898
|
static #announceDelay = 200;
|
|
918
|
-
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
919
|
-
#params = {};
|
|
920
899
|
#subscription = null;
|
|
921
|
-
|
|
900
|
+
#follow = new MicrotaskCoalescer(() => this.#followDeclaration());
|
|
901
|
+
#identifier = "";
|
|
922
902
|
#typers = /* @__PURE__ */ new Set();
|
|
923
|
-
/** Each typer's auto-clear timer, restarted by every further signal from that name. */
|
|
924
903
|
#expiry = new KeyedTimers();
|
|
925
904
|
#timers = new SafeTimeout();
|
|
926
|
-
/** Epoch ms of the last broadcast, for leading-edge throttling. */
|
|
927
905
|
#lastSentAt = 0;
|
|
928
|
-
/** The one outstanding announcement, so a newer set supersedes it. */
|
|
929
906
|
#announceId = null;
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
* naming the channel instead of the subscription never being created at all.
|
|
935
|
-
*/
|
|
907
|
+
#connected = false;
|
|
908
|
+
channelValueChanged() {
|
|
909
|
+
this.#follow.schedule();
|
|
910
|
+
}
|
|
936
911
|
paramsValueChanged() {
|
|
937
|
-
this.#
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
*/
|
|
912
|
+
this.#follow.schedule();
|
|
913
|
+
}
|
|
914
|
+
nameValueChanged() {
|
|
915
|
+
this.#follow.schedule();
|
|
916
|
+
}
|
|
917
|
+
get #descriptor() {
|
|
918
|
+
const { channel: _declared, ...rest } = parseSubscriptionParams(this.paramsValue);
|
|
919
|
+
return { channel: this.channelValue, ...rest };
|
|
920
|
+
}
|
|
947
921
|
statusTargetConnected() {
|
|
948
|
-
if (this.#
|
|
922
|
+
if (this.#connected) this.#paintStatus([...this.#typers]);
|
|
923
|
+
}
|
|
924
|
+
statusTargetDisconnected() {
|
|
925
|
+
if (this.#connected) this.#paintStatus([...this.#typers]);
|
|
949
926
|
}
|
|
950
927
|
connect() {
|
|
951
|
-
this
|
|
952
|
-
this
|
|
953
|
-
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
928
|
+
this.#connected = true;
|
|
929
|
+
this.#reset();
|
|
954
930
|
this.element.addEventListener("input", this.#onInput);
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
{ channel: this.channelValue, ...this.#params },
|
|
958
|
-
{
|
|
959
|
-
// The server refused the subscription: the send gate stays shut for
|
|
960
|
-
// good, and the hook lets the consumer's CSS reflect the dead stream.
|
|
961
|
-
rejected: () => {
|
|
962
|
-
this.element.setAttribute("data-typing-indicator-rejected", "true");
|
|
963
|
-
},
|
|
964
|
-
received: (data) => this.#onReceived(data)
|
|
965
|
-
}
|
|
966
|
-
);
|
|
967
|
-
}
|
|
931
|
+
this.#follow.activate();
|
|
932
|
+
this.#open();
|
|
968
933
|
}
|
|
969
934
|
disconnect() {
|
|
935
|
+
this.#connected = false;
|
|
936
|
+
this.#follow.cancel();
|
|
970
937
|
this.element.removeEventListener("input", this.#onInput);
|
|
938
|
+
this.#close();
|
|
939
|
+
this.#reset();
|
|
940
|
+
}
|
|
941
|
+
#open() {
|
|
942
|
+
const descriptor = this.#descriptor;
|
|
943
|
+
this.#identifier = identifierOf(descriptor);
|
|
944
|
+
if (!this.channelValue) return;
|
|
945
|
+
this.#subscription = createConfirmedSubscription(descriptor, {
|
|
946
|
+
rejected: () => {
|
|
947
|
+
this.element.setAttribute("data-typing-indicator-rejected", "true");
|
|
948
|
+
},
|
|
949
|
+
received: (data) => this.#onReceived(data)
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
#close() {
|
|
971
953
|
this.#subscription?.unsubscribe();
|
|
972
954
|
this.#subscription = null;
|
|
955
|
+
}
|
|
956
|
+
#followDeclaration() {
|
|
957
|
+
if (identifierOf(this.#descriptor) !== this.#identifier) {
|
|
958
|
+
this.#close();
|
|
959
|
+
this.#reset();
|
|
960
|
+
this.#open();
|
|
961
|
+
} else {
|
|
962
|
+
this.#forget(this.nameValue);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
#forget(name) {
|
|
966
|
+
if (!this.#typers.delete(name)) return;
|
|
967
|
+
this.#expiry.clear(name);
|
|
968
|
+
this.#paint();
|
|
969
|
+
}
|
|
970
|
+
#reset() {
|
|
973
971
|
this.#timers.clearAll();
|
|
974
972
|
this.#expiry.clearAll();
|
|
975
973
|
this.#announceId = null;
|
|
976
974
|
this.#typers.clear();
|
|
977
|
-
this.element
|
|
978
|
-
this.element.removeAttribute("data-typing-indicator-rejected");
|
|
975
|
+
TRANSIENT3.reset(this.element);
|
|
979
976
|
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
980
977
|
this.#lastSentAt = 0;
|
|
981
978
|
}
|
|
982
|
-
/** Throttled (leading-edge) broadcast of this client's typing signal. */
|
|
983
979
|
#onInput = () => {
|
|
984
980
|
if (!this.#subscription?.confirmed) return;
|
|
985
981
|
const now = Date.now();
|
|
@@ -987,11 +983,6 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
987
983
|
this.#lastSentAt = now;
|
|
988
984
|
this.#subscription.perform("typing", { name: this.nameValue });
|
|
989
985
|
};
|
|
990
|
-
/**
|
|
991
|
-
* Tracks a broadcast typer. The own echo is dropped (same `name`); every
|
|
992
|
-
* further signal from a name restarts its auto-clear timer, so the indicator
|
|
993
|
-
* survives continuous typing and clears `timeout` ms after the last signal.
|
|
994
|
-
*/
|
|
995
986
|
#onReceived(data) {
|
|
996
987
|
const name = data?.name;
|
|
997
988
|
if (typeof name !== "string" || name === "" || name === this.nameValue) return;
|
|
@@ -1000,48 +991,21 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1000
991
|
this.#expiry.set(name, () => this.#untrack(name), this.#timeout);
|
|
1001
992
|
if (added) this.#render();
|
|
1002
993
|
}
|
|
1003
|
-
/**
|
|
1004
|
-
* The silence after which a typer is dropped, in ms: a finite, non-negative number
|
|
1005
|
-
* a timer can hold. Anything else names no delay — `setTimeout` reads `NaN`, a
|
|
1006
|
-
* negative value and `Infinity` alike as "now", and a value past
|
|
1007
|
-
* {@link MAX_TIMER_DELAY_MS} overflows to the same place — so the typer would
|
|
1008
|
-
* vanish in the same task it appeared and the indicator could never be seen. Such
|
|
1009
|
-
* a declaration falls back to the default.
|
|
1010
|
-
*/
|
|
1011
994
|
get #timeout() {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
}
|
|
1015
|
-
/**
|
|
1016
|
-
* The minimum gap between outgoing signals, in ms: a finite, non-negative number.
|
|
1017
|
-
* Anything else names no interval, and the gate then settles the same way at every
|
|
1018
|
-
* keystroke — `NaN` and a negative gap leave it open, so every keystroke broadcasts,
|
|
1019
|
-
* while `Infinity` is never exceeded, so nothing is ever sent. Such a declaration
|
|
1020
|
-
* falls back to the default.
|
|
1021
|
-
*/
|
|
995
|
+
return this.#safeTimeout;
|
|
996
|
+
}
|
|
1022
997
|
get #throttle() {
|
|
1023
|
-
|
|
1024
|
-
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THROTTLE;
|
|
998
|
+
return this.#safeThrottle;
|
|
1025
999
|
}
|
|
1026
1000
|
#untrack(name) {
|
|
1027
1001
|
this.#typers.delete(name);
|
|
1028
1002
|
this.#render();
|
|
1029
1003
|
}
|
|
1030
|
-
/** Reflects the typer set onto the display, announces it, and emits `change`. */
|
|
1031
1004
|
#render() {
|
|
1032
1005
|
const names = this.#paint();
|
|
1033
1006
|
this.#announce(names);
|
|
1034
1007
|
this.dispatch("change", { detail: { names } });
|
|
1035
1008
|
}
|
|
1036
|
-
/**
|
|
1037
|
-
* Hands the settled typer set to the page's shared announcer.
|
|
1038
|
-
*
|
|
1039
|
-
* Debounced: a burst of arrivals is one announcement, not one per name. Only a
|
|
1040
|
-
* non-empty set is announced — that typing stopped is not news worth interrupting
|
|
1041
|
-
* a reader for, and the visible copy already clears. Wording comes from the
|
|
1042
|
-
* consumer, and {@link announce} drops an empty message, so an undeclared template
|
|
1043
|
-
* announces nothing at all.
|
|
1044
|
-
*/
|
|
1045
1009
|
#announce(names) {
|
|
1046
1010
|
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
1047
1011
|
this.#announceId = null;
|
|
@@ -1055,20 +1019,15 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1055
1019
|
this.#announceId = null;
|
|
1056
1020
|
}, _TypingIndicatorController.#announceDelay);
|
|
1057
1021
|
}
|
|
1058
|
-
/** Writes the current typer set onto the hook and the status slot. */
|
|
1059
1022
|
#paint() {
|
|
1060
1023
|
const names = [...this.#typers];
|
|
1061
1024
|
this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
|
|
1062
|
-
|
|
1063
|
-
this.statusTarget.textContent = this.#message(names);
|
|
1064
|
-
}
|
|
1025
|
+
this.#paintStatus(names);
|
|
1065
1026
|
return names;
|
|
1066
1027
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
* English is the fallback.
|
|
1071
|
-
*/
|
|
1028
|
+
#paintStatus(names) {
|
|
1029
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = this.#message(names);
|
|
1030
|
+
}
|
|
1072
1031
|
#message(names) {
|
|
1073
1032
|
if (names.length === 0) return "";
|
|
1074
1033
|
const joined = names.join(", ");
|
|
@@ -1080,9 +1039,26 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1080
1039
|
const template = this.statusTarget.dataset.many;
|
|
1081
1040
|
return template ? fillTokens(template, { names: joined, count: String(names.length) }) : `${joined} are typing\u2026`;
|
|
1082
1041
|
}
|
|
1042
|
+
get #safeTimeout() {
|
|
1043
|
+
return this.#numbers.read(
|
|
1044
|
+
this,
|
|
1045
|
+
"timeout",
|
|
1046
|
+
this.timeoutValue,
|
|
1047
|
+
_TypingIndicatorController.values.timeout.default,
|
|
1048
|
+
_TypingIndicatorController.valueConstraints.timeout
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
get #safeThrottle() {
|
|
1052
|
+
return this.#numbers.read(
|
|
1053
|
+
this,
|
|
1054
|
+
"throttle",
|
|
1055
|
+
this.throttleValue,
|
|
1056
|
+
_TypingIndicatorController.values.throttle.default,
|
|
1057
|
+
_TypingIndicatorController.valueConstraints.throttle
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1083
1060
|
};
|
|
1084
1061
|
|
|
1085
|
-
// src/cable/index.ts
|
|
1086
1062
|
var cableControllers = {
|
|
1087
1063
|
"stimeo--live-counter": LiveCounterController,
|
|
1088
1064
|
"stimeo--presence": PresenceController,
|
|
@@ -1095,5 +1071,4 @@ function registerCable(application) {
|
|
|
1095
1071
|
}
|
|
1096
1072
|
|
|
1097
1073
|
export { LiveCounterController, PresenceController, TypingIndicatorController, cableControllers, createConfirmedSubscription, getCableConsumer, registerCable, setCableConsumer };
|
|
1098
|
-
//# sourceMappingURL=index.js.map
|
|
1099
1074
|
//# sourceMappingURL=index.js.map
|