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