smriti 0.5.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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +168 -0
  4. data/Rakefile +15 -0
  5. data/app/assets/images/smriti/android-chrome-192x192.png +0 -0
  6. data/app/assets/images/smriti/android-chrome-512x512.png +0 -0
  7. data/app/assets/images/smriti/apple-touch-icon.png +0 -0
  8. data/app/assets/images/smriti/favicon-16x16.png +0 -0
  9. data/app/assets/images/smriti/favicon-32x32.png +0 -0
  10. data/app/assets/images/smriti/favicon-48x48.png +0 -0
  11. data/app/assets/images/smriti/favicon.ico +0 -0
  12. data/app/assets/images/smriti/favicon.svg +18 -0
  13. data/app/assets/images/smriti/logo.svg +18 -0
  14. data/app/assets/images/smriti/mask-icon.svg +5 -0
  15. data/app/assets/stylesheets/smriti/application.css +1040 -0
  16. data/app/controllers/smriti/admin/application_controller.rb +135 -0
  17. data/app/controllers/smriti/admin/dashboard_controller.rb +32 -0
  18. data/app/controllers/smriti/admin/mat_view_definitions_controller.rb +372 -0
  19. data/app/controllers/smriti/admin/mat_view_runs_controller.rb +185 -0
  20. data/app/controllers/smriti/admin/preferences_controller.rb +91 -0
  21. data/app/helpers/smriti/admin/datatable_helper.rb +249 -0
  22. data/app/helpers/smriti/admin/localized_digit_helper.rb +70 -0
  23. data/app/helpers/smriti/admin/ui_helper.rb +539 -0
  24. data/app/javascript/smriti/application.js +8 -0
  25. data/app/javascript/smriti/controllers/application.js +10 -0
  26. data/app/javascript/smriti/controllers/body_setup_controller.js +120 -0
  27. data/app/javascript/smriti/controllers/datatable_controller.js +351 -0
  28. data/app/javascript/smriti/controllers/details_controller.js +200 -0
  29. data/app/javascript/smriti/controllers/drawer_controller.js +470 -0
  30. data/app/javascript/smriti/controllers/flash_controller.js +112 -0
  31. data/app/javascript/smriti/controllers/index.js +10 -0
  32. data/app/javascript/smriti/controllers/mv_confirm_controller.js +435 -0
  33. data/app/javascript/smriti/controllers/tabs_controller.js +184 -0
  34. data/app/javascript/smriti/controllers/tooltip_controller.js +525 -0
  35. data/app/javascript/smriti/controllers/turbo_frame_lifecycle_controller.js +342 -0
  36. data/app/jobs/smriti/application_job.rb +144 -0
  37. data/app/jobs/smriti/create_view_job.rb +87 -0
  38. data/app/jobs/smriti/delete_view_job.rb +89 -0
  39. data/app/jobs/smriti/refresh_view_job.rb +94 -0
  40. data/app/models/concerns/smriti_i18n.rb +139 -0
  41. data/app/models/concerns/smriti_paginate.rb +70 -0
  42. data/app/models/concerns/smriti_query_helper.rb +36 -0
  43. data/app/models/smriti/application_record.rb +39 -0
  44. data/app/models/smriti/mat_view_definition.rb +254 -0
  45. data/app/models/smriti/mat_view_run.rb +275 -0
  46. data/app/views/layouts/smriti/_footer.html.erb +47 -0
  47. data/app/views/layouts/smriti/_header.html.erb +25 -0
  48. data/app/views/layouts/smriti/admin.html.erb +47 -0
  49. data/app/views/layouts/smriti/turbo_frame.html.erb +3 -0
  50. data/app/views/smriti/admin/dashboard/index.html.erb +38 -0
  51. data/app/views/smriti/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  52. data/app/views/smriti/admin/mat_view_definitions/_dt-index-empty-row.html.erb +11 -0
  53. data/app/views/smriti/admin/mat_view_definitions/_dt-index-row.html.erb +27 -0
  54. data/app/views/smriti/admin/mat_view_definitions/empty.html.erb +1 -0
  55. data/app/views/smriti/admin/mat_view_definitions/form.html.erb +79 -0
  56. data/app/views/smriti/admin/mat_view_definitions/index.html.erb +10 -0
  57. data/app/views/smriti/admin/mat_view_definitions/show.html.erb +40 -0
  58. data/app/views/smriti/admin/mat_view_runs/_dt-index-empty-row.html.erb +11 -0
  59. data/app/views/smriti/admin/mat_view_runs/_dt-index-row.html.erb +41 -0
  60. data/app/views/smriti/admin/mat_view_runs/index.html.erb +1 -0
  61. data/app/views/smriti/admin/mat_view_runs/show.html.erb +64 -0
  62. data/app/views/smriti/admin/preferences/show.html.erb +49 -0
  63. data/app/views/smriti/admin/ui/_card.html.erb +15 -0
  64. data/app/views/smriti/admin/ui/_datatable.html.erb +34 -0
  65. data/app/views/smriti/admin/ui/_datatable_filters.html.erb +45 -0
  66. data/app/views/smriti/admin/ui/_datatable_tbody.html.erb +11 -0
  67. data/app/views/smriti/admin/ui/_datatable_tfoot.html.erb +70 -0
  68. data/app/views/smriti/admin/ui/_datatable_thead.html.erb +105 -0
  69. data/app/views/smriti/admin/ui/_details.html.erb +10 -0
  70. data/app/views/smriti/admin/ui/_flash.html.erb +6 -0
  71. data/app/views/smriti/admin/ui/_table.html.erb +8 -0
  72. data/config/importmap.rb +9 -0
  73. data/config/locales/ar.yml +223 -0
  74. data/config/locales/de.yml +230 -0
  75. data/config/locales/en-AU-ocker.yml +223 -0
  76. data/config/locales/en-AU.yml +202 -0
  77. data/config/locales/en-BORK.yml +225 -0
  78. data/config/locales/en-CA.yml +223 -0
  79. data/config/locales/en-GB.yml +223 -0
  80. data/config/locales/en-LOL.yml +219 -0
  81. data/config/locales/en-SCOT.yml +223 -0
  82. data/config/locales/en-SHAKESPEARE.yml +225 -0
  83. data/config/locales/en-US-pirate.yml +222 -0
  84. data/config/locales/en-US.yml +225 -0
  85. data/config/locales/en-YODA.yml +221 -0
  86. data/config/locales/en.yml +223 -0
  87. data/config/locales/es.yml +226 -0
  88. data/config/locales/fa.yml +223 -0
  89. data/config/locales/fr-CA.yml +227 -0
  90. data/config/locales/fr.yml +227 -0
  91. data/config/locales/he.yml +218 -0
  92. data/config/locales/hi.yml +223 -0
  93. data/config/locales/it.yml +225 -0
  94. data/config/locales/ja-JP.yml +215 -0
  95. data/config/locales/pt.yml +225 -0
  96. data/config/locales/ru.yml +228 -0
  97. data/config/locales/ur.yml +225 -0
  98. data/config/locales/zh-CN.yml +214 -0
  99. data/config/locales/zh-TW.yml +214 -0
  100. data/config/routes.rb +36 -0
  101. data/lib/ext/exception.rb +20 -0
  102. data/lib/generators/smriti/install/install_generator.rb +86 -0
  103. data/lib/generators/smriti/install/templates/create_mat_view_definitions.rb +29 -0
  104. data/lib/generators/smriti/install/templates/create_mat_view_runs.rb +32 -0
  105. data/lib/generators/smriti/install/templates/smriti_initializer.rb +23 -0
  106. data/lib/smriti/admin/auth_bridge.rb +93 -0
  107. data/lib/smriti/admin/default_auth.rb +62 -0
  108. data/lib/smriti/configuration.rb +58 -0
  109. data/lib/smriti/engine.rb +82 -0
  110. data/lib/smriti/helpers/ui_test_ids.rb +49 -0
  111. data/lib/smriti/jobs/adapter.rb +81 -0
  112. data/lib/smriti/service_response.rb +75 -0
  113. data/lib/smriti/services/base_service.rb +471 -0
  114. data/lib/smriti/services/check_matview_exists.rb +76 -0
  115. data/lib/smriti/services/concurrent_refresh.rb +94 -0
  116. data/lib/smriti/services/create_view.rb +173 -0
  117. data/lib/smriti/services/delete_view.rb +111 -0
  118. data/lib/smriti/services/regular_refresh.rb +90 -0
  119. data/lib/smriti/services/swap_refresh.rb +181 -0
  120. data/lib/smriti/version.rb +21 -0
  121. data/lib/smriti.rb +64 -0
  122. data/lib/tasks/helpers.rb +185 -0
  123. data/lib/tasks/smriti_tasks.rake +151 -0
  124. metadata +206 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ /**
9
+ * Stimulus Controller: DatatableController
10
+ * ----------------------------------------
11
+ * Headless datatable controller that manages search, multi-column sorting,
12
+ * filter clauses, pagination, and Turbo Stream refreshes.
13
+ *
14
+ * Responsibilities:
15
+ * - Keep URL query params in sync with datatable state (`dtsearch`, `dtsort`, `dtfilter`, `dtpage`, `dtperpage`).
16
+ * - Debounce search input and trigger refreshes efficiently.
17
+ * - Manage multi-column sort state and annotate sort order on headers.
18
+ * - Build and load the Turbo Frame stream URL for server-rendered table updates.
19
+ *
20
+ * Key Components:
21
+ * - Search: `onSearchInput`, `clearSearchInput`
22
+ * - Sorting: `toggleSort`, `_syncSortParamsFromLocation`, `_serializeSortParams`, `_updateSortIcons`
23
+ * - Filtering: `onFilterChange`
24
+ * - Pagination: `goToPage`, `_placeDefaultPaginationParamsInLocation`
25
+ * - Refresh & URL: `_refresh`, `_replaceLocationParam`, `_buildStreamSrc`
26
+ */
27
+
28
+ import { Controller } from "@hotwired/stimulus";
29
+
30
+ /**
31
+ * @class DatatableController
32
+ * @extends Controller
33
+ */
34
+ export default class extends Controller {
35
+ /**
36
+ * Stimulus targets used by this controller.
37
+ * - th: header cells that toggle sort (data-key is required)
38
+ * - searchInput: text input for search terms
39
+ * - filterField: <select> elements that emit filter clauses
40
+ */
41
+ static targets = ["th", "searchInput", "filterField"];
42
+
43
+ /**
44
+ * Stimulus values used by this controller.
45
+ * @property {string} indexUrl - Base index URL used to fetch Turbo Stream updates.
46
+ * @property {number} perpageDefault - Default page size if not present in URL.
47
+ */
48
+ static values = {
49
+ indexUrl: String,
50
+ perpageDefault: { type: Number, default: 10 },
51
+ };
52
+
53
+ /** Separator for joining multiple param clauses (e.g., sort/filter). */
54
+ paramsSeparator = ",";
55
+ /** Separator between sort column and direction (e.g., "name:asc"). */
56
+ sortDirSeparator = ":";
57
+ /** Debounce duration (ms) for search input. */
58
+ debounceTimeout = 500;
59
+
60
+ /**
61
+ * Lifecycle: connect
62
+ * Binds refresh handler, initializes state from URL, updates icons, and triggers first refresh.
63
+ * @return {void}
64
+ */
65
+ connect() {
66
+ this._refresh = this._refresh.bind(this);
67
+ document.addEventListener("datatable:refresh", this._refresh);
68
+ this._searchTimeout = null;
69
+ this.dtSortParams = [];
70
+ this._frameListenersAttached = false;
71
+
72
+ this._placeDefaultPaginationParamsInLocation();
73
+ this._syncSortParamsFromLocation();
74
+ this._updateSortIcons();
75
+ this._refresh();
76
+ }
77
+
78
+ /**
79
+ * Lifecycle: disconnect
80
+ * Cleans up listeners and timers.
81
+ * @return {void}
82
+ */
83
+ disconnect() {
84
+ document.removeEventListener("datatable:refresh", this._refresh);
85
+ clearTimeout(this._searchTimeout);
86
+ }
87
+
88
+ /**
89
+ * Handles search input with debounce. Updates `dtsearch` and refreshes.
90
+ * @param {InputEvent} event
91
+ * @return {void}
92
+ */
93
+ onSearchInput(event) {
94
+ const value = this.searchInputTarget.value.trim();
95
+ clearTimeout(this._searchTimeout);
96
+ this._searchTimeout = setTimeout(() => {
97
+ this._replaceLocationParam("dtsearch", value);
98
+ this._refresh();
99
+ }, this.debounceTimeout);
100
+ }
101
+
102
+ /**
103
+ * Clears the search input, removes `dtsearch`, and refreshes.
104
+ * @param {MouseEvent} event
105
+ * @return {void}
106
+ */
107
+ clearSearchInput(event) {
108
+ event.preventDefault();
109
+ this.searchInputTarget.value = "";
110
+ clearTimeout(this._searchTimeout);
111
+ this._replaceLocationParam("dtsearch", null);
112
+ this._refresh();
113
+ }
114
+
115
+ /**
116
+ * Cycles sort state for a header (asc → desc → off) and updates `dtsort`.
117
+ * Supports multi-column sort by accumulating clauses.
118
+ * @param {MouseEvent} event
119
+ * @return {void}
120
+ */
121
+ toggleSort(event) {
122
+ event.preventDefault();
123
+ const th = event.target.closest("th");
124
+ const key = th?.dataset.key;
125
+ if (!key) return;
126
+
127
+ const existing = this.dtSortParams.find((sort) => sort.col === key);
128
+ if (existing) {
129
+ if (existing.dir === "asc") {
130
+ existing.dir = "desc";
131
+ } else {
132
+ this.dtSortParams = this.dtSortParams.filter(
133
+ (sort) => sort.col !== key,
134
+ );
135
+ }
136
+ } else {
137
+ this.dtSortParams.push({ col: key, dir: "asc" });
138
+ }
139
+
140
+ this._replaceLocationParam("dtsort", this._serializeSortParams());
141
+ this._updateSortIcons();
142
+ this._refresh();
143
+ }
144
+
145
+ /**
146
+ * Builds filter clauses from select targets and updates `dtfilter`.
147
+ * Clause format: "<key>:<value>", joined by `paramsSeparator`.
148
+ * @param {Event} _event
149
+ * @return {void}
150
+ */
151
+ onFilterChange(_event) {
152
+ const selects = this.filterFieldTargets;
153
+ if (!selects || selects.length === 0) return;
154
+
155
+ const filterClauses = [];
156
+ selects.forEach((select) => {
157
+ const key = select.dataset.key;
158
+ const value = select.value;
159
+
160
+ if (key && value && value != "no_filter") {
161
+ filterClauses.push(`${key}:${value}`);
162
+ }
163
+ });
164
+
165
+ const filterParam =
166
+ filterClauses.length > 0
167
+ ? filterClauses.join(this.paramsSeparator)
168
+ : null;
169
+ this._replaceLocationParam("dtfilter", filterParam);
170
+ this._refresh();
171
+ }
172
+
173
+ /**
174
+ * Navigates to a specific page and updates `dtpage`.
175
+ * @param {MouseEvent} event
176
+ * @return {void}
177
+ */
178
+ goToPage(event) {
179
+ event.preventDefault();
180
+ const page = event.currentTarget.dataset.page;
181
+ if (!page) return;
182
+
183
+ const pageNum = parseInt(page);
184
+ if (isNaN(pageNum) || pageNum < 1) return;
185
+
186
+ this._replaceLocationParam("dtpage", `${pageNum}`);
187
+ this._refresh();
188
+ }
189
+
190
+ changePerPage(event) {
191
+ const select = event.target;
192
+ const perpage = select.value;
193
+ if (!perpage) return;
194
+
195
+ const perpageNum = parseInt(perpage);
196
+ if (isNaN(perpageNum) || perpageNum < 1) return;
197
+
198
+ this._replaceLocationParam("dtperpage", `${perpageNum}`);
199
+ this._replaceLocationParam("dtpage", "1");
200
+ this._refresh();
201
+ }
202
+
203
+ /**
204
+ * Triggers a Turbo Frame reload with the computed stream URL.
205
+ * @return {void}
206
+ */
207
+ _refresh() {
208
+ const url = this._buildStreamSrc(this.indexUrlValue);
209
+ this.element.closest("turbo-frame").src = url;
210
+ }
211
+
212
+ /**
213
+ * Parses `dtsort` from window location and initializes `dtSortParams`.
214
+ * @return {void}
215
+ */
216
+ _syncSortParamsFromLocation() {
217
+ const qs = new URLSearchParams(window.location.search);
218
+ const sortParam = qs.get("dtsort");
219
+ if (!sortParam) return;
220
+
221
+ this.dtSortParams = sortParam
222
+ .split(this.paramsSeparator)
223
+ .map((clause) => clause.split(this.sortDirSeparator))
224
+ .filter(([col]) => col)
225
+ .map(([col, dir]) => ({
226
+ col,
227
+ dir: dir?.toLowerCase() === "desc" ? "desc" : "asc",
228
+ }));
229
+ }
230
+
231
+ /**
232
+ * Replaces or removes a query param in the current URL without reloading.
233
+ * If `value` is null/empty, the param is removed.
234
+ * @param {string} key
235
+ * @param {?string} value
236
+ * @return {void}
237
+ */
238
+ _replaceLocationParam(key, value) {
239
+ const url = new URL(window.location);
240
+ url.searchParams.delete(key);
241
+ if (value && value.length > 0) {
242
+ url.search += `&${key}=${value}`;
243
+ }
244
+ window.history.replaceState({}, "", url);
245
+ }
246
+
247
+ /**
248
+ * Serializes `dtSortParams` into the `dtsort` query value.
249
+ * Example: "name:asc,created_at:desc"
250
+ * @return {?string}
251
+ */
252
+ _serializeSortParams() {
253
+ if (!this.dtSortParams.length) return null;
254
+ return this.dtSortParams
255
+ .map((sort) => `${sort.col}${this.sortDirSeparator}${sort.dir}`)
256
+ .join(this.paramsSeparator);
257
+ }
258
+
259
+ /**
260
+ * Updates header sort icons and annotations to reflect `dtSortParams`.
261
+ * Shows numeric annotations when multiple columns are sorted.
262
+ * @return {void}
263
+ */
264
+ _updateSortIcons() {
265
+ this.thTargets.forEach((th) => {
266
+ const sortNeutral = th.querySelector(".mv-icon.sort-neutral");
267
+ const sortAsc = th.querySelector(".mv-icon.sort-asc");
268
+ const sortDesc = th.querySelector(".mv-icon.sort-desc");
269
+ const annotation = th.querySelector(".mv-annotation");
270
+ sortNeutral?.classList.remove("hidden");
271
+ sortAsc?.classList.add("hidden");
272
+ sortDesc?.classList.add("hidden");
273
+ annotation?.classList.add("hidden");
274
+ });
275
+
276
+ const showAnnotation = this.dtSortParams.length > 1;
277
+ this.dtSortParams.forEach((sort, index) => {
278
+ const th = this.thTargets.find(
279
+ (target) => target.dataset.key === sort.col,
280
+ );
281
+ if (!th) return;
282
+
283
+ const sortNeutral = th.querySelector(".mv-icon.sort-neutral");
284
+ const sortAsc = th.querySelector(".mv-icon.sort-asc");
285
+ const sortDesc = th.querySelector(".mv-icon.sort-desc");
286
+ const annotation = th.querySelector(".mv-annotation");
287
+
288
+ sortNeutral?.classList.add("hidden");
289
+ if (showAnnotation && annotation) {
290
+ annotation.textContent = String(index + 1);
291
+ annotation.classList.remove("hidden");
292
+ } else {
293
+ annotation?.classList.add("hidden");
294
+ }
295
+
296
+ if (sort.dir === "asc") {
297
+ sortAsc?.classList.remove("hidden");
298
+ sortDesc?.classList.add("hidden");
299
+ } else {
300
+ sortAsc?.classList.add("hidden");
301
+ sortDesc?.classList.remove("hidden");
302
+ }
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Builds the Turbo Stream URL by merging datatable params from the current page.
308
+ * Always appends `stream=true` to force stream rendering server-side.
309
+ * @param {string} baseSrc
310
+ * @return {string}
311
+ */
312
+ _buildStreamSrc(baseSrc) {
313
+ const url = new URL(baseSrc, window.location.origin);
314
+ const pageParams = new URLSearchParams(window.location.search);
315
+ ["dtsearch", "dtsort", "dtfilter", "dtpage", "dtperpage"].forEach(
316
+ (param) => {
317
+ if (pageParams.has(param)) {
318
+ url.searchParams.set(param, pageParams.get(param));
319
+ } else {
320
+ url.searchParams.delete(param);
321
+ }
322
+ },
323
+ );
324
+ url.searchParams.set("stream", "true");
325
+ return url.toString();
326
+ }
327
+
328
+ /**
329
+ * Ensures the URL contains default pagination params (`dtpage`, `dtperpage`).
330
+ * @return {void}
331
+ */
332
+ _placeDefaultPaginationParamsInLocation() {
333
+ const url = new URL(window.location);
334
+ const params = url.searchParams;
335
+ let updated = false;
336
+
337
+ if (!params.has("dtpage")) {
338
+ params.set("dtpage", "1");
339
+ updated = true;
340
+ }
341
+
342
+ if (!params.has("dtperpage")) {
343
+ params.set("dtperpage", String(this.perpageDefaultValue));
344
+ updated = true;
345
+ }
346
+
347
+ if (updated) {
348
+ window.history.replaceState({}, "", url);
349
+ }
350
+ }
351
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ /**
9
+ * Stimulus Controller: DetailsDisclosureController
10
+ * -----------------------------------------------
11
+ * Smoothly animates a `<details>` element’s open/close behavior by
12
+ * transitioning the height of an inner content wrapper.
13
+ *
14
+ * Responsibilities:
15
+ * - Normalize initial open/closed state on connect (SSR/serverside toggles).
16
+ * - Animate expand/collapse using CSS transitions on a single `height` property.
17
+ * - Provide interruption handling to cancel/lock ongoing animations cleanly.
18
+ *
19
+ * Key Components:
20
+ * - Public: `toggle`
21
+ * - Internal: `_expand`, `_collapse`, `_cancelAnimation`, `_onTransitionEnd`
22
+ * - Style helpers: `_setTransition`, `_setTransitionNone`, `_setHeight`, `_setHeightAuto`
23
+ */
24
+
25
+ import { Controller } from "@hotwired/stimulus";
26
+
27
+ /**
28
+ * @class DetailsDisclosureController
29
+ * @extends Controller
30
+ */
31
+ export default class extends Controller {
32
+ /**
33
+ * Targets:
34
+ * - content: wrapper inside <details> whose height is animated.
35
+ */
36
+ static targets = ["content"];
37
+
38
+ /**
39
+ * Values:
40
+ * - duration: animation duration in milliseconds.
41
+ */
42
+ static values = { duration: { type: Number, default: 200 } };
43
+
44
+ /**
45
+ * Lifecycle: connect
46
+ * Normalizes initial state and prepares animation flags.
47
+ * @return {void}
48
+ */
49
+ connect() {
50
+ this.animating = false;
51
+ // Normalize initial state (support SSR or toggled-by-server)
52
+ if (this.element.open) {
53
+ this._setHeightAuto();
54
+ } else {
55
+ this._setHeight(0);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Toggles the disclosure (expand if closed, collapse if open).
61
+ * Cancels any in-flight animation before toggling.
62
+ * @param {Event} event
63
+ * @return {void}
64
+ */
65
+ toggle(event) {
66
+ event.preventDefault();
67
+ if (this.animating) this._cancelAnimation();
68
+
69
+ if (this.element.open) {
70
+ this._collapse();
71
+ } else {
72
+ this._expand();
73
+ }
74
+ }
75
+
76
+ // ────────────────────────────────────────────────────────────────
77
+ // internal
78
+ // ────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Animates expansion: set height from 0 → measured content height.
82
+ * Keeps <details> open and resets to `height:auto` after transition.
83
+ * @return {void}
84
+ */
85
+ _expand() {
86
+ const el = this.contentTarget;
87
+ this.animating = true;
88
+
89
+ // Set starting point
90
+ this._setTransitionNone();
91
+ this._setHeight(0);
92
+ // Make <details> open *before* measuring end height, so children are laid out.
93
+ this.element.open = true;
94
+
95
+ // Next frame: measure, then animate to end height
96
+ requestAnimationFrame(() => {
97
+ const end = el.scrollHeight;
98
+ this._setTransition();
99
+ this._setHeight(end);
100
+
101
+ this._onTransitionEnd(() => {
102
+ this._setHeightAuto(); // allow content to grow/shrink after expand
103
+ this.animating = false;
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Animates collapse: lock current pixel height → 0, then close <details>.
110
+ * @return {void}
111
+ */
112
+ _collapse() {
113
+ const el = this.contentTarget;
114
+ this.animating = true;
115
+
116
+ // Freeze current height (auto → px) to start a smooth collapse
117
+ this._setTransitionNone();
118
+ const start = el.scrollHeight;
119
+ this._setHeight(start);
120
+
121
+ // Next frame: animate to 0, then close details
122
+ requestAnimationFrame(() => {
123
+ this._setTransition();
124
+ this._setHeight(0);
125
+
126
+ this._onTransitionEnd(() => {
127
+ this.element.open = false;
128
+ this._setTransitionNone();
129
+ this._setHeight(0); // keep at 0 when closed
130
+ this.animating = false;
131
+ });
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Cancels any ongoing transition and locks the current visual height.
137
+ * Useful when rapidly toggling.
138
+ * @return {void}
139
+ */
140
+ _cancelAnimation() {
141
+ // Interrupt ongoing animation cleanly
142
+ const el = this.contentTarget;
143
+ const computed = parseFloat(getComputedStyle(el).height);
144
+ this._setTransitionNone();
145
+ this._setHeight(computed); // lock current visual height
146
+ this.animating = false;
147
+ }
148
+
149
+ /**
150
+ * Invokes callback after the height transition on the content target ends.
151
+ * Filters unrelated transition events.
152
+ * @param {Function} cb - Callback to run after transition end.
153
+ * @return {void}
154
+ */
155
+ _onTransitionEnd(cb) {
156
+ const el = this.contentTarget;
157
+ const handler = (e) => {
158
+ if (e.target !== el || e.propertyName !== "height") return;
159
+ el.removeEventListener("transitionend", handler);
160
+ el.removeEventListener("transitioncancel", handler);
161
+ cb();
162
+ };
163
+ el.addEventListener("transitionend", handler, { once: false });
164
+ el.addEventListener("transitioncancel", handler, { once: false });
165
+ }
166
+
167
+ /**
168
+ * Applies the height transition using the configured duration.
169
+ * @return {void}
170
+ */
171
+ _setTransition() {
172
+ this.contentTarget.style.transition = `height ${this.durationValue}ms ease`;
173
+ }
174
+
175
+ /**
176
+ * Disables the height transition for instantaneous style updates.
177
+ * @return {void}
178
+ */
179
+ _setTransitionNone() {
180
+ this.contentTarget.style.transition = "none";
181
+ }
182
+
183
+ /**
184
+ * Sets the content wrapper height in pixels.
185
+ * @param {number} px - Height in pixels.
186
+ * @return {void}
187
+ */
188
+ _setHeight(px) {
189
+ this.contentTarget.style.height = `${px}px`;
190
+ }
191
+
192
+ /**
193
+ * Switches the content wrapper to natural height (`auto`).
194
+ * @return {void}
195
+ */
196
+ _setHeightAuto() {
197
+ this._setTransitionNone();
198
+ this.contentTarget.style.height = "auto";
199
+ }
200
+ }