stimeo-ui 0.2.1 → 0.4.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 +160 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.js +32 -5
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +32 -6
- package/dist/controllers/announcer_controller.js +255 -20
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +97 -10
- package/dist/controllers/breadcrumb_controller.js +225 -13
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +11 -1
- package/dist/controllers/calendar_controller.js +89 -22
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +16 -1
- package/dist/controllers/carousel_controller.js +47 -6
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.js +2 -2
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +14 -2
- package/dist/controllers/color_picker_controller.js +92 -7
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +14 -1
- package/dist/controllers/combobox_controller.js +162 -23
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +226 -22
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +32 -5
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +7 -6
- package/dist/controllers/context_menu_controller.js +32 -10
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +19 -5
- package/dist/controllers/countdown_controller.js +112 -12
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +19 -1
- package/dist/controllers/data_grid_controller.js +82 -4
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +5 -1
- package/dist/controllers/date_range_picker_controller.js +77 -3
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +32 -5
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +32 -5
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +1 -0
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +9 -4
- package/dist/controllers/empty_state_controller.js +24 -10
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js +32 -5
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +14 -3
- package/dist/controllers/frame_loading_controller.js +177 -15
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +36 -11
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +31 -10
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +9 -2
- package/dist/controllers/listbox_controller.js +257 -53
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +19 -3
- package/dist/controllers/local_time_controller.js +102 -8
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +29 -11
- package/dist/controllers/menu_controller.js +104 -17
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +91 -11
- package/dist/controllers/menubar_controller.js +415 -63
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +22 -2
- package/dist/controllers/meter_controller.js +145 -26
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +31 -5
- package/dist/controllers/multi_select_controller.js +312 -29
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +65 -18
- package/dist/controllers/navigation_menu_controller.js +154 -27
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +13 -1
- package/dist/controllers/network_status_controller.js +28 -8
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +1 -1
- package/dist/controllers/number_input_controller.js +7 -0
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +18 -1
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +3 -2
- package/dist/controllers/overflow_indicator_controller.js +81 -13
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +78 -19
- package/dist/controllers/overflow_menu_controller.js +408 -57
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +36 -5
- package/dist/controllers/pagination_controller.js +163 -32
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/persist_controller.js +6 -6
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +2 -3
- package/dist/controllers/pointer_drag_controller.js +9 -1
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -2
- package/dist/controllers/popover_controller.js +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +4 -4
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +16 -1
- package/dist/controllers/progress_controller.js +116 -9
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +22 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +18 -5
- package/dist/controllers/range_slider_controller.js +100 -9
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +4 -1
- package/dist/controllers/rating_controller.js +69 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.js +63 -19
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +13 -0
- package/dist/controllers/relative_time_controller.js +133 -12
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +3 -1
- package/dist/controllers/resizable_controller.js +65 -1
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +17 -2
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +86 -12
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +1 -1
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +33 -3
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +64 -15
- package/dist/controllers/scrollspy_controller.js +346 -73
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +20 -5
- package/dist/controllers/separator_controller.js +9 -0
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +2 -12
- package/dist/controllers/sidebar_controller.js +37 -8
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +7 -2
- package/dist/controllers/skeleton_controller.js +73 -20
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +49 -8
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +3 -1
- package/dist/controllers/sortable_controller.js +34 -3
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +37 -4
- package/dist/controllers/spinner_controller.js +228 -27
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +12 -1
- package/dist/controllers/step_indicator_controller.js +82 -5
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +31 -8
- package/dist/controllers/stick_to_bottom_controller.js +61 -10
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +32 -11
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +2 -2
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js +1 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +11 -3
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.js +22 -2
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +22 -3
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +20 -1
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +4 -5
- package/dist/controllers/toast_controller.js +4 -5
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +23 -2
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +53 -13
- package/dist/controllers/toolbar_controller.js +230 -31
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +87 -6
- package/dist/controllers/tree_view_controller.js +467 -51
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +4507 -907
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +1009 -86
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +1200 -120
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +28 -28
- package/dist/inspector/manifest.json +1060 -132
- package/package.json +5 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,163 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-08-11
|
|
11
|
+
|
|
12
|
+
The feedback and status components announce through the shared
|
|
13
|
+
`stimeo--announcer` and survive Turbo caching and morphs. The Inspector reads the
|
|
14
|
+
`data:` hashes Rails helpers render. Two public API entries were removed.
|
|
15
|
+
|
|
16
|
+
### Removed
|
|
17
|
+
|
|
18
|
+
- **Breaking** — sidebar: the `beforeCache` action. Delete
|
|
19
|
+
`data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"`; the
|
|
20
|
+
behavior is built in, and leaving it makes `stimeo check` report an unknown
|
|
21
|
+
action.
|
|
22
|
+
- **Breaking** — empty-state: the `announce` boolean, and the `role="status"` /
|
|
23
|
+
`aria-live="polite"` it applied to the `empty` target. Use `announceText` /
|
|
24
|
+
`announceFilledText` instead.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- countdown, empty-state, frame-loading, meter, network-status, progress,
|
|
29
|
+
skeleton, spinner: `announceText`, plus `announceFilledText` /
|
|
30
|
+
`announceOnlineText` / `announceReadyText` where a component has two
|
|
31
|
+
transitions to report. Wording is yours and defaults to empty, and the page
|
|
32
|
+
needs a `stimeo--announcer` for any of it to be read.
|
|
33
|
+
- spinner: `timeout`, with a `stimeo--spinner:timeout` event. Off by default.
|
|
34
|
+
- stick-to-bottom: `pinOnConnect`. Off by default.
|
|
35
|
+
- Inspector: Stimulus wiring in a Rails helper's literal `data:` hash is checked
|
|
36
|
+
like static attributes. Re-run `stimeo check` — it can report new diagnostics.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- meter, progress: an `aria-valuetext` you authored is preserved across renders.
|
|
41
|
+
- local-time, relative-time: the locale falls back to the nearest ancestor
|
|
42
|
+
carrying `lang`, not just the element's own and then the document's.
|
|
43
|
+
- countdown: `data-state` is the source of truth for the run state; `autostart`
|
|
44
|
+
governs only markup that states none.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Turbo caching and morphs across announcer, color-picker, countdown,
|
|
49
|
+
date-range-picker, frame-loading, local-time, meter, overflow-menu, progress,
|
|
50
|
+
range-slider, rating, relative-time, sidebar, spinner, step-indicator: the
|
|
51
|
+
page is rewound before the snapshot and repaints when values are swapped,
|
|
52
|
+
instead of restoring stuck state or leaving a stale reading.
|
|
53
|
+
- network-status: transitions are actually announced.
|
|
54
|
+
- countdown: resume steps by one unit, and a settled timer no longer re-emits
|
|
55
|
+
`complete` on reconnect.
|
|
56
|
+
- step-indicator: runtime step changes re-derive the set, and a non-numeric
|
|
57
|
+
`current` falls back to the first step.
|
|
58
|
+
- local-time: a `datetime` with a space separator (`2026-06-08 12:30`) parses.
|
|
59
|
+
- relative-time: a malformed `locale` no longer throws.
|
|
60
|
+
- skeleton, frame-loading: a repeated signal no longer restarts or stacks the
|
|
61
|
+
minimum-duration wait.
|
|
62
|
+
- Empty, inverted, and non-numeric ranges floor to `0` across meter, progress,
|
|
63
|
+
slider, and range-slider.
|
|
64
|
+
|
|
65
|
+
## [0.3.0] - 2026-08-09
|
|
66
|
+
|
|
67
|
+
Minor release focused on consistent keyboard and selection behavior, resilient
|
|
68
|
+
runtime DOM changes, and substantially stronger Inspector diagnostics. No
|
|
69
|
+
controller, target, value, action, event, or package export was removed. The
|
|
70
|
+
Inspector manifest advances from schema v5 to v8, so tools that read the raw
|
|
71
|
+
manifest must accept the new schema and fields.
|
|
72
|
+
|
|
73
|
+
### Upgrade notes
|
|
74
|
+
|
|
75
|
+
- Run `stimeo check` against existing views before deploying. New structural
|
|
76
|
+
checks can report errors for incomplete optional target groups, missing
|
|
77
|
+
companion controllers, undeclared role-bearing targets, and invalid
|
|
78
|
+
cardinality. New ARIA checks can also add warnings without failing the command.
|
|
79
|
+
- In RTL containers, horizontal arrow keys now follow logical inline direction
|
|
80
|
+
across composite widgets. LTR behavior is unchanged.
|
|
81
|
+
- Slider, range-slider, and color-picker pointer/arrow mirroring remains opt-in:
|
|
82
|
+
set their new `logicalTrack` value only when the consuming CSS mirrors the
|
|
83
|
+
track in RTL.
|
|
84
|
+
- calendar's `stimeo--calendar:monthchange` no longer fires while the grid
|
|
85
|
+
settles on its initial month, so code that ran from that first event must now
|
|
86
|
+
run once at initialization. Listeners that re-apply per-date `aria-disabled`
|
|
87
|
+
marks are the common case.
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- breadcrumb: a public `update` action for reconciling collapsible state after
|
|
92
|
+
application-driven DOM changes.
|
|
93
|
+
- tree-view: a public `toggle` action for explicitly expanding or collapsing an
|
|
94
|
+
item from application markup.
|
|
95
|
+
- scrollspy: a `focusSection` value for opting into focus movement when a link
|
|
96
|
+
activates a section.
|
|
97
|
+
- slider, range-slider, color-picker: the `logicalTrack` value described above.
|
|
98
|
+
- Inspector schema v6-v8: conditional target requirements and the new
|
|
99
|
+
`missing-companion`, `undeclared-target`, `cardinality-violation`,
|
|
100
|
+
`forbidden-aria`, and `missing-conditional-target` diagnostics. Rules can now
|
|
101
|
+
depend on controller values, element/document conditions, contained targets,
|
|
102
|
+
and ARIA requirement severity.
|
|
103
|
+
|
|
104
|
+
### Changed
|
|
105
|
+
|
|
106
|
+
- accordion, carousel, calendar, combobox, command-palette, context-menu,
|
|
107
|
+
data-grid, date-range-picker, listbox, menu, menubar, multi-select,
|
|
108
|
+
navigation-menu, OTP, radio-group, roving, tabs, tags-input, time-picker,
|
|
109
|
+
toggle-group, toolbar, tree-view, and related horizontal controls now resolve
|
|
110
|
+
Left/Right behavior from the effective text direction. Sortable geometry also
|
|
111
|
+
follows logical inline direction.
|
|
112
|
+
- Arrow-key handlers yield modified browser shortcuts, and composite handlers
|
|
113
|
+
yield events already consumed by a nested widget. IME composition continues to
|
|
114
|
+
take precedence over component shortcuts.
|
|
115
|
+
- Selection widgets normalize controller-owned `aria-selected` state, preserve a
|
|
116
|
+
single active/selected item where the pattern requires one, and reconcile that
|
|
117
|
+
state when options are inserted, removed, or replaced at runtime.
|
|
118
|
+
- menu and overflow-menu delegate item interaction from their containers, so
|
|
119
|
+
dynamically added items work without per-item actions. Existing action markup
|
|
120
|
+
remains compatible.
|
|
121
|
+
- `aria-disabled="true"` items remain discoverable by composite keyboard
|
|
122
|
+
navigation while activation, expansion, and consumer actions are suppressed.
|
|
123
|
+
- Outside pointer listeners for dismissible composites — click and context-menu
|
|
124
|
+
alike — run in the capture phase so nested application code cannot leave an
|
|
125
|
+
open layer stuck by stopping bubbling.
|
|
126
|
+
- Inspector accessible-name diagnostics now follow ARIA's Required, Recommended,
|
|
127
|
+
and conditional levels instead of treating every role alike.
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
|
|
131
|
+
- combobox, command-palette, listbox, and multi-select no longer retain stale
|
|
132
|
+
active IDs, selection, chips, or hidden fields after Turbo morphs and runtime
|
|
133
|
+
target replacement. A disconnect/reconnect in the same task can no longer let
|
|
134
|
+
an old reconciliation microtask consume the fresh generation's pass.
|
|
135
|
+
- breadcrumb fails open when its optional disclosure targets are incomplete and
|
|
136
|
+
restores items that leave the collapsible set instead of leaving them hidden.
|
|
137
|
+
- menu, menubar, navigation-menu, overflow-menu, pagination, and tree-view now
|
|
138
|
+
recover focus consistently across empty lists, disabled fieldsets, hidden
|
|
139
|
+
ancestors, item banking, and runtime item removal.
|
|
140
|
+
- toolbar restores its single tab stop when an enclosing `<fieldset>` is
|
|
141
|
+
re-enabled, so unlocking a form brings the group back into the Tab sequence.
|
|
142
|
+
- multi-select keeps chip focus when the selection was made in an order other
|
|
143
|
+
than the option order. The chips are reconciled against the selected value set
|
|
144
|
+
rather than position by position, and their labels are paired by value.
|
|
145
|
+
- overflow-menu measures items adopted from server-rendered overflow in the bar
|
|
146
|
+
rather than inside the closed menu, so an already-overflowed bar no longer
|
|
147
|
+
returns every item to the bar on first paint.
|
|
148
|
+
- combobox commits a clicked option after its input target is removed instead of
|
|
149
|
+
throwing, matching the tolerance the popup already had for that state.
|
|
150
|
+
- color-picker falls back to the per-channel range when a slider omits
|
|
151
|
+
`aria-valuemin` / `aria-valuemax`, instead of pinning the channel at zero.
|
|
152
|
+
- calendar maintains exactly one roving tab stop while selection and available
|
|
153
|
+
days change.
|
|
154
|
+
- calendar reports `monthchange` from the month it paints, so a malformed
|
|
155
|
+
`month` value can no longer put a raw non-`YYYY-MM` string in `detail.month`.
|
|
156
|
+
The report is driven by the repaint rather than by the `month` value, so a
|
|
157
|
+
fallback month moved by `selected` or by selecting a neighbouring month's cell
|
|
158
|
+
is announced as it happens instead of arriving on a later repaint. Re-applying
|
|
159
|
+
the same month, as a Turbo cache restore does, stays silent.
|
|
160
|
+
- scrollspy re-evaluates links and sections after every supported entry path;
|
|
161
|
+
intersection-based controllers align fallback thresholds, hidden-layout
|
|
162
|
+
handling, and reduced-motion behavior.
|
|
163
|
+
- `stimeo catalog --json` and large `stimeo check --json` reports are no longer
|
|
164
|
+
truncated when stdout is piped; the CLI now lets buffered output flush before
|
|
165
|
+
exiting, and the build audit exercises the real piped catalog.
|
|
166
|
+
|
|
10
167
|
## [0.2.1] - 2026-07-26
|
|
11
168
|
|
|
12
169
|
Patch release from a review of the disclosure and layout components, plus a
|
|
@@ -234,6 +391,9 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
234
391
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
235
392
|
RubyGems.
|
|
236
393
|
|
|
394
|
+
[0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
|
|
395
|
+
[0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
|
|
396
|
+
[0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
|
|
237
397
|
[0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
|
|
238
398
|
[0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
|
|
239
399
|
[0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
|
package/dist/cable/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Controller, Application } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Headless **live counter** —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Headless **live counter** — a *server-bound* behavior: a number bound to an
|
|
5
|
+
* Action Cable stream (likes, views, active users) that ticks across every
|
|
6
|
+
* connected client. A local action applies an **optimistic** increment
|
|
7
|
+
* immediately, then reconciles with the broadcast; the own echo of a delta
|
|
8
|
+
* broadcast is deduped so the increment is never applied twice. Ships in the
|
|
9
|
+
* opt-in `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer).
|
|
10
10
|
*
|
|
11
11
|
* Markup contract (identifier: `stimeo--live-counter`):
|
|
12
12
|
* <div data-controller="stimeo--live-counter"
|
|
@@ -91,8 +91,8 @@ declare class LiveCounterController extends Controller<HTMLElement> {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* Headless **presence** —
|
|
95
|
-
*
|
|
94
|
+
* Headless **presence** — a *server-bound* behavior: online dots / a
|
|
95
|
+
* "who's viewing this" stack, bound to an Action Cable channel. Like
|
|
96
96
|
* `stimeo--typing-indicator`, the server stays a trivial rebroadcast channel
|
|
97
97
|
* with **zero presence state**: every client heartbeats an `appear` beacon
|
|
98
98
|
* (`{ id, name }`), and each client expires peers it has not heard from for
|
|
@@ -198,14 +198,14 @@ declare class PresenceController extends Controller<HTMLElement> {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/**
|
|
201
|
-
* Headless **typing indicator** —
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
201
|
+
* Headless **typing indicator** — a *server-bound* behavior: a controller whose
|
|
202
|
+
* state lives on the server stream, not in client memory. Typing in the composer
|
|
203
|
+
* throttle-broadcasts a `typing` signal over an Action Cable channel; received
|
|
204
|
+
* signals from *other* clients render "X is typing…" into a `status` live region
|
|
205
|
+
* and auto-clear after `timeout` ms of silence. The whole behavior is HTML + a
|
|
206
|
+
* broadcast — no app JS, no client store, no reconciler. Ships in the opt-in
|
|
207
|
+
* `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer); the core stays
|
|
208
|
+
* zero-dep.
|
|
209
209
|
*
|
|
210
210
|
* Markup contract (identifier: `stimeo--typing-indicator`):
|
|
211
211
|
* <div data-controller="stimeo--typing-indicator"
|
package/dist/cable/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/cable/consumer.ts","../../src/cable/live_counter_controller.ts","../../src/utils/safe_timeout.ts","../../src/cable/presence_controller.ts","../../src/cable/typing_indicator_controller.ts","../../src/cable/index.ts"],"names":["Controller","template"],"mappings":"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","file":"index.js","sourcesContent":["import { createConsumer } from \"@rails/actioncable\";\n\n/**\n * Minimal structural view of an Action Cable subscription — the two members the\n * server-bound controllers use. Keeping our own narrow interface (instead of\n * re-exporting the full `@rails/actioncable` types) lets consumers hand us any\n * structurally compatible object, including test doubles.\n */\nexport interface CableSubscription {\n /** Invokes a channel action on the server (`ChannelName#action`). */\n perform(action: string, data?: Record<string, unknown>): void;\n /** Cancels the subscription (the channel's `unsubscribed` runs server-side). */\n unsubscribe(): void;\n}\n\n/** The mixin a controller passes to {@link CableConsumer.subscriptions}' create. */\nexport interface CableSubscriptionMixin {\n /** Called once the subscription is confirmed (`perform` is deliverable). */\n connected?(): void;\n /**\n * Called when the connection drops — perform() is silently undeliverable\n * until Action Cable reconnects and re-confirms (`connected` fires again).\n */\n disconnected?(): void;\n /** Called when the server refuses the subscription (it will never confirm). */\n rejected?(): void;\n /** Called with each broadcast the channel transmits to this client. */\n received?(data: unknown): void;\n}\n\n/** Minimal structural view of an Action Cable consumer (the websocket owner). */\nexport interface CableConsumer {\n subscriptions: {\n create(\n channel: string | Record<string, unknown>,\n mixin: CableSubscriptionMixin,\n ): CableSubscription;\n };\n}\n\n/**\n * The shared consumer. Deliberately module-scoped: an Action Cable consumer is\n * *connection infrastructure* (one websocket per app, the Rails\n * `channels/consumer.js` convention), not UI state — it must survive Turbo\n * navigations, so the \"no module-scope mutable state\" lifecycle rule (which is\n * about DOM-rebuildable state) intentionally does not apply to it.\n */\nlet sharedConsumer: CableConsumer | null = null;\n\n/**\n * Replaces (or clears, with `null`) the shared Action Cable consumer.\n *\n * Call this once at boot when the app already owns a consumer (the usual\n * `app/javascript/channels/consumer.js`), so the server-bound controllers reuse\n * its websocket instead of opening a second one. Tests use it to inject a\n * double. With `null`, the next {@link getCableConsumer} lazily re-creates one.\n */\nexport function setCableConsumer(consumer: CableConsumer | null): void {\n sharedConsumer = consumer;\n}\n\n/**\n * The shared Action Cable consumer, lazily created on first use via\n * `createConsumer()` (which reads the standard `action_cable_meta_tag` URL).\n */\nexport function getCableConsumer(): CableConsumer {\n if (!sharedConsumer) sharedConsumer = createConsumer();\n return sharedConsumer;\n}\n\n/**\n * A {@link CableSubscription} that also tracks its confirmation lifecycle.\n * Action Cable silently drops a `perform()` sent before the subscription is\n * confirmed or while the connection is down — every server-bound controller\n * must therefore gate its sends (and their local side effects: optimistic\n * updates, throttle bookkeeping) on {@link confirmed}.\n */\nexport interface ConfirmedCableSubscription extends CableSubscription {\n /** True while the subscription is confirmed — `perform()` is deliverable. */\n readonly confirmed: boolean;\n /** True once the server refused the subscription (it will never confirm). */\n readonly rejected: boolean;\n}\n\n/**\n * Creates a subscription on the shared consumer with confirmation tracking\n * layered over the caller's mixin: `connected` / `disconnected` / `rejected`\n * flip the {@link ConfirmedCableSubscription.confirmed} flag *before* the\n * caller's own handler runs (so a handler reading `subscription.confirmed`\n * sees the post-transition state), and `received` passes straight through.\n *\n * This is deliberately a *tracker*, not an automatic `perform()` gate: the\n * controllers must skip the local side effects that surround a send (an\n * optimistic DOM bump, a throttle timestamp) together with the send itself,\n * which only the call site can decide — so they check `confirmed` and bail\n * before any of it.\n */\nexport function createConfirmedSubscription(\n channel: string | Record<string, unknown>,\n mixin: CableSubscriptionMixin,\n): ConfirmedCableSubscription {\n let confirmed = false;\n let rejected = false;\n const subscription = getCableConsumer().subscriptions.create(channel, {\n // A refusal is final: Action Cable never confirms a rejected subscription,\n // so a late connected/disconnected (only possible from a misbehaving\n // consumer double) must not reopen the gate `rejected` promised shut.\n connected: () => {\n if (rejected) return;\n confirmed = true;\n mixin.connected?.();\n },\n disconnected: () => {\n if (rejected) return;\n confirmed = false;\n mixin.disconnected?.();\n },\n rejected: () => {\n confirmed = false;\n rejected = true;\n mixin.rejected?.();\n },\n received: (data: unknown) => mixin.received?.(data),\n });\n return {\n perform: (action, data) => subscription.perform(action, data),\n unsubscribe: () => subscription.unsubscribe(),\n get confirmed() {\n return confirmed;\n },\n get rejected() {\n return rejected;\n },\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { type ConfirmedCableSubscription, createConfirmedSubscription } from \"./consumer\";\n\n/**\n * Marker on triggers this controller disabled (mirroring submit-once's\n * marker): `disabled` is a shared attribute, so only marked ones are ever\n * re-enabled — an authored-disabled trigger stays untouched.\n */\nconst DISABLED_MARKER = \"data-live-counter-disabled\";\n\n/**\n * Headless **live counter** — the third *server-bound* behavior: a\n * number bound to an Action Cable stream (likes, views, active users) that\n * ticks across every connected client. A local action applies an **optimistic**\n * increment immediately, then reconciles with the broadcast; the own echo of a\n * delta broadcast is deduped so the increment is never applied twice. Ships in\n * the opt-in `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer).\n *\n * Markup contract (identifier: `stimeo--live-counter`):\n * <div data-controller=\"stimeo--live-counter\"\n * data-stimeo--live-counter-channel-value=\"LikesChannel\"\n * data-stimeo--live-counter-params-value='{\"post\":42}'\n * data-stimeo--live-counter-id-value=\"<%= SecureRandom.uuid %>\">\n * <span data-stimeo--live-counter-target=\"value\">128</span>\n * <button type=\"button\" data-action=\"stimeo--live-counter#increment\"\n * data-stimeo--live-counter-target=\"trigger\">♥</button>\n * </div>\n *\n * Wire contract — the broadcast is either **authoritative** or a **delta**:\n * `{ count: 129 }` sets the absolute value (naturally idempotent; preferred —\n * the server owns the number), while `{ delta: 1, by: \"17\" }` adds to it,\n * skipped when `by` matches this client's `id` (the optimistic increment\n * already applied it). `increment` performs `increment` on the channel with\n * `{ id, delta }`; the server persists and broadcasts. Without an own `id`\n * the delta echo is indistinguishable from a foreign delta, so the optimistic\n * bump is skipped and the broadcast applies the increment exactly once — set\n * `id` (any per-client string, e.g. `SecureRandom.uuid`) for optimistic UX.\n *\n * @remarks\n * Behavior only — the displayed number IS the state, and the **DOM is the\n * source of truth**: the server renders the initial count into the `value`\n * target (the element itself without one), so a Turbo cache restore needs no\n * reconciliation and `connect()` only (re)subscribes. Sending tracks the full\n * subscription lifecycle (via the shared confirmation-aware subscription):\n * increments are dropped before confirmation AND while the connection is down\n * (`disconnected` shuts the gate until Action Cable re-confirms), so the\n * display never advances past what the server can receive. A refused\n * subscription publishes the `data-live-counter-rejected` hook (cleared on\n * `connect()` — rejection is transient server state) so the consumer's CSS can\n * disable the trigger. Optional `trigger` targets make that declarative: they\n * carry the real `disabled` attribute exactly while an increment would be\n * dropped (before confirmation, during an outage, after a rejection) — no CSS\n * required, and a disabled form control is announced as such by AT. An\n * authored `disabled` (set by the consumer for its own reasons) is respected:\n * only a disabled this controller applied — tracked via a marker attribute —\n * is ever lifted. Channel-less (local-only) counters never disable their\n * triggers. There are no timers; the subscription is released on\n * `disconnect()` (Turbo navigation included).\n */\nexport class LiveCounterController extends Controller<HTMLElement> {\n static override targets = [\"value\", \"trigger\"];\n static override values = {\n channel: { type: String, default: \"\" },\n params: { type: Object, default: {} },\n id: { type: String, default: \"\" },\n };\n static actions = [\"increment\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly hasValueTarget: boolean;\n declare readonly valueTarget: HTMLElement;\n declare readonly triggerTargets: HTMLElement[];\n declare channelValue: string;\n declare paramsValue: Record<string, unknown>;\n declare idValue: string;\n\n #subscription: ConfirmedCableSubscription | null = null;\n\n override connect(): void {\n // Rejection is transient server state: a Turbo cache snapshot must not\n // resurrect the hook — the fresh subscription below re-decides it.\n this.element.removeAttribute(\"data-live-counter-rejected\");\n if (this.channelValue) {\n this.#subscription = createConfirmedSubscription(\n { channel: this.channelValue, ...this.paramsValue },\n {\n connected: () => this.#syncTriggers(),\n // A drop closes the send window (the shared subscription tracks it)\n // until Action Cable reconnects and re-confirms — an increment during\n // the outage would bump the display while its perform() is silently\n // discarded by the closed socket.\n disconnected: () => this.#syncTriggers(),\n // The server refused the subscription (auth, bad params): the gate\n // stays shut for good, and the hook lets the consumer's CSS disable\n // or hide the trigger instead of leaving a silently dead button.\n rejected: () => {\n this.element.setAttribute(\"data-live-counter-rejected\", \"true\");\n this.#syncTriggers();\n },\n received: (data: unknown) => this.#onReceived(data),\n },\n );\n }\n // Also covers a Turbo cache restore that snapshotted a disabled trigger:\n // the fresh (unconfirmed or absent) subscription re-decides the state.\n this.#syncTriggers();\n }\n\n override disconnect(): void {\n this.#subscription?.unsubscribe();\n this.#subscription = null;\n this.element.removeAttribute(\"data-live-counter-rejected\");\n }\n\n /** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */\n triggerTargetConnected(target: HTMLElement): void {\n this.#syncTrigger(target);\n }\n\n /**\n * Optimistic local increment: bumps the display immediately, then asks the\n * server to persist and broadcast. The step comes from the action param\n * (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.\n */\n increment(event?: Event & { params?: { delta?: number } }): void {\n // Before the confirmed subscription — and while the connection is down —\n // a perform() is silently dropped by Action Cable: bumping the display\n // then would diverge from the server, so the whole increment is dropped\n // (screen and server stay consistent).\n if (this.#subscription && !this.#subscription.confirmed) return;\n // Action params arrive as authored strings; normalize and ignore garbage.\n const raw = Number(event?.params?.delta ?? 1);\n const delta = Number.isFinite(raw) ? raw : 1;\n // Without an own `id` the delta echo cannot be deduped: skip the optimistic\n // bump and let the broadcast apply the increment exactly once. Channel-less\n // (local-only) counters have no echo, so they always bump.\n if (!this.#subscription || this.idValue !== \"\") {\n this.#write(this.#current + delta);\n }\n this.#subscription?.perform(\"increment\", { id: this.idValue, delta });\n }\n\n /** Reconciles a broadcast: absolute `count` wins; own-echo deltas are skipped. */\n #onReceived(data: unknown): void {\n const message = data as { count?: unknown; delta?: unknown; by?: unknown } | null;\n if (typeof message?.count === \"number\") {\n this.#write(message.count);\n return;\n }\n if (typeof message?.delta === \"number\") {\n // The own echo: this client already applied the delta optimistically.\n if (typeof message.by === \"string\" && message.by !== \"\" && message.by === this.idValue) {\n return;\n }\n this.#write(this.#current + message.delta);\n }\n }\n\n /** True while an increment would go through (channel-less counters always are). */\n get #ready(): boolean {\n return !this.#subscription || this.#subscription.confirmed;\n }\n\n /**\n * Reflects the send gate onto the optional `trigger` targets as the real\n * `disabled` attribute — the declarative alternative to styling off the\n * `data-live-counter-rejected` hook (a disabled control is also skipped by\n * keyboard focus and announced by AT, which CSS alone cannot do).\n */\n #syncTriggers(): void {\n for (const trigger of this.triggerTargets) this.#syncTrigger(trigger);\n }\n\n /**\n * `disabled` is a shared attribute: only disable what is currently enabled\n * (marking it ours), and only lift a disabled carrying our marker — so an\n * authored-disabled trigger (\"disabled until valid\", say) is never\n * re-enabled by the gate. A marked disabled restored from a Turbo cache\n * snapshot is recognized as ours and lifted once the gate opens.\n */\n #syncTrigger(trigger: HTMLElement): void {\n if (this.#ready) {\n if (trigger.hasAttribute(DISABLED_MARKER)) {\n trigger.removeAttribute(\"disabled\");\n trigger.removeAttribute(DISABLED_MARKER);\n }\n } else if (!trigger.hasAttribute(\"disabled\")) {\n trigger.setAttribute(\"disabled\", \"\");\n trigger.setAttribute(DISABLED_MARKER, \"\");\n }\n }\n\n /** The displayed element: the `value` target, else the controller element. */\n get #display(): HTMLElement {\n return this.hasValueTarget ? this.valueTarget : this.element;\n }\n\n /** The current count, parsed from the DOM (the single source of truth). */\n get #current(): number {\n // Strip separators/suffixes (\"1,200 likes\") like count-up, so a formatted\n // server-rendered value doesn't collapse to its first digit group.\n const parsed = Number.parseInt((this.#display.textContent ?? \"\").replace(/[^0-9-]/g, \"\"), 10);\n return Number.isNaN(parsed) ? 0 : parsed;\n }\n\n #write(count: number): void {\n if (count === this.#current) return;\n this.#display.textContent = String(count);\n this.dispatch(\"change\", { detail: { count } });\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeInterval, SafeTimeout } from \"../utils/safe_timeout\";\nimport { type ConfirmedCableSubscription, createConfirmedSubscription } from \"./consumer\";\n\n/** A peer currently present (another client in the same room). */\ninterface Peer {\n name: string;\n /** Auto-expiry timer id, restarted by every beacon. */\n timer: number;\n}\n\n/**\n * Minimum ms between outgoing beacons. The steady state is the `heartbeat`\n * interval; this guard only bounds the *extra* beacons sent in response to\n * newly seen peers (see the roster-convergence note in the class doc), so a\n * burst of joiners cannot make this client flood the channel.\n */\nconst BEACON_THROTTLE_MS = 2000;\n\n/**\n * Headless **presence** — the second *server-bound* behavior: online\n * dots / a \"who's viewing this\" stack, bound to an Action Cable channel. Like\n * `stimeo--typing-indicator`, the server stays a trivial rebroadcast channel\n * with **zero presence state**: every client heartbeats an `appear` beacon\n * (`{ id, name }`), and each client expires peers it has not heard from for\n * `timeout` ms. Leaving (`{ id, leaving: true }`) is broadcast best-effort on\n * `disconnect()` and on `pagehide` (tab close / hard navigation, where\n * `disconnect()` never runs); a lost notice is caught by the expiry. Ships in\n * the opt-in `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer).\n *\n * Roster convergence: a late joiner would otherwise see peers only as their\n * next heartbeats arrive, so on hearing a beacon from an *unknown* peer, each\n * client re-announces itself (throttled to {@link BEACON_THROTTLE_MS}) — the\n * roster converges in one round-trip instead of one heartbeat period.\n *\n * Markup contract (identifier: `stimeo--presence`):\n * <div data-controller=\"stimeo--presence\"\n * data-stimeo--presence-channel-value=\"PresenceChannel\"\n * data-stimeo--presence-params-value='{\"room\":\"doc_7\"}'\n * data-stimeo--presence-id-value=\"17\" data-stimeo--presence-name-value=\"Alice\">\n * <span data-stimeo--presence-target=\"count\" data-other=\"%{count} viewing\"></span>\n * <ul data-stimeo--presence-target=\"list\"></ul>\n * <template data-stimeo--presence-target=\"template\">\n * <li><span data-presence-name></span></li>\n * </template>\n * </div>\n *\n * Server contract (a trivial rebroadcast channel):\n * class PresenceChannel < ApplicationCable::Channel\n * def subscribed = stream_from \"presence:#{params[:room]}\"\n * def appear(data) = ActionCable.server.broadcast(\"presence:#{params[:room]}\",\n * { id: data[\"id\"], name: data[\"name\"], leaving: data[\"leaving\"] })\n * end\n *\n * @remarks\n * Behavior only — the dot/stack look is the consumer's CSS, keyed off the\n * `data-present` / `data-present-count` hooks. The roster counts and renders\n * **other** clients (`id` ≠ own `id`); rendering is optional: given a `list` +\n * `template` pair, one clone per peer is appended (elements marked\n * `data-presence-name` receive the name; the clone root is tagged\n * `data-presence-id`), and the `count` target renders through localizable\n * `data-zero` / `data-one` / `data-other` templates (`%{count}`). Richer\n * per-user rendering (avatars, links) belongs to the consumer via the `join` /\n * `leave` / `change` events or a server-rendered Turbo Stream. The `id`\n * comparison is display-level echo suppression, not authentication — identity\n * belongs to the server. Sending tracks the full subscription lifecycle (via\n * the shared confirmation-aware subscription): beacons — heartbeats, the\n * convergence answer, the leaving notice — are dropped before confirmation and\n * while the connection is down, where Action Cable would discard them anyway\n * (and a discarded beacon must not burn the convergence throttle); `connected`\n * re-fires on every reconnect and force-beacons, so the roster self-heals. A\n * refused subscription publishes the `data-presence-rejected` hook (cleared on\n * `connect()` — rejection is transient server state), mirroring\n * `data-live-counter-rejected`. Presence is transient: `connect()` clears\n * whatever a Turbo cache snapshot preserved (hooks + rendered clones), renders\n * the known-empty count (the `data-present*` hooks stay absent until the first\n * beacon), and the stream re-populates; the subscription, heartbeat interval,\n * per-peer expiry timers, and the `pagehide` listener are all released on\n * `disconnect()` (Turbo navigation included).\n */\nexport class PresenceController extends Controller<HTMLElement> {\n static override targets = [\"count\", \"list\", \"template\"];\n static override values = {\n channel: { type: String, default: \"\" },\n params: { type: Object, default: {} },\n id: { type: String, default: \"\" },\n name: { type: String, default: \"\" },\n heartbeat: { type: Number, default: 15_000 },\n timeout: { type: Number, default: 40_000 },\n };\n static events = [\"join\", \"leave\", \"change\"] as const;\n\n declare readonly hasCountTarget: boolean;\n declare readonly countTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly listTarget: HTMLElement;\n declare readonly hasTemplateTarget: boolean;\n declare readonly templateTarget: HTMLTemplateElement;\n declare channelValue: string;\n declare paramsValue: Record<string, unknown>;\n declare idValue: string;\n declare nameValue: string;\n declare heartbeatValue: number;\n declare timeoutValue: number;\n\n #subscription: ConfirmedCableSubscription | null = null;\n /** Present peers keyed by id (insertion order = join order). */\n readonly #peers = new Map<string, Peer>();\n readonly #timers = new SafeTimeout();\n readonly #intervals = new SafeInterval();\n /** Epoch ms of the last outgoing beacon, for the convergence throttle. */\n #lastBeaconAt = 0;\n /** Pending trailing-edge convergence beacon (at most one queued). */\n #pendingBeacon: number | null = null;\n\n override connect(): void {\n // Presence is transient: drop whatever a Turbo cache snapshot preserved\n // (hooks + rendered clones); the live stream re-populates the roster.\n // Rejection is transient server state too — the fresh subscription below\n // re-decides the hook.\n this.#reset();\n this.element.removeAttribute(\"data-presence-rejected\");\n // The roster is known-empty here, so the count target can say so right\n // away instead of sitting blank until the first roster change. The\n // data-present* hooks intentionally stay absent until the first beacon.\n if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);\n\n if (!this.channelValue) return;\n this.#subscription = createConfirmedSubscription(\n { channel: this.channelValue, ...this.paramsValue },\n {\n // The first beacon must wait for the confirmed subscription — a\n // perform() before that is silently dropped by Action Cable. Fires\n // again on every reconnect, so the roster self-heals after an outage.\n connected: () => this.#beacon(true),\n // The server refused the subscription: no beacon will ever go through,\n // and the hook lets the consumer's CSS reflect the dead stream.\n rejected: () => {\n this.element.setAttribute(\"data-presence-rejected\", \"true\");\n },\n received: (data: unknown) => this.#onReceived(data),\n },\n );\n this.#intervals.set(() => this.#beacon(true), this.heartbeatValue);\n window.addEventListener(\"pagehide\", this.#onPageHide);\n }\n\n override disconnect(): void {\n window.removeEventListener(\"pagehide\", this.#onPageHide);\n // Best-effort graceful leave; a lost notice is caught by peers' expiry\n // timers instead.\n this.#sendLeaveNotice();\n this.#subscription?.unsubscribe();\n this.#subscription = null;\n this.#intervals.clearAll();\n this.#reset();\n this.element.removeAttribute(\"data-presence-rejected\");\n }\n\n /**\n * Sends the best-effort leaving notice (skipped without an own `id`, and\n * outside the confirmed window, where Action Cable would discard it anyway).\n */\n #sendLeaveNotice(): void {\n if (!this.#subscription?.confirmed || !this.idValue) return;\n this.#subscription.perform(\"appear\", { id: this.idValue, leaving: true });\n }\n\n /**\n * `pagehide` covers the leaves `disconnect()` cannot see: closing the tab or\n * a hard (non-Turbo) navigation destroys the page without running Stimulus\n * teardown, so this listener is the only chance to announce them. Best-effort\n * by nature (the socket may close before the frame flushes); peers' expiry\n * timers stay the safety net. If the page enters the bfcache and is restored\n * instead, the next heartbeat re-announces this client, so an over-eager\n * leave self-heals.\n */\n readonly #onPageHide = (): void => {\n this.#sendLeaveNotice();\n };\n\n /**\n * Broadcasts this client's beacon (skipped without an own `id`). A throttled\n * convergence answer is deferred to the trailing edge rather than dropped —\n * otherwise a peer joining right after a heartbeat would not learn about\n * this client until the next full heartbeat period.\n *\n * Gated on the confirmed subscription: before confirmation and during an\n * outage Action Cable silently discards perform(), so a beacon sent then is\n * pure waste — worse, it would burn `#lastBeaconAt` and delay the next real\n * convergence answer by up to the throttle window. The heartbeat interval\n * keeps ticking regardless; `connected` re-fires on reconfirm and\n * force-beacons immediately, so a gated tick is never missed for long.\n */\n #beacon(force: boolean): void {\n if (!this.#subscription?.confirmed || !this.idValue) return;\n const now = Date.now();\n const wait = BEACON_THROTTLE_MS - (now - this.#lastBeaconAt);\n if (!force && wait > 0) {\n if (this.#pendingBeacon === null) {\n this.#pendingBeacon = this.#timers.set(() => {\n this.#pendingBeacon = null;\n this.#beacon(true);\n }, wait);\n }\n return;\n }\n this.#lastBeaconAt = now;\n this.#subscription.perform(\"appear\", { id: this.idValue, name: this.nameValue });\n }\n\n /**\n * Tracks a broadcast beacon: upserts the peer (restarting its expiry timer),\n * removes it on a `leaving` notice, and re-announces this client when the\n * peer was unknown (roster convergence — see the class doc).\n */\n #onReceived(data: unknown): void {\n const beacon = data as { id?: unknown; name?: unknown; leaving?: unknown } | null;\n const id = beacon?.id;\n if (typeof id !== \"string\" || id === \"\" || id === this.idValue) return;\n\n if (beacon?.leaving === true) {\n this.#drop(id);\n return;\n }\n\n const name = typeof beacon?.name === \"string\" ? beacon.name : \"\";\n const existing = this.#peers.get(id);\n if (existing !== undefined) this.#timers.clear(existing.timer);\n const timer = this.#timers.set(() => this.#drop(id), this.timeoutValue);\n this.#peers.set(id, { name, timer });\n\n if (existing === undefined) {\n this.#appendClone(id, name);\n this.#render();\n this.dispatch(\"join\", { detail: { id, name } });\n this.#beacon(false); // answer an unknown peer so its roster converges\n } else if (existing.name !== name) {\n this.#updateClone(id, name);\n this.#render();\n }\n }\n\n /** Removes a peer (expiry or graceful leave) and reflects the change. */\n #drop(id: string): void {\n const peer = this.#peers.get(id);\n if (peer === undefined) return;\n this.#timers.clear(peer.timer);\n this.#peers.delete(id);\n this.#removeClone(id);\n this.#render();\n this.dispatch(\"leave\", { detail: { id } });\n }\n\n /** Reflects the roster onto the hooks + count target and emits `change`. */\n #render(): void {\n const users = [...this.#peers.entries()].map(([id, peer]) => ({ id, name: peer.name }));\n this.element.setAttribute(\"data-present\", users.length > 0 ? \"true\" : \"false\");\n this.element.setAttribute(\"data-present-count\", String(users.length));\n if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);\n this.dispatch(\"change\", { detail: { users } });\n }\n\n /**\n * Builds the count copy. Localizable through `data-zero` / `data-one` /\n * `data-other` templates on the count target (`%{count}`); the bare number is\n * the fallback (copy-free, so nothing to localize by default).\n */\n #countMessage(count: number): string {\n const templates = this.countTarget.dataset;\n const template =\n (count === 0 ? templates.zero : count === 1 ? templates.one : templates.other) ??\n templates.other;\n return template ? template.replace(\"%{count}\", String(count)) : String(count);\n }\n\n /** Appends one template clone for a newly present peer (list + template only). */\n #appendClone(id: string, name: string): void {\n if (!this.hasListTarget || !this.hasTemplateTarget) return;\n const clone = this.templateTarget.content.cloneNode(true) as DocumentFragment;\n const root = clone.firstElementChild;\n if (!root) return;\n root.setAttribute(\"data-presence-id\", id);\n this.#fillName(root, name);\n this.listTarget.appendChild(clone);\n }\n\n #updateClone(id: string, name: string): void {\n const root = this.#cloneFor(id);\n if (root) this.#fillName(root, name);\n }\n\n #removeClone(id: string): void {\n this.#cloneFor(id)?.remove();\n }\n\n #cloneFor(id: string): Element | null {\n if (!this.hasListTarget) return null;\n // Attribute selectors cannot escape arbitrary ids reliably; match manually.\n for (const child of this.listTarget.querySelectorAll(\"[data-presence-id]\")) {\n if (child.getAttribute(\"data-presence-id\") === id) return child;\n }\n return null;\n }\n\n /** Writes the peer's name into the clone's `data-presence-name` slots. */\n #fillName(root: Element, name: string): void {\n const slots = root.querySelectorAll(\"[data-presence-name]\");\n for (const slot of slots) slot.textContent = name;\n if (slots.length === 0 && root.hasAttribute(\"data-presence-name\")) {\n root.textContent = name;\n }\n }\n\n /** Clears the transient roster state (connect reset + disconnect teardown). */\n #reset(): void {\n this.#timers.clearAll();\n this.#pendingBeacon = null;\n this.#peers.clear();\n this.#lastBeaconAt = 0;\n this.element.removeAttribute(\"data-present\");\n this.element.removeAttribute(\"data-present-count\");\n if (this.hasCountTarget) this.countTarget.textContent = \"\";\n if (this.hasListTarget) {\n for (const child of this.listTarget.querySelectorAll(\"[data-presence-id]\")) {\n child.remove();\n }\n }\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { type ConfirmedCableSubscription, createConfirmedSubscription } from \"./consumer\";\n\n/**\n * Headless **typing indicator** — the first *server-bound* behavior: a\n * controller whose state lives on the server stream, not in client\n * memory. Typing in the composer throttle-broadcasts a `typing` signal over an\n * Action Cable channel; received signals from *other* clients render \"X is\n * typing…\" into a `status` live region and auto-clear after `timeout` ms of\n * silence. The whole behavior is HTML + a broadcast — no app JS, no client\n * store, no reconciler. Ships in the opt-in `stimeo-ui/cable` subpath\n * (`@rails/actioncable` optional peer); the core stays zero-dep.\n *\n * Markup contract (identifier: `stimeo--typing-indicator`):\n * <div data-controller=\"stimeo--typing-indicator\"\n * data-stimeo--typing-indicator-channel-value=\"TypingChannel\"\n * data-stimeo--typing-indicator-params-value='{\"room\":\"chat_42\"}'\n * data-stimeo--typing-indicator-name-value=\"Alice\">\n * <textarea data-stimeo--typing-indicator-target=\"input\"></textarea>\n * <p role=\"status\" data-stimeo--typing-indicator-target=\"status\"\n * data-one=\"%{name} is typing…\" data-many=\"%{names} are typing…\"></p>\n * </div>\n *\n * Server contract (a trivial rebroadcast channel):\n * class TypingChannel < ApplicationCable::Channel\n * def subscribed = stream_from \"typing:#{params[:room]}\"\n * def typing(data) = ActionCable.server.broadcast(\"typing:#{params[:room]}\",\n * { name: data[\"name\"] })\n * end\n *\n * @remarks\n * Behavior only — the indicator's look is the consumer's CSS, keyed off the\n * `data-typing` hook; the copy is localizable through the `data-one` /\n * `data-many` templates (`%{name}` / `%{names}` / `%{count}`, terse English\n * fallback — the same channel design as `stimeo--sortable`). The own echo is\n * dropped by comparing the broadcast `name` against `name` (a same-name guard,\n * not authentication — identity belongs to the server). Sending tracks the full\n * subscription lifecycle (via the shared confirmation-aware subscription):\n * signals are dropped before confirmation and while the connection is down, so\n * an outage never burns the throttle window on undeliverable sends. A refused\n * subscription publishes the `data-typing-indicator-rejected` hook (cleared on\n * `connect()` — rejection is transient server state), mirroring\n * `data-live-counter-rejected`. Typing state is transient by nature:\n * `connect()` resets the hook and the live region (a Turbo cache snapshot must\n * not resurrect a stale \"X is typing…\"), and re-population happens naturally\n * from the stream. The subscription, the per-typer timers, and the delegated\n * `input` listener are all released on `disconnect()` (Turbo navigation\n * included).\n */\nexport class TypingIndicatorController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"status\"];\n static override values = {\n channel: { type: String, default: \"\" },\n params: { type: Object, default: {} },\n name: { type: String, default: \"\" },\n timeout: { type: Number, default: 3000 },\n // Must stay below `timeout`: the throttle is leading-edge only (no trailing\n // send), so a receiver's display survives continuous typing only while a\n // fresh signal lands within its timeout window.\n throttle: { type: Number, default: 2000 },\n };\n static events = [\"change\"] as const;\n\n declare readonly hasStatusTarget: boolean;\n declare readonly statusTarget: HTMLElement;\n declare channelValue: string;\n declare paramsValue: Record<string, unknown>;\n declare nameValue: string;\n declare timeoutValue: number;\n declare throttleValue: number;\n\n #subscription: ConfirmedCableSubscription | null = null;\n /** Names currently typing (other clients), each with its auto-clear timer id. */\n readonly #typers = new Map<string, number>();\n readonly #timers = new SafeTimeout();\n /** Epoch ms of the last broadcast, for leading-edge throttling. */\n #lastSentAt = 0;\n\n override connect(): void {\n // Typing state is transient: a Turbo cache snapshot must not resurrect a\n // stale indicator, and the live stream re-populates naturally. Rejection is\n // transient server state too — the fresh subscription re-decides the hook.\n this.element.removeAttribute(\"data-typing\");\n this.element.removeAttribute(\"data-typing-indicator-rejected\");\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n\n // Delegated on the container so the composer needs no per-input data-action\n // (and swapped/appended inputs keep working — lifecycle Rule A′).\n this.element.addEventListener(\"input\", this.#onInput);\n if (this.channelValue) {\n // Confirmation tracking (connected / disconnected / rejected) lives in\n // the shared subscription; #onInput gates on its `confirmed` so an\n // outage doesn't burn the throttle window on dropped sends.\n this.#subscription = createConfirmedSubscription(\n { channel: this.channelValue, ...this.paramsValue },\n {\n // The server refused the subscription: the send gate stays shut for\n // good, and the hook lets the consumer's CSS reflect the dead stream.\n rejected: () => {\n this.element.setAttribute(\"data-typing-indicator-rejected\", \"true\");\n },\n received: (data: unknown) => this.#onReceived(data),\n },\n );\n }\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onInput);\n this.#subscription?.unsubscribe();\n this.#subscription = null;\n this.#timers.clearAll();\n this.#typers.clear();\n this.element.removeAttribute(\"data-typing\");\n this.element.removeAttribute(\"data-typing-indicator-rejected\");\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n this.#lastSentAt = 0;\n }\n\n /** Throttled (leading-edge) broadcast of this client's typing signal. */\n readonly #onInput = (): void => {\n // Before the confirmed subscription a perform() is silently dropped by\n // Action Cable — and recording #lastSentAt would ALSO throttle away the\n // first real send, so bail without touching the throttle state.\n if (!this.#subscription?.confirmed) return;\n const now = Date.now();\n if (now - this.#lastSentAt < this.throttleValue) return;\n this.#lastSentAt = now;\n this.#subscription.perform(\"typing\", { name: this.nameValue });\n };\n\n /**\n * Tracks a broadcast typer. The own echo is dropped (same `name`); every\n * further signal from a name restarts its auto-clear timer, so the indicator\n * survives continuous typing and clears `timeout` ms after the last signal.\n */\n #onReceived(data: unknown): void {\n const name = (data as { name?: unknown } | null)?.name;\n if (typeof name !== \"string\" || name === \"\" || name === this.nameValue) return;\n\n const existing = this.#typers.get(name);\n if (existing !== undefined) this.#timers.clear(existing);\n const added = existing === undefined;\n this.#typers.set(\n name,\n this.#timers.set(() => this.#untrack(name), this.timeoutValue),\n );\n if (added) this.#render();\n }\n\n #untrack(name: string): void {\n this.#typers.delete(name);\n this.#render();\n }\n\n /** Reflects the typer set onto the hook + live region and emits `change`. */\n #render(): void {\n const names = [...this.#typers.keys()];\n this.element.setAttribute(\"data-typing\", names.length > 0 ? \"true\" : \"false\");\n if (this.hasStatusTarget) {\n this.statusTarget.textContent = this.#message(names);\n }\n this.dispatch(\"change\", { detail: { names } });\n }\n\n /**\n * Builds the live-region copy. Localizable through `data-one` / `data-many`\n * templates on the status target (`%{name}` / `%{names}` / `%{count}`); terse\n * English is the fallback.\n */\n #message(names: string[]): string {\n if (names.length === 0) return \"\";\n const joined = names.join(\", \");\n if (names.length === 1) {\n const template = this.statusTarget.dataset.one;\n const name = names[0] ?? \"\";\n return template ? template.replace(\"%{name}\", name) : `${name} is typing…`;\n }\n const template = this.statusTarget.dataset.many;\n return template\n ? template.replace(\"%{names}\", joined).replace(\"%{count}\", String(names.length))\n : `${joined} are typing…`;\n }\n}\n","import type { Application } from \"@hotwired/stimulus\";\nimport { LiveCounterController } from \"./live_counter_controller\";\nimport { PresenceController } from \"./presence_controller\";\nimport { TypingIndicatorController } from \"./typing_indicator_controller\";\n\nexport {\n type CableConsumer,\n type CableSubscription,\n type CableSubscriptionMixin,\n type ConfirmedCableSubscription,\n createConfirmedSubscription,\n getCableConsumer,\n setCableConsumer,\n} from \"./consumer\";\nexport { LiveCounterController, PresenceController, TypingIndicatorController };\n\n/**\n * Opt-in **server-bound** behaviors for Stimeo — controllers whose state lives\n * on an Action Cable / Turbo stream rather than in client memory: presence,\n * typing indicators, live counters.\n *\n * **Why this is a separate entry point.** The core library is zero-runtime-dep:\n * `import \"stimeo-ui\"` pulls in nothing but `@hotwired/stimulus`. Binding the\n * DOM to a server stream genuinely needs `@rails/actioncable`, so — exactly\n * like `stimeo-ui/positioning` and `@floating-ui/dom` — that cost is opt-in:\n * this module lives at `stimeo-ui/cable`, `@rails/actioncable` is an *optional*\n * peer, and nothing in the core imports it.\n *\n * When the app already owns a consumer (`app/javascript/channels/consumer.js`),\n * hand it over once at boot so no second websocket is opened:\n *\n * ```ts\n * import consumer from \"./channels/consumer\";\n * import { registerCable, setCableConsumer } from \"stimeo-ui/cable\";\n *\n * setCableConsumer(consumer);\n * registerCable(application);\n * ```\n */\nexport const cableControllers = {\n \"stimeo--live-counter\": LiveCounterController,\n \"stimeo--presence\": PresenceController,\n \"stimeo--typing-indicator\": TypingIndicatorController,\n} as const;\n\n/**\n * Registers the opt-in server-bound controllers on a Stimulus Application\n * (additive to `registerStimeo`, mirroring `registerPositioning`).\n */\nexport function registerCable(application: Application): void {\n for (const [identifier, controller] of Object.entries(cableControllers)) {\n application.register(identifier, controller);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/cable/consumer.ts","../../src/cable/live_counter_controller.ts","../../src/utils/safe_timeout.ts","../../src/cable/presence_controller.ts","../../src/cable/typing_indicator_controller.ts","../../src/cable/index.ts"],"names":["Controller","template"],"mappings":"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","file":"index.js","sourcesContent":["import { createConsumer } from \"@rails/actioncable\";\n\n/**\n * Minimal structural view of an Action Cable subscription — the two members the\n * server-bound controllers use. Keeping our own narrow interface (instead of\n * re-exporting the full `@rails/actioncable` types) lets consumers hand us any\n * structurally compatible object, including test doubles.\n */\nexport interface CableSubscription {\n /** Invokes a channel action on the server (`ChannelName#action`). */\n perform(action: string, data?: Record<string, unknown>): void;\n /** Cancels the subscription (the channel's `unsubscribed` runs server-side). */\n unsubscribe(): void;\n}\n\n/** The mixin a controller passes to {@link CableConsumer.subscriptions}' create. */\nexport interface CableSubscriptionMixin {\n /** Called once the subscription is confirmed (`perform` is deliverable). */\n connected?(): void;\n /**\n * Called when the connection drops — perform() is silently undeliverable\n * until Action Cable reconnects and re-confirms (`connected` fires again).\n */\n disconnected?(): void;\n /** Called when the server refuses the subscription (it will never confirm). */\n rejected?(): void;\n /** Called with each broadcast the channel transmits to this client. */\n received?(data: unknown): void;\n}\n\n/** Minimal structural view of an Action Cable consumer (the websocket owner). */\nexport interface CableConsumer {\n subscriptions: {\n create(\n channel: string | Record<string, unknown>,\n mixin: CableSubscriptionMixin,\n ): CableSubscription;\n };\n}\n\n/**\n * The shared consumer. Deliberately module-scoped: an Action Cable consumer is\n * *connection infrastructure* (one websocket per app, the Rails\n * `channels/consumer.js` convention), not UI state — it must survive Turbo\n * navigations, so it is deliberately not rebuilt per `connect()` the way\n * DOM-derived state is.\n */\nlet sharedConsumer: CableConsumer | null = null;\n\n/**\n * Replaces (or clears, with `null`) the shared Action Cable consumer.\n *\n * Call this once at boot when the app already owns a consumer (the usual\n * `app/javascript/channels/consumer.js`), so the server-bound controllers reuse\n * its websocket instead of opening a second one. Tests use it to inject a\n * double. With `null`, the next {@link getCableConsumer} lazily re-creates one.\n */\nexport function setCableConsumer(consumer: CableConsumer | null): void {\n sharedConsumer = consumer;\n}\n\n/**\n * The shared Action Cable consumer, lazily created on first use via\n * `createConsumer()` (which reads the standard `action_cable_meta_tag` URL).\n */\nexport function getCableConsumer(): CableConsumer {\n if (!sharedConsumer) sharedConsumer = createConsumer();\n return sharedConsumer;\n}\n\n/**\n * A {@link CableSubscription} that also tracks its confirmation lifecycle.\n * Action Cable silently drops a `perform()` sent before the subscription is\n * confirmed or while the connection is down — every server-bound controller\n * must therefore gate its sends (and their local side effects: optimistic\n * updates, throttle bookkeeping) on {@link confirmed}.\n */\nexport interface ConfirmedCableSubscription extends CableSubscription {\n /** True while the subscription is confirmed — `perform()` is deliverable. */\n readonly confirmed: boolean;\n /** True once the server refused the subscription (it will never confirm). */\n readonly rejected: boolean;\n}\n\n/**\n * Creates a subscription on the shared consumer with confirmation tracking\n * layered over the caller's mixin: `connected` / `disconnected` / `rejected`\n * flip the {@link ConfirmedCableSubscription.confirmed} flag *before* the\n * caller's own handler runs (so a handler reading `subscription.confirmed`\n * sees the post-transition state), and `received` passes straight through.\n *\n * This is deliberately a *tracker*, not an automatic `perform()` gate: the\n * controllers must skip the local side effects that surround a send (an\n * optimistic DOM bump, a throttle timestamp) together with the send itself,\n * which only the call site can decide — so they check `confirmed` and bail\n * before any of it.\n */\nexport function createConfirmedSubscription(\n channel: string | Record<string, unknown>,\n mixin: CableSubscriptionMixin,\n): ConfirmedCableSubscription {\n let confirmed = false;\n let rejected = false;\n const subscription = getCableConsumer().subscriptions.create(channel, {\n // A refusal is final: Action Cable never confirms a rejected subscription,\n // so a late connected/disconnected (only possible from a misbehaving\n // consumer double) must not reopen the gate `rejected` promised shut.\n connected: () => {\n if (rejected) return;\n confirmed = true;\n mixin.connected?.();\n },\n disconnected: () => {\n if (rejected) return;\n confirmed = false;\n mixin.disconnected?.();\n },\n rejected: () => {\n confirmed = false;\n rejected = true;\n mixin.rejected?.();\n },\n received: (data: unknown) => mixin.received?.(data),\n });\n return {\n perform: (action, data) => subscription.perform(action, data),\n unsubscribe: () => subscription.unsubscribe(),\n get confirmed() {\n return confirmed;\n },\n get rejected() {\n return rejected;\n },\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { type ConfirmedCableSubscription, createConfirmedSubscription } from \"./consumer\";\n\n/**\n * Marker on triggers this controller disabled (mirroring submit-once's\n * marker): `disabled` is a shared attribute, so only marked ones are ever\n * re-enabled — an authored-disabled trigger stays untouched.\n */\nconst DISABLED_MARKER = \"data-live-counter-disabled\";\n\n/**\n * Headless **live counter** — a *server-bound* behavior: a number bound to an\n * Action Cable stream (likes, views, active users) that ticks across every\n * connected client. A local action applies an **optimistic** increment\n * immediately, then reconciles with the broadcast; the own echo of a delta\n * broadcast is deduped so the increment is never applied twice. Ships in the\n * opt-in `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer).\n *\n * Markup contract (identifier: `stimeo--live-counter`):\n * <div data-controller=\"stimeo--live-counter\"\n * data-stimeo--live-counter-channel-value=\"LikesChannel\"\n * data-stimeo--live-counter-params-value='{\"post\":42}'\n * data-stimeo--live-counter-id-value=\"<%= SecureRandom.uuid %>\">\n * <span data-stimeo--live-counter-target=\"value\">128</span>\n * <button type=\"button\" data-action=\"stimeo--live-counter#increment\"\n * data-stimeo--live-counter-target=\"trigger\">♥</button>\n * </div>\n *\n * Wire contract — the broadcast is either **authoritative** or a **delta**:\n * `{ count: 129 }` sets the absolute value (naturally idempotent; preferred —\n * the server owns the number), while `{ delta: 1, by: \"17\" }` adds to it,\n * skipped when `by` matches this client's `id` (the optimistic increment\n * already applied it). `increment` performs `increment` on the channel with\n * `{ id, delta }`; the server persists and broadcasts. Without an own `id`\n * the delta echo is indistinguishable from a foreign delta, so the optimistic\n * bump is skipped and the broadcast applies the increment exactly once — set\n * `id` (any per-client string, e.g. `SecureRandom.uuid`) for optimistic UX.\n *\n * @remarks\n * Behavior only — the displayed number IS the state, and the **DOM is the\n * source of truth**: the server renders the initial count into the `value`\n * target (the element itself without one), so a Turbo cache restore needs no\n * reconciliation and `connect()` only (re)subscribes. Sending tracks the full\n * subscription lifecycle (via the shared confirmation-aware subscription):\n * increments are dropped before confirmation AND while the connection is down\n * (`disconnected` shuts the gate until Action Cable re-confirms), so the\n * display never advances past what the server can receive. A refused\n * subscription publishes the `data-live-counter-rejected` hook (cleared on\n * `connect()` — rejection is transient server state) so the consumer's CSS can\n * disable the trigger. Optional `trigger` targets make that declarative: they\n * carry the real `disabled` attribute exactly while an increment would be\n * dropped (before confirmation, during an outage, after a rejection) — no CSS\n * required, and a disabled form control is announced as such by AT. An\n * authored `disabled` (set by the consumer for its own reasons) is respected:\n * only a disabled this controller applied — tracked via a marker attribute —\n * is ever lifted. Channel-less (local-only) counters never disable their\n * triggers. There are no timers; the subscription is released on\n * `disconnect()` (Turbo navigation included).\n */\nexport class LiveCounterController extends Controller<HTMLElement> {\n static override targets = [\"value\", \"trigger\"];\n static override values = {\n channel: { type: String, default: \"\" },\n params: { type: Object, default: {} },\n id: { type: String, default: \"\" },\n };\n static actions = [\"increment\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly hasValueTarget: boolean;\n declare readonly valueTarget: HTMLElement;\n declare readonly triggerTargets: HTMLElement[];\n declare channelValue: string;\n declare paramsValue: Record<string, unknown>;\n declare idValue: string;\n\n #subscription: ConfirmedCableSubscription | null = null;\n\n override connect(): void {\n // Rejection is transient server state: a Turbo cache snapshot must not\n // resurrect the hook — the fresh subscription below re-decides it.\n this.element.removeAttribute(\"data-live-counter-rejected\");\n if (this.channelValue) {\n this.#subscription = createConfirmedSubscription(\n { channel: this.channelValue, ...this.paramsValue },\n {\n connected: () => this.#syncTriggers(),\n // A drop closes the send window (the shared subscription tracks it)\n // until Action Cable reconnects and re-confirms — an increment during\n // the outage would bump the display while its perform() is silently\n // discarded by the closed socket.\n disconnected: () => this.#syncTriggers(),\n // The server refused the subscription (auth, bad params): the gate\n // stays shut for good, and the hook lets the consumer's CSS disable\n // or hide the trigger instead of leaving a silently dead button.\n rejected: () => {\n this.element.setAttribute(\"data-live-counter-rejected\", \"true\");\n this.#syncTriggers();\n },\n received: (data: unknown) => this.#onReceived(data),\n },\n );\n }\n // Also covers a Turbo cache restore that snapshotted a disabled trigger:\n // the fresh (unconfirmed or absent) subscription re-decides the state.\n this.#syncTriggers();\n }\n\n override disconnect(): void {\n this.#subscription?.unsubscribe();\n this.#subscription = null;\n this.element.removeAttribute(\"data-live-counter-rejected\");\n }\n\n /** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */\n triggerTargetConnected(target: HTMLElement): void {\n this.#syncTrigger(target);\n }\n\n /**\n * Optimistic local increment: bumps the display immediately, then asks the\n * server to persist and broadcast. The step comes from the action param\n * (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.\n */\n increment(event?: Event & { params?: { delta?: number } }): void {\n // Before the confirmed subscription — and while the connection is down —\n // a perform() is silently dropped by Action Cable: bumping the display\n // then would diverge from the server, so the whole increment is dropped\n // (screen and server stay consistent).\n if (this.#subscription && !this.#subscription.confirmed) return;\n // Action params arrive as authored strings; normalize and ignore garbage.\n const raw = Number(event?.params?.delta ?? 1);\n const delta = Number.isFinite(raw) ? raw : 1;\n // Without an own `id` the delta echo cannot be deduped: skip the optimistic\n // bump and let the broadcast apply the increment exactly once. Channel-less\n // (local-only) counters have no echo, so they always bump.\n if (!this.#subscription || this.idValue !== \"\") {\n this.#write(this.#current + delta);\n }\n this.#subscription?.perform(\"increment\", { id: this.idValue, delta });\n }\n\n /** Reconciles a broadcast: absolute `count` wins; own-echo deltas are skipped. */\n #onReceived(data: unknown): void {\n const message = data as { count?: unknown; delta?: unknown; by?: unknown } | null;\n if (typeof message?.count === \"number\") {\n this.#write(message.count);\n return;\n }\n if (typeof message?.delta === \"number\") {\n // The own echo: this client already applied the delta optimistically.\n if (typeof message.by === \"string\" && message.by !== \"\" && message.by === this.idValue) {\n return;\n }\n this.#write(this.#current + message.delta);\n }\n }\n\n /** True while an increment would go through (channel-less counters always are). */\n get #ready(): boolean {\n return !this.#subscription || this.#subscription.confirmed;\n }\n\n /**\n * Reflects the send gate onto the optional `trigger` targets as the real\n * `disabled` attribute — the declarative alternative to styling off the\n * `data-live-counter-rejected` hook (a disabled control is also skipped by\n * keyboard focus and announced by AT, which CSS alone cannot do).\n */\n #syncTriggers(): void {\n for (const trigger of this.triggerTargets) this.#syncTrigger(trigger);\n }\n\n /**\n * `disabled` is a shared attribute: only disable what is currently enabled\n * (marking it ours), and only lift a disabled carrying our marker — so an\n * authored-disabled trigger (\"disabled until valid\", say) is never\n * re-enabled by the gate. A marked disabled restored from a Turbo cache\n * snapshot is recognized as ours and lifted once the gate opens.\n */\n #syncTrigger(trigger: HTMLElement): void {\n if (this.#ready) {\n if (trigger.hasAttribute(DISABLED_MARKER)) {\n trigger.removeAttribute(\"disabled\");\n trigger.removeAttribute(DISABLED_MARKER);\n }\n } else if (!trigger.hasAttribute(\"disabled\")) {\n trigger.setAttribute(\"disabled\", \"\");\n trigger.setAttribute(DISABLED_MARKER, \"\");\n }\n }\n\n /** The displayed element: the `value` target, else the controller element. */\n get #display(): HTMLElement {\n return this.hasValueTarget ? this.valueTarget : this.element;\n }\n\n /** The current count, parsed from the DOM (the single source of truth). */\n get #current(): number {\n // Strip separators/suffixes (\"1,200 likes\") like count-up, so a formatted\n // server-rendered value doesn't collapse to its first digit group.\n const parsed = Number.parseInt((this.#display.textContent ?? \"\").replace(/[^0-9-]/g, \"\"), 10);\n return Number.isNaN(parsed) ? 0 : parsed;\n }\n\n #write(count: number): void {\n if (count === this.#current) return;\n this.#display.textContent = String(count);\n this.dispatch(\"change\", { detail: { count } });\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeInterval, SafeTimeout } from \"../utils/safe_timeout\";\nimport { type ConfirmedCableSubscription, createConfirmedSubscription } from \"./consumer\";\n\n/** A peer currently present (another client in the same room). */\ninterface Peer {\n name: string;\n /** Auto-expiry timer id, restarted by every beacon. */\n timer: number;\n}\n\n/**\n * Minimum ms between outgoing beacons. The steady state is the `heartbeat`\n * interval; this guard only bounds the *extra* beacons sent in response to\n * newly seen peers (see the roster-convergence note in the class doc), so a\n * burst of joiners cannot make this client flood the channel.\n */\nconst BEACON_THROTTLE_MS = 2000;\n\n/**\n * Headless **presence** — a *server-bound* behavior: online dots / a\n * \"who's viewing this\" stack, bound to an Action Cable channel. Like\n * `stimeo--typing-indicator`, the server stays a trivial rebroadcast channel\n * with **zero presence state**: every client heartbeats an `appear` beacon\n * (`{ id, name }`), and each client expires peers it has not heard from for\n * `timeout` ms. Leaving (`{ id, leaving: true }`) is broadcast best-effort on\n * `disconnect()` and on `pagehide` (tab close / hard navigation, where\n * `disconnect()` never runs); a lost notice is caught by the expiry. Ships in\n * the opt-in `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer).\n *\n * Roster convergence: a late joiner would otherwise see peers only as their\n * next heartbeats arrive, so on hearing a beacon from an *unknown* peer, each\n * client re-announces itself (throttled to {@link BEACON_THROTTLE_MS}) — the\n * roster converges in one round-trip instead of one heartbeat period.\n *\n * Markup contract (identifier: `stimeo--presence`):\n * <div data-controller=\"stimeo--presence\"\n * data-stimeo--presence-channel-value=\"PresenceChannel\"\n * data-stimeo--presence-params-value='{\"room\":\"doc_7\"}'\n * data-stimeo--presence-id-value=\"17\" data-stimeo--presence-name-value=\"Alice\">\n * <span data-stimeo--presence-target=\"count\" data-other=\"%{count} viewing\"></span>\n * <ul data-stimeo--presence-target=\"list\"></ul>\n * <template data-stimeo--presence-target=\"template\">\n * <li><span data-presence-name></span></li>\n * </template>\n * </div>\n *\n * Server contract (a trivial rebroadcast channel):\n * class PresenceChannel < ApplicationCable::Channel\n * def subscribed = stream_from \"presence:#{params[:room]}\"\n * def appear(data) = ActionCable.server.broadcast(\"presence:#{params[:room]}\",\n * { id: data[\"id\"], name: data[\"name\"], leaving: data[\"leaving\"] })\n * end\n *\n * @remarks\n * Behavior only — the dot/stack look is the consumer's CSS, keyed off the\n * `data-present` / `data-present-count` hooks. The roster counts and renders\n * **other** clients (`id` ≠ own `id`); rendering is optional: given a `list` +\n * `template` pair, one clone per peer is appended (elements marked\n * `data-presence-name` receive the name; the clone root is tagged\n * `data-presence-id`), and the `count` target renders through localizable\n * `data-zero` / `data-one` / `data-other` templates (`%{count}`). Richer\n * per-user rendering (avatars, links) belongs to the consumer via the `join` /\n * `leave` / `change` events or a server-rendered Turbo Stream. The `id`\n * comparison is display-level echo suppression, not authentication — identity\n * belongs to the server. Sending tracks the full subscription lifecycle (via\n * the shared confirmation-aware subscription): beacons — heartbeats, the\n * convergence answer, the leaving notice — are dropped before confirmation and\n * while the connection is down, where Action Cable would discard them anyway\n * (and a discarded beacon must not burn the convergence throttle); `connected`\n * re-fires on every reconnect and force-beacons, so the roster self-heals. A\n * refused subscription publishes the `data-presence-rejected` hook (cleared on\n * `connect()` — rejection is transient server state), mirroring\n * `data-live-counter-rejected`. Presence is transient: `connect()` clears\n * whatever a Turbo cache snapshot preserved (hooks + rendered clones), renders\n * the known-empty count (the `data-present*` hooks stay absent until the first\n * beacon), and the stream re-populates; the subscription, heartbeat interval,\n * per-peer expiry timers, and the `pagehide` listener are all released on\n * `disconnect()` (Turbo navigation included).\n */\nexport class PresenceController extends Controller<HTMLElement> {\n static override targets = [\"count\", \"list\", \"template\"];\n static override values = {\n channel: { type: String, default: \"\" },\n params: { type: Object, default: {} },\n id: { type: String, default: \"\" },\n name: { type: String, default: \"\" },\n heartbeat: { type: Number, default: 15_000 },\n timeout: { type: Number, default: 40_000 },\n };\n static events = [\"join\", \"leave\", \"change\"] as const;\n\n declare readonly hasCountTarget: boolean;\n declare readonly countTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly listTarget: HTMLElement;\n declare readonly hasTemplateTarget: boolean;\n declare readonly templateTarget: HTMLTemplateElement;\n declare channelValue: string;\n declare paramsValue: Record<string, unknown>;\n declare idValue: string;\n declare nameValue: string;\n declare heartbeatValue: number;\n declare timeoutValue: number;\n\n #subscription: ConfirmedCableSubscription | null = null;\n /** Present peers keyed by id (insertion order = join order). */\n readonly #peers = new Map<string, Peer>();\n readonly #timers = new SafeTimeout();\n readonly #intervals = new SafeInterval();\n /** Epoch ms of the last outgoing beacon, for the convergence throttle. */\n #lastBeaconAt = 0;\n /** Pending trailing-edge convergence beacon (at most one queued). */\n #pendingBeacon: number | null = null;\n\n override connect(): void {\n // Presence is transient: drop whatever a Turbo cache snapshot preserved\n // (hooks + rendered clones); the live stream re-populates the roster.\n // Rejection is transient server state too — the fresh subscription below\n // re-decides the hook.\n this.#reset();\n this.element.removeAttribute(\"data-presence-rejected\");\n // The roster is known-empty here, so the count target can say so right\n // away instead of sitting blank until the first roster change. The\n // data-present* hooks intentionally stay absent until the first beacon.\n if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);\n\n if (!this.channelValue) return;\n this.#subscription = createConfirmedSubscription(\n { channel: this.channelValue, ...this.paramsValue },\n {\n // The first beacon must wait for the confirmed subscription — a\n // perform() before that is silently dropped by Action Cable. Fires\n // again on every reconnect, so the roster self-heals after an outage.\n connected: () => this.#beacon(true),\n // The server refused the subscription: no beacon will ever go through,\n // and the hook lets the consumer's CSS reflect the dead stream.\n rejected: () => {\n this.element.setAttribute(\"data-presence-rejected\", \"true\");\n },\n received: (data: unknown) => this.#onReceived(data),\n },\n );\n this.#intervals.set(() => this.#beacon(true), this.heartbeatValue);\n window.addEventListener(\"pagehide\", this.#onPageHide);\n }\n\n override disconnect(): void {\n window.removeEventListener(\"pagehide\", this.#onPageHide);\n // Best-effort graceful leave; a lost notice is caught by peers' expiry\n // timers instead.\n this.#sendLeaveNotice();\n this.#subscription?.unsubscribe();\n this.#subscription = null;\n this.#intervals.clearAll();\n this.#reset();\n this.element.removeAttribute(\"data-presence-rejected\");\n }\n\n /**\n * Sends the best-effort leaving notice (skipped without an own `id`, and\n * outside the confirmed window, where Action Cable would discard it anyway).\n */\n #sendLeaveNotice(): void {\n if (!this.#subscription?.confirmed || !this.idValue) return;\n this.#subscription.perform(\"appear\", { id: this.idValue, leaving: true });\n }\n\n /**\n * `pagehide` covers the leaves `disconnect()` cannot see: closing the tab or\n * a hard (non-Turbo) navigation destroys the page without running Stimulus\n * teardown, so this listener is the only chance to announce them. Best-effort\n * by nature (the socket may close before the frame flushes); peers' expiry\n * timers stay the safety net. If the page enters the bfcache and is restored\n * instead, the next heartbeat re-announces this client, so an over-eager\n * leave self-heals.\n */\n readonly #onPageHide = (): void => {\n this.#sendLeaveNotice();\n };\n\n /**\n * Broadcasts this client's beacon (skipped without an own `id`). A throttled\n * convergence answer is deferred to the trailing edge rather than dropped —\n * otherwise a peer joining right after a heartbeat would not learn about\n * this client until the next full heartbeat period.\n *\n * Gated on the confirmed subscription: before confirmation and during an\n * outage Action Cable silently discards perform(), so a beacon sent then is\n * pure waste — worse, it would burn `#lastBeaconAt` and delay the next real\n * convergence answer by up to the throttle window. The heartbeat interval\n * keeps ticking regardless; `connected` re-fires on reconfirm and\n * force-beacons immediately, so a gated tick is never missed for long.\n */\n #beacon(force: boolean): void {\n if (!this.#subscription?.confirmed || !this.idValue) return;\n const now = Date.now();\n const wait = BEACON_THROTTLE_MS - (now - this.#lastBeaconAt);\n if (!force && wait > 0) {\n if (this.#pendingBeacon === null) {\n this.#pendingBeacon = this.#timers.set(() => {\n this.#pendingBeacon = null;\n this.#beacon(true);\n }, wait);\n }\n return;\n }\n this.#lastBeaconAt = now;\n this.#subscription.perform(\"appear\", { id: this.idValue, name: this.nameValue });\n }\n\n /**\n * Tracks a broadcast beacon: upserts the peer (restarting its expiry timer),\n * removes it on a `leaving` notice, and re-announces this client when the\n * peer was unknown (roster convergence — see the class doc).\n */\n #onReceived(data: unknown): void {\n const beacon = data as { id?: unknown; name?: unknown; leaving?: unknown } | null;\n const id = beacon?.id;\n if (typeof id !== \"string\" || id === \"\" || id === this.idValue) return;\n\n if (beacon?.leaving === true) {\n this.#drop(id);\n return;\n }\n\n const name = typeof beacon?.name === \"string\" ? beacon.name : \"\";\n const existing = this.#peers.get(id);\n if (existing !== undefined) this.#timers.clear(existing.timer);\n const timer = this.#timers.set(() => this.#drop(id), this.timeoutValue);\n this.#peers.set(id, { name, timer });\n\n if (existing === undefined) {\n this.#appendClone(id, name);\n this.#render();\n this.dispatch(\"join\", { detail: { id, name } });\n this.#beacon(false); // answer an unknown peer so its roster converges\n } else if (existing.name !== name) {\n this.#updateClone(id, name);\n this.#render();\n }\n }\n\n /** Removes a peer (expiry or graceful leave) and reflects the change. */\n #drop(id: string): void {\n const peer = this.#peers.get(id);\n if (peer === undefined) return;\n this.#timers.clear(peer.timer);\n this.#peers.delete(id);\n this.#removeClone(id);\n this.#render();\n this.dispatch(\"leave\", { detail: { id } });\n }\n\n /** Reflects the roster onto the hooks + count target and emits `change`. */\n #render(): void {\n const users = [...this.#peers.entries()].map(([id, peer]) => ({ id, name: peer.name }));\n this.element.setAttribute(\"data-present\", users.length > 0 ? \"true\" : \"false\");\n this.element.setAttribute(\"data-present-count\", String(users.length));\n if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);\n this.dispatch(\"change\", { detail: { users } });\n }\n\n /**\n * Builds the count copy. Localizable through `data-zero` / `data-one` /\n * `data-other` templates on the count target (`%{count}`); the bare number is\n * the fallback (copy-free, so nothing to localize by default).\n */\n #countMessage(count: number): string {\n const templates = this.countTarget.dataset;\n const template =\n (count === 0 ? templates.zero : count === 1 ? templates.one : templates.other) ??\n templates.other;\n return template ? template.replace(\"%{count}\", String(count)) : String(count);\n }\n\n /** Appends one template clone for a newly present peer (list + template only). */\n #appendClone(id: string, name: string): void {\n if (!this.hasListTarget || !this.hasTemplateTarget) return;\n const clone = this.templateTarget.content.cloneNode(true) as DocumentFragment;\n const root = clone.firstElementChild;\n if (!root) return;\n root.setAttribute(\"data-presence-id\", id);\n this.#fillName(root, name);\n this.listTarget.appendChild(clone);\n }\n\n #updateClone(id: string, name: string): void {\n const root = this.#cloneFor(id);\n if (root) this.#fillName(root, name);\n }\n\n #removeClone(id: string): void {\n this.#cloneFor(id)?.remove();\n }\n\n #cloneFor(id: string): Element | null {\n if (!this.hasListTarget) return null;\n // Attribute selectors cannot escape arbitrary ids reliably; match manually.\n for (const child of this.listTarget.querySelectorAll(\"[data-presence-id]\")) {\n if (child.getAttribute(\"data-presence-id\") === id) return child;\n }\n return null;\n }\n\n /** Writes the peer's name into the clone's `data-presence-name` slots. */\n #fillName(root: Element, name: string): void {\n const slots = root.querySelectorAll(\"[data-presence-name]\");\n for (const slot of slots) slot.textContent = name;\n if (slots.length === 0 && root.hasAttribute(\"data-presence-name\")) {\n root.textContent = name;\n }\n }\n\n /** Clears the transient roster state (connect reset + disconnect teardown). */\n #reset(): void {\n this.#timers.clearAll();\n this.#pendingBeacon = null;\n this.#peers.clear();\n this.#lastBeaconAt = 0;\n this.element.removeAttribute(\"data-present\");\n this.element.removeAttribute(\"data-present-count\");\n if (this.hasCountTarget) this.countTarget.textContent = \"\";\n if (this.hasListTarget) {\n for (const child of this.listTarget.querySelectorAll(\"[data-presence-id]\")) {\n child.remove();\n }\n }\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { type ConfirmedCableSubscription, createConfirmedSubscription } from \"./consumer\";\n\n/**\n * Headless **typing indicator** — a *server-bound* behavior: a controller whose\n * state lives on the server stream, not in client memory. Typing in the composer\n * throttle-broadcasts a `typing` signal over an Action Cable channel; received\n * signals from *other* clients render \"X is typing…\" into a `status` live region\n * and auto-clear after `timeout` ms of silence. The whole behavior is HTML + a\n * broadcast — no app JS, no client store, no reconciler. Ships in the opt-in\n * `stimeo-ui/cable` subpath (`@rails/actioncable` optional peer); the core stays\n * zero-dep.\n *\n * Markup contract (identifier: `stimeo--typing-indicator`):\n * <div data-controller=\"stimeo--typing-indicator\"\n * data-stimeo--typing-indicator-channel-value=\"TypingChannel\"\n * data-stimeo--typing-indicator-params-value='{\"room\":\"chat_42\"}'\n * data-stimeo--typing-indicator-name-value=\"Alice\">\n * <textarea data-stimeo--typing-indicator-target=\"input\"></textarea>\n * <p role=\"status\" data-stimeo--typing-indicator-target=\"status\"\n * data-one=\"%{name} is typing…\" data-many=\"%{names} are typing…\"></p>\n * </div>\n *\n * Server contract (a trivial rebroadcast channel):\n * class TypingChannel < ApplicationCable::Channel\n * def subscribed = stream_from \"typing:#{params[:room]}\"\n * def typing(data) = ActionCable.server.broadcast(\"typing:#{params[:room]}\",\n * { name: data[\"name\"] })\n * end\n *\n * @remarks\n * Behavior only — the indicator's look is the consumer's CSS, keyed off the\n * `data-typing` hook; the copy is localizable through the `data-one` /\n * `data-many` templates (`%{name}` / `%{names}` / `%{count}`, terse English\n * fallback — the same channel design as `stimeo--sortable`). The own echo is\n * dropped by comparing the broadcast `name` against `name` (a same-name guard,\n * not authentication — identity belongs to the server). Sending tracks the full\n * subscription lifecycle (via the shared confirmation-aware subscription):\n * signals are dropped before confirmation and while the connection is down, so\n * an outage never burns the throttle window on undeliverable sends. A refused\n * subscription publishes the `data-typing-indicator-rejected` hook (cleared on\n * `connect()` — rejection is transient server state), mirroring\n * `data-live-counter-rejected`. Typing state is transient by nature:\n * `connect()` resets the hook and the live region (a Turbo cache snapshot must\n * not resurrect a stale \"X is typing…\"), and re-population happens naturally\n * from the stream. The subscription, the per-typer timers, and the delegated\n * `input` listener are all released on `disconnect()` (Turbo navigation\n * included).\n */\nexport class TypingIndicatorController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"status\"];\n static override values = {\n channel: { type: String, default: \"\" },\n params: { type: Object, default: {} },\n name: { type: String, default: \"\" },\n timeout: { type: Number, default: 3000 },\n // Must stay below `timeout`: the throttle is leading-edge only (no trailing\n // send), so a receiver's display survives continuous typing only while a\n // fresh signal lands within its timeout window.\n throttle: { type: Number, default: 2000 },\n };\n static events = [\"change\"] as const;\n\n declare readonly hasStatusTarget: boolean;\n declare readonly statusTarget: HTMLElement;\n declare channelValue: string;\n declare paramsValue: Record<string, unknown>;\n declare nameValue: string;\n declare timeoutValue: number;\n declare throttleValue: number;\n\n #subscription: ConfirmedCableSubscription | null = null;\n /** Names currently typing (other clients), each with its auto-clear timer id. */\n readonly #typers = new Map<string, number>();\n readonly #timers = new SafeTimeout();\n /** Epoch ms of the last broadcast, for leading-edge throttling. */\n #lastSentAt = 0;\n\n override connect(): void {\n // Typing state is transient: a Turbo cache snapshot must not resurrect a\n // stale indicator, and the live stream re-populates naturally. Rejection is\n // transient server state too — the fresh subscription re-decides the hook.\n this.element.removeAttribute(\"data-typing\");\n this.element.removeAttribute(\"data-typing-indicator-rejected\");\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n\n // Delegated on the container so the composer needs no per-input data-action\n // (and swapped/appended inputs keep working).\n this.element.addEventListener(\"input\", this.#onInput);\n if (this.channelValue) {\n // Confirmation tracking (connected / disconnected / rejected) lives in\n // the shared subscription; #onInput gates on its `confirmed` so an\n // outage doesn't burn the throttle window on dropped sends.\n this.#subscription = createConfirmedSubscription(\n { channel: this.channelValue, ...this.paramsValue },\n {\n // The server refused the subscription: the send gate stays shut for\n // good, and the hook lets the consumer's CSS reflect the dead stream.\n rejected: () => {\n this.element.setAttribute(\"data-typing-indicator-rejected\", \"true\");\n },\n received: (data: unknown) => this.#onReceived(data),\n },\n );\n }\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onInput);\n this.#subscription?.unsubscribe();\n this.#subscription = null;\n this.#timers.clearAll();\n this.#typers.clear();\n this.element.removeAttribute(\"data-typing\");\n this.element.removeAttribute(\"data-typing-indicator-rejected\");\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n this.#lastSentAt = 0;\n }\n\n /** Throttled (leading-edge) broadcast of this client's typing signal. */\n readonly #onInput = (): void => {\n // Before the confirmed subscription a perform() is silently dropped by\n // Action Cable — and recording #lastSentAt would ALSO throttle away the\n // first real send, so bail without touching the throttle state.\n if (!this.#subscription?.confirmed) return;\n const now = Date.now();\n if (now - this.#lastSentAt < this.throttleValue) return;\n this.#lastSentAt = now;\n this.#subscription.perform(\"typing\", { name: this.nameValue });\n };\n\n /**\n * Tracks a broadcast typer. The own echo is dropped (same `name`); every\n * further signal from a name restarts its auto-clear timer, so the indicator\n * survives continuous typing and clears `timeout` ms after the last signal.\n */\n #onReceived(data: unknown): void {\n const name = (data as { name?: unknown } | null)?.name;\n if (typeof name !== \"string\" || name === \"\" || name === this.nameValue) return;\n\n const existing = this.#typers.get(name);\n if (existing !== undefined) this.#timers.clear(existing);\n const added = existing === undefined;\n this.#typers.set(\n name,\n this.#timers.set(() => this.#untrack(name), this.timeoutValue),\n );\n if (added) this.#render();\n }\n\n #untrack(name: string): void {\n this.#typers.delete(name);\n this.#render();\n }\n\n /** Reflects the typer set onto the hook + live region and emits `change`. */\n #render(): void {\n const names = [...this.#typers.keys()];\n this.element.setAttribute(\"data-typing\", names.length > 0 ? \"true\" : \"false\");\n if (this.hasStatusTarget) {\n this.statusTarget.textContent = this.#message(names);\n }\n this.dispatch(\"change\", { detail: { names } });\n }\n\n /**\n * Builds the live-region copy. Localizable through `data-one` / `data-many`\n * templates on the status target (`%{name}` / `%{names}` / `%{count}`); terse\n * English is the fallback.\n */\n #message(names: string[]): string {\n if (names.length === 0) return \"\";\n const joined = names.join(\", \");\n if (names.length === 1) {\n const template = this.statusTarget.dataset.one;\n const name = names[0] ?? \"\";\n return template ? template.replace(\"%{name}\", name) : `${name} is typing…`;\n }\n const template = this.statusTarget.dataset.many;\n return template\n ? template.replace(\"%{names}\", joined).replace(\"%{count}\", String(names.length))\n : `${joined} are typing…`;\n }\n}\n","import type { Application } from \"@hotwired/stimulus\";\nimport { LiveCounterController } from \"./live_counter_controller\";\nimport { PresenceController } from \"./presence_controller\";\nimport { TypingIndicatorController } from \"./typing_indicator_controller\";\n\nexport {\n type CableConsumer,\n type CableSubscription,\n type CableSubscriptionMixin,\n type ConfirmedCableSubscription,\n createConfirmedSubscription,\n getCableConsumer,\n setCableConsumer,\n} from \"./consumer\";\nexport { LiveCounterController, PresenceController, TypingIndicatorController };\n\n/**\n * Opt-in **server-bound** behaviors for Stimeo — controllers whose state lives\n * on an Action Cable / Turbo stream rather than in client memory: presence,\n * typing indicators, live counters.\n *\n * **Why this is a separate entry point.** The core library is zero-runtime-dep:\n * `import \"stimeo-ui\"` pulls in nothing but `@hotwired/stimulus`. Binding the\n * DOM to a server stream genuinely needs `@rails/actioncable`, so — exactly\n * like `stimeo-ui/positioning` and `@floating-ui/dom` — that cost is opt-in:\n * this module lives at `stimeo-ui/cable`, `@rails/actioncable` is an *optional*\n * peer, and nothing in the core imports it.\n *\n * When the app already owns a consumer (`app/javascript/channels/consumer.js`),\n * hand it over once at boot so no second websocket is opened:\n *\n * ```ts\n * import consumer from \"./channels/consumer\";\n * import { registerCable, setCableConsumer } from \"stimeo-ui/cable\";\n *\n * setCableConsumer(consumer);\n * registerCable(application);\n * ```\n */\nexport const cableControllers = {\n \"stimeo--live-counter\": LiveCounterController,\n \"stimeo--presence\": PresenceController,\n \"stimeo--typing-indicator\": TypingIndicatorController,\n} as const;\n\n/**\n * Registers the opt-in server-bound controllers on a Stimulus Application\n * (additive to `registerStimeo`, mirroring `registerPositioning`).\n */\nexport function registerCable(application: Application): void {\n for (const [identifier, controller] of Object.entries(cableControllers)) {\n application.register(identifier, controller);\n }\n}\n"]}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/accordion_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
// src/controllers/accordion_controller.ts
|
|
4
12
|
var AccordionController = class extends Controller {
|
|
5
13
|
static targets = ["trigger", "panel"];
|
|
@@ -39,6 +47,8 @@ var AccordionController = class extends Controller {
|
|
|
39
47
|
* `.focus()` lands on nothing and navigation appears to stall.
|
|
40
48
|
*/
|
|
41
49
|
onKeydown(event) {
|
|
50
|
+
if (event.defaultPrevented) return;
|
|
51
|
+
if (isReservedArrowChord(event)) return;
|
|
42
52
|
const current = event.currentTarget;
|
|
43
53
|
if (this.triggerTargets.indexOf(current) === -1) return;
|
|
44
54
|
const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/accordion_controller.ts"],"names":[],"mappings":"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","file":"accordion_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible accordion behavior.\n *\n * Markup contract (identifier: `stimeo--accordion`):\n * <div data-controller=\"stimeo--accordion\">\n * <h3>\n * <button id=\"trigger-1\"\n * data-stimeo--accordion-target=\"trigger\"\n * data-action=\"stimeo--accordion#toggle\n * keydown->stimeo--accordion#onKeydown\"\n * aria-expanded=\"false\" aria-controls=\"panel-1\">Section 1</button>\n * </h3>\n * <div id=\"panel-1\" data-stimeo--accordion-target=\"panel\"\n * role=\"region\" aria-labelledby=\"trigger-1\" hidden>…</div>\n * <!-- repeat header/panel pairs -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is\n * associated with its panel through `aria-controls`; the panel's `id` is the\n * single source of truth for the pairing, so headers and panels need not be\n * adjacent siblings.\n *\n * @remarks\n * Multiple panels may be open at once (this is the APG-allowed default). State\n * is reflected through `aria-expanded` on the header and the `hidden` attribute\n * on the panel — never through visual styling, which the consumer owns.\n *\n * Behavior provided:\n * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).\n * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the\n * first/last header. Hidden headers (including those inside a hidden subtree,\n * e.g. a section a filter collapsed) are skipped so focus stays perceivable.\n * - {@link expandAll} / {@link collapseAll} open or close every panel at once,\n * for an optional \"expand all / collapse all\" control pair anywhere in scope.\n */\nexport class AccordionController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static actions = [\"collapseAll\", \"expandAll\", \"onKeydown\", \"toggle\"] as const;\n\n declare readonly triggerTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Toggles the panel controlled by the activated header. */\n toggle(event: Event): void {\n const trigger = event.currentTarget as HTMLButtonElement;\n const panel = this.#panelFor(trigger);\n if (!panel) return;\n\n this.#setExpanded(trigger, panel, trigger.getAttribute(\"aria-expanded\") !== \"true\");\n }\n\n /** Opens every panel. Bound via `data-action` on an \"expand all\" control. */\n expandAll(): void {\n this.#setAll(true);\n }\n\n /** Closes every panel. Bound via `data-action` on a \"collapse all\" control. */\n collapseAll(): void {\n this.#setAll(false);\n }\n\n /** Drives every header/panel pair to the same expanded state. */\n #setAll(open: boolean): void {\n for (const trigger of this.triggerTargets) {\n const panel = this.#panelFor(trigger);\n if (panel) this.#setExpanded(trigger, panel, open);\n }\n }\n\n /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */\n #setExpanded(trigger: HTMLButtonElement, panel: HTMLElement, open: boolean): void {\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n panel.hidden = !open;\n }\n\n /**\n * Moves focus between headers per the APG keyboard model, skipping any header\n * that is hidden or nested in a hidden subtree. A consumer may hide whole\n * sections (e.g. an `stimeo--filter` that collapses empty groups), and an\n * unperceivable header must never become an arrow-key target — otherwise\n * `.focus()` lands on nothing and navigation appears to stall.\n */\n onKeydown(event: KeyboardEvent): void {\n const current = event.currentTarget as HTMLButtonElement;\n if (this.triggerTargets.indexOf(current) === -1) return;\n\n // `closest(\"[hidden]\")` catches both a directly-hidden header and one inside a\n // hidden ancestor (the filter-group case); navigate over the visible set only.\n const navigable = this.triggerTargets.filter((trigger) => trigger.closest(\"[hidden]\") === null);\n const here = navigable.indexOf(current);\n if (here === -1) return;\n\n let next: HTMLButtonElement | undefined;\n switch (event.key) {\n case \"ArrowDown\":\n next = navigable[(here + 1) % navigable.length];\n break;\n case \"ArrowUp\":\n next = navigable[(here - 1 + navigable.length) % navigable.length];\n break;\n case \"Home\":\n next = navigable[0];\n break;\n case \"End\":\n next = navigable[navigable.length - 1];\n break;\n default:\n return;\n }\n\n event.preventDefault();\n next?.focus();\n }\n\n /** Resolves the panel a header controls via its `aria-controls` reference. */\n #panelFor(trigger: HTMLButtonElement): HTMLElement | null {\n const id = trigger.getAttribute(\"aria-controls\");\n return id ? (this.panelTargets.find((panel) => panel.id === id) ?? null) : null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/controllers/accordion_controller.ts"],"names":[],"mappings":"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","file":"accordion_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\n\n/**\n * Headless, accessible accordion behavior.\n *\n * Markup contract (identifier: `stimeo--accordion`):\n * <div data-controller=\"stimeo--accordion\">\n * <h3>\n * <button id=\"trigger-1\"\n * data-stimeo--accordion-target=\"trigger\"\n * data-action=\"stimeo--accordion#toggle\n * keydown->stimeo--accordion#onKeydown\"\n * aria-expanded=\"false\" aria-controls=\"panel-1\">Section 1</button>\n * </h3>\n * <div id=\"panel-1\" data-stimeo--accordion-target=\"panel\"\n * role=\"region\" aria-labelledby=\"trigger-1\" hidden>…</div>\n * <!-- repeat header/panel pairs -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is\n * associated with its panel through `aria-controls`; the panel's `id` is the\n * single source of truth for the pairing, so headers and panels need not be\n * adjacent siblings.\n *\n * @remarks\n * Multiple panels may be open at once (this is the APG-allowed default). State\n * is reflected through `aria-expanded` on the header and the `hidden` attribute\n * on the panel — never through visual styling, which the consumer owns.\n *\n * Behavior provided:\n * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).\n * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the\n * first/last header. Hidden headers (including those inside a hidden subtree,\n * e.g. a section a filter collapsed) are skipped so focus stays perceivable.\n * - {@link expandAll} / {@link collapseAll} open or close every panel at once,\n * for an optional \"expand all / collapse all\" control pair anywhere in scope.\n */\nexport class AccordionController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static actions = [\"collapseAll\", \"expandAll\", \"onKeydown\", \"toggle\"] as const;\n\n declare readonly triggerTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Toggles the panel controlled by the activated header. */\n toggle(event: Event): void {\n const trigger = event.currentTarget as HTMLButtonElement;\n const panel = this.#panelFor(trigger);\n if (!panel) return;\n\n this.#setExpanded(trigger, panel, trigger.getAttribute(\"aria-expanded\") !== \"true\");\n }\n\n /** Opens every panel. Bound via `data-action` on an \"expand all\" control. */\n expandAll(): void {\n this.#setAll(true);\n }\n\n /** Closes every panel. Bound via `data-action` on a \"collapse all\" control. */\n collapseAll(): void {\n this.#setAll(false);\n }\n\n /** Drives every header/panel pair to the same expanded state. */\n #setAll(open: boolean): void {\n for (const trigger of this.triggerTargets) {\n const panel = this.#panelFor(trigger);\n if (panel) this.#setExpanded(trigger, panel, open);\n }\n }\n\n /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */\n #setExpanded(trigger: HTMLButtonElement, panel: HTMLElement, open: boolean): void {\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n panel.hidden = !open;\n }\n\n /**\n * Moves focus between headers per the APG keyboard model, skipping any header\n * that is hidden or nested in a hidden subtree. A consumer may hide whole\n * sections (e.g. an `stimeo--filter` that collapses empty groups), and an\n * unperceivable header must never become an arrow-key target — otherwise\n * `.focus()` lands on nothing and navigation appears to stall.\n */\n onKeydown(event: KeyboardEvent): void {\n // A descendant widget that already claimed the key (a grabbed drag handle, a\n // nested menu) must not ALSO act on it — composition depends on this yield.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const current = event.currentTarget as HTMLButtonElement;\n if (this.triggerTargets.indexOf(current) === -1) return;\n\n // `closest(\"[hidden]\")` catches both a directly-hidden header and one inside a\n // hidden ancestor (the filter-group case); navigate over the visible set only.\n const navigable = this.triggerTargets.filter((trigger) => trigger.closest(\"[hidden]\") === null);\n const here = navigable.indexOf(current);\n if (here === -1) return;\n\n let next: HTMLButtonElement | undefined;\n switch (event.key) {\n case \"ArrowDown\":\n next = navigable[(here + 1) % navigable.length];\n break;\n case \"ArrowUp\":\n next = navigable[(here - 1 + navigable.length) % navigable.length];\n break;\n case \"Home\":\n next = navigable[0];\n break;\n case \"End\":\n next = navigable[navigable.length - 1];\n break;\n default:\n return;\n }\n\n event.preventDefault();\n next?.focus();\n }\n\n /** Resolves the panel a header controls via its `aria-controls` reference. */\n #panelFor(trigger: HTMLButtonElement): HTMLElement | null {\n const id = trigger.getAttribute(\"aria-controls\");\n return id ? (this.panelTargets.find((panel) => panel.id === id) ?? null) : null;\n }\n}\n"]}
|