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,525 @@
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: TooltipController
10
+ * --------------------------------------
11
+ * Displays a lightweight, accessible tooltip near the trigger element with
12
+ * viewport-aware placement, hover/focus/touch interactions, and delayed show/hide.
13
+ *
14
+ * Responsibilities:
15
+ * - Render a single reusable tooltip element (`#mv-tooltip`) in the DOM.
16
+ * - Show/hide on mouse/keyboard/touch with small delays to avoid flicker.
17
+ * - Compute placement (top/right/bottom/left) with clamping to viewport.
18
+ * - Manage ARIA (`aria-describedby`) and avoid the native browser tooltip.
19
+ *
20
+ * Key Components:
21
+ * - Public actions: `show`, `hide`, `toggle`, `forceHide`
22
+ * - Internals (show/hide): `_actuallyShow`, `_actuallyHide`
23
+ * - Listeners: `_bindHandlers`, `_addListeners`, `_removeListeners`
24
+ * - DOM helpers: `_ensureTooltipEl`, `_prepareForMeasure`, `_applyPlacementClass`,
25
+ * `_removeAllPlacementClasses`, `_setTransform`, `_offscreen`,
26
+ * `_showTooltip`, `_hideTooltip`, `_setDescribedBy`, `_clearDescribedBy`
27
+ * - Data/title: `_resolveText`, `_resolvePlacement`, `_saveAndRemoveTitle`, `_restoreTitle`
28
+ * - Timers: `_schedule`, `_clearTimer`, `_clearAllTimers`, `_isVisible`
29
+ * - Geometry: `_computePosition`, `_coordsFor`, `_clampX`, `_clampY`
30
+ *
31
+ * Usage:
32
+ * <button
33
+ * data-controller="tooltip"
34
+ * data-tooltip-text-value="Save"
35
+ * data-tooltip-placement="bottom"
36
+ * >
37
+ * Save
38
+ * </button>
39
+ */
40
+
41
+ import { Controller } from "@hotwired/stimulus";
42
+
43
+ export default class extends Controller {
44
+ /**
45
+ * Values:
46
+ * - text: explicit tooltip text; falls back to aria-label or title.
47
+ * - placement: preferred placement ("top" | "right" | "bottom" | "left").
48
+ * - delay: ms before showing on hover/focus.
49
+ * - hideDelay: ms before hiding after leave/blur.
50
+ * - disabled: disable tooltip behavior when true.
51
+ * - gap: pixel gap between trigger and tooltip.
52
+ * - margin: minimum distance from viewport edges.
53
+ */
54
+ static values = {
55
+ text: String,
56
+ placement: { type: String, default: "top" },
57
+ delay: { type: Number, default: 120 },
58
+ hideDelay: { type: Number, default: 80 },
59
+ disabled: { type: Boolean, default: false },
60
+ gap: { type: Number, default: 8 },
61
+ margin: { type: Number, default: 8 },
62
+ };
63
+
64
+ // ──────────────────────────────────────────────
65
+ // Lifecycle
66
+ // ──────────────────────────────────────────────
67
+
68
+ /**
69
+ * Creates/locates the singleton tooltip, binds handlers, and installs listeners.
70
+ * @return {void}
71
+ */
72
+ connect() {
73
+ this.tooltipEl = this._ensureTooltipEl();
74
+ this.contentEl = this.tooltipEl.querySelector(".mv-tooltip__content");
75
+ this.element.classList.add("mv-cursor-pointer");
76
+ this._bindHandlers();
77
+ this._addListeners();
78
+ }
79
+
80
+ /**
81
+ * Clears timers, removes listeners, restores native title, and ARIA cleanup.
82
+ * @return {void}
83
+ */
84
+ disconnect() {
85
+ this._clearAllTimers();
86
+ this._removeListeners();
87
+ this._restoreTitle();
88
+ this._clearDescribedBy();
89
+ }
90
+
91
+ // ──────────────────────────────────────────────
92
+ // Public actions
93
+ // ──────────────────────────────────────────────
94
+
95
+ /**
96
+ * Schedules tooltip show after `delay`. Cancels pending hide.
97
+ * @return {void}
98
+ */
99
+ show() {
100
+ if (this.disabledValue) return;
101
+ this._clearTimer("_hideTimer");
102
+ this._schedule("_showTimer", () => this._actuallyShow(), this.delayValue);
103
+ }
104
+
105
+ /**
106
+ * Schedules tooltip hide after `hideDelay`. Cancels pending show.
107
+ * @return {void}
108
+ */
109
+ hide() {
110
+ this._clearTimer("_showTimer");
111
+ this._schedule(
112
+ "_hideTimer",
113
+ () => this._actuallyHide(),
114
+ this.hideDelayValue,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Toggles visibility based on current state.
120
+ * @return {void}
121
+ */
122
+ toggle() {
123
+ this._isVisible() ? this.hide() : this.show();
124
+ }
125
+
126
+ /**
127
+ * Immediately hides the tooltip and moves it offscreen (no delay).
128
+ * @return {void}
129
+ */
130
+ forceHide() {
131
+ this._clearAllTimers();
132
+ this._actuallyHide(true);
133
+ }
134
+
135
+ // ──────────────────────────────────────────────
136
+ // Internals: show/hide
137
+ // ──────────────────────────────────────────────
138
+
139
+ /**
140
+ * Resolves text, removes native title, positions, and shows the tooltip.
141
+ * @return {void}
142
+ */
143
+ _actuallyShow() {
144
+ const text = this._resolveText();
145
+ if (!text) return;
146
+
147
+ // Remove native title to avoid default browser tooltip
148
+ this._saveAndRemoveTitle();
149
+
150
+ // Fill content
151
+ this.contentEl.textContent = text;
152
+
153
+ // Prep, place, show
154
+ this._prepareForMeasure();
155
+ const rect = this.element.getBoundingClientRect();
156
+ const ttRect = this.tooltipEl.getBoundingClientRect();
157
+ const placement = this._resolvePlacement();
158
+ const { x, y } = this._computePosition(
159
+ rect,
160
+ ttRect,
161
+ placement,
162
+ this.gapValue,
163
+ this.marginValue,
164
+ );
165
+
166
+ this._applyPlacementClass(placement);
167
+ this._setTransform(x, y);
168
+ this._showTooltip();
169
+ this._setDescribedBy();
170
+ }
171
+
172
+ /**
173
+ * Hides tooltip; optionally skip transition and move offscreen immediately.
174
+ * @param {boolean} [immediate=false]
175
+ * @return {void}
176
+ */
177
+ _actuallyHide(immediate = false) {
178
+ this._hideTooltip();
179
+
180
+ if (immediate) {
181
+ this._offscreen();
182
+ } else {
183
+ // Allow CSS transition to finish before moving offscreen (if not reopened)
184
+ setTimeout(() => {
185
+ if (!this._isVisible()) this._offscreen();
186
+ }, 150);
187
+ }
188
+
189
+ this._restoreTitle();
190
+ this._clearDescribedBy();
191
+ }
192
+
193
+ // ──────────────────────────────────────────────
194
+ // Internals: listeners and handlers
195
+ // ──────────────────────────────────────────────
196
+
197
+ /**
198
+ * Binds event handlers with correct `this`.
199
+ * @return {void}
200
+ */
201
+ _bindHandlers() {
202
+ this._onEnter = this.show.bind(this);
203
+ this._onLeave = this.hide.bind(this);
204
+ this._onFocus = this.show.bind(this);
205
+ this._onBlur = this.hide.bind(this);
206
+ this._onKey = (e) => {
207
+ if (e.key === "Escape") this.forceHide();
208
+ };
209
+ this._onTouch = (e) => {
210
+ e.preventDefault();
211
+ this.show();
212
+ this._clearTimer("_touchTimer");
213
+ this._touchTimer = setTimeout(() => this.hide(), 1500);
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Installs mouse/keyboard/touch listeners on the trigger element.
219
+ * @return {void}
220
+ */
221
+ _addListeners() {
222
+ const el = this.element;
223
+ el.addEventListener("mouseenter", this._onEnter);
224
+ el.addEventListener("mouseleave", this._onLeave);
225
+ el.addEventListener("focus", this._onFocus);
226
+ el.addEventListener("blur", this._onBlur);
227
+ el.addEventListener("keydown", this._onKey);
228
+ // preventDefault() requires passive: false
229
+ el.addEventListener("touchstart", this._onTouch, { passive: false });
230
+ }
231
+
232
+ /**
233
+ * Removes listeners installed by `_addListeners`.
234
+ * @return {void}
235
+ */
236
+ _removeListeners() {
237
+ const el = this.element;
238
+ if (!el) return;
239
+ el.removeEventListener("mouseenter", this._onEnter);
240
+ el.removeEventListener("mouseleave", this._onLeave);
241
+ el.removeEventListener("focus", this._onFocus);
242
+ el.removeEventListener("blur", this._onBlur);
243
+ el.removeEventListener("keydown", this._onKey);
244
+ el.removeEventListener("touchstart", this._onTouch, { passive: false });
245
+ }
246
+
247
+ // ──────────────────────────────────────────────
248
+ // Internals: DOM helpers
249
+ // ──────────────────────────────────────────────
250
+
251
+ /**
252
+ * Ensures a single reusable tooltip element exists in the document.
253
+ * @return {HTMLElement}
254
+ */
255
+ _ensureTooltipEl() {
256
+ let el = document.getElementById("mv-tooltip");
257
+ if (el) return el;
258
+
259
+ el = document.createElement("div");
260
+ el.id = "mv-tooltip";
261
+ el.setAttribute("role", "tooltip");
262
+ el.className = "mv-tooltip";
263
+ Object.assign(el.style, {
264
+ position: "fixed",
265
+ top: "0",
266
+ left: "0",
267
+ pointerEvents: "none",
268
+ opacity: "0",
269
+ transform: "translate(-9999px,-9999px)",
270
+ });
271
+ el.innerHTML =
272
+ '<div class="mv-tooltip__content"></div><div class="mv-tooltip__arrow" data-arrow></div>';
273
+ document.body.appendChild(el);
274
+ return el;
275
+ }
276
+
277
+ /**
278
+ * Prepares tooltip for measurement: hide, offscreen, remove placement classes.
279
+ * @return {void}
280
+ */
281
+ _prepareForMeasure() {
282
+ this.tooltipEl.style.opacity = "0";
283
+ this._offscreen();
284
+ this._removeAllPlacementClasses();
285
+ }
286
+
287
+ /**
288
+ * Applies the placement modifier class (e.g., `mv-tooltip--top`).
289
+ * @param {"top"|"right"|"bottom"|"left"} placement
290
+ * @return {void}
291
+ */
292
+ _applyPlacementClass(placement) {
293
+ this._removeAllPlacementClasses();
294
+ this.tooltipEl.classList.add(`mv-tooltip--${placement}`);
295
+ }
296
+
297
+ /**
298
+ * Removes all placement modifier classes.
299
+ * @return {void}
300
+ */
301
+ _removeAllPlacementClasses() {
302
+ this.tooltipEl.classList.remove(
303
+ "mv-tooltip--top",
304
+ "mv-tooltip--right",
305
+ "mv-tooltip--bottom",
306
+ "mv-tooltip--left",
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Applies a CSS transform to move the tooltip to (x, y).
312
+ * @param {number} x
313
+ * @param {number} y
314
+ * @return {void}
315
+ */
316
+ _setTransform(x, y) {
317
+ this.tooltipEl.style.transform = `translate(${x}px, ${y}px)`;
318
+ }
319
+
320
+ /**
321
+ * Moves the tooltip offscreen.
322
+ * @return {void}
323
+ */
324
+ _offscreen() {
325
+ this.tooltipEl.style.transform = "translate(-9999px,-9999px)";
326
+ }
327
+
328
+ /**
329
+ * Shows the tooltip via opacity/data attribute (for CSS transitions).
330
+ * @return {void}
331
+ */
332
+ _showTooltip() {
333
+ this.tooltipEl.style.opacity = "1";
334
+ this.tooltipEl.setAttribute("data-show", "true");
335
+ }
336
+
337
+ /**
338
+ * Hides the tooltip via opacity/data attribute (for CSS transitions).
339
+ * @return {void}
340
+ */
341
+ _hideTooltip() {
342
+ this.tooltipEl.removeAttribute("data-show");
343
+ this.tooltipEl.style.opacity = "0";
344
+ }
345
+
346
+ /**
347
+ * Adds `aria-describedby` to the trigger to reference the tooltip.
348
+ * @return {void}
349
+ */
350
+ _setDescribedBy() {
351
+ this.element.setAttribute("aria-describedby", "mv-tooltip");
352
+ }
353
+
354
+ /**
355
+ * Removes `aria-describedby` from the trigger.
356
+ * @return {void}
357
+ */
358
+ _clearDescribedBy() {
359
+ this.element.removeAttribute("aria-describedby");
360
+ }
361
+
362
+ // ──────────────────────────────────────────────
363
+ // Internals: data/text/title
364
+ // ──────────────────────────────────────────────
365
+
366
+ /**
367
+ * Returns tooltip text from value, aria-label, or title attribute.
368
+ * @return {string|undefined}
369
+ */
370
+ _resolveText() {
371
+ return (
372
+ this.textValue ||
373
+ this.element.getAttribute("aria-label") ||
374
+ this.element.getAttribute("title")
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Returns desired placement from data attribute or value.
380
+ * @return {"top"|"right"|"bottom"|"left"}
381
+ */
382
+ _resolvePlacement() {
383
+ return (
384
+ this.element.getAttribute("data-tooltip-placement") || this.placementValue
385
+ );
386
+ }
387
+
388
+ /**
389
+ * Saves and removes native title to suppress browser tooltip.
390
+ * @return {void}
391
+ */
392
+ _saveAndRemoveTitle() {
393
+ if (this.element.hasAttribute("title")) {
394
+ this._savedTitle = this.element.getAttribute("title");
395
+ this.element.removeAttribute("title");
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Restores the saved native title after hiding.
401
+ * @return {void}
402
+ */
403
+ _restoreTitle() {
404
+ if (this._savedTitle) {
405
+ this.element.setAttribute("title", this._savedTitle);
406
+ this._savedTitle = null;
407
+ }
408
+ }
409
+
410
+ // ──────────────────────────────────────────────
411
+ // Internals: timers & state helpers
412
+ // ──────────────────────────────────────────────
413
+
414
+ /**
415
+ * Schedules a `setTimeout` under a named slot, clearing any existing one.
416
+ * @param {string} name
417
+ * @param {Function} fn
418
+ * @param {number} ms
419
+ * @return {void}
420
+ */
421
+ _schedule(name, fn, ms) {
422
+ this._clearTimer(name);
423
+ this[name] = setTimeout(fn, ms);
424
+ }
425
+
426
+ /**
427
+ * Clears a named timeout if present.
428
+ * @param {string} name
429
+ * @return {void}
430
+ */
431
+ _clearTimer(name) {
432
+ if (this[name]) {
433
+ clearTimeout(this[name]);
434
+ this[name] = null;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Clears all internal timers.
440
+ * @return {void}
441
+ */
442
+ _clearAllTimers() {
443
+ this._clearTimer("_showTimer");
444
+ this._clearTimer("_hideTimer");
445
+ this._clearTimer("_touchTimer");
446
+ }
447
+
448
+ /**
449
+ * Returns true when tooltip is currently visible.
450
+ * @return {boolean}
451
+ */
452
+ _isVisible() {
453
+ return this.tooltipEl?.getAttribute("data-show") === "true";
454
+ }
455
+
456
+ // ──────────────────────────────────────────────
457
+ // Geometry
458
+ // ──────────────────────────────────────────────
459
+
460
+ /**
461
+ * Computes final (x,y) with clamping for viewport edges.
462
+ * @param {DOMRect} targetRect
463
+ * @param {DOMRect} ttRect
464
+ * @param {"top"|"right"|"bottom"|"left"} placement
465
+ * @param {number} gap
466
+ * @param {number} margin
467
+ * @return {{x:number, y:number}}
468
+ */
469
+ _computePosition(targetRect, ttRect, placement, gap, margin) {
470
+ const coords = this._coordsFor(placement, targetRect, ttRect, gap);
471
+ const x = this._clampX(coords.x, ttRect.width, margin);
472
+ const y = this._clampY(coords.y, ttRect.height, margin);
473
+ return { x: Math.round(x), y: Math.round(y) };
474
+ }
475
+
476
+ /**
477
+ * Calculates raw (x,y) for a given placement before clamping.
478
+ * @param {"top"|"right"|"bottom"|"left"} placement
479
+ * @param {DOMRect} r
480
+ * @param {DOMRect} tt
481
+ * @param {number} gap
482
+ * @return {{x:number, y:number}}
483
+ */
484
+ _coordsFor(placement, r, tt, gap) {
485
+ switch (placement) {
486
+ case "top":
487
+ return {
488
+ x: r.left + (r.width - tt.width) / 2,
489
+ y: r.top - tt.height - gap,
490
+ };
491
+ case "bottom":
492
+ return { x: r.left + (r.width - tt.width) / 2, y: r.bottom + gap };
493
+ case "left":
494
+ return {
495
+ x: r.left - tt.width - gap,
496
+ y: r.top + (r.height - tt.height) / 2,
497
+ };
498
+ case "right":
499
+ default:
500
+ return { x: r.right + gap, y: r.top + (r.height - tt.height) / 2 };
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Clamps X to viewport with margin.
506
+ * @param {number} x
507
+ * @param {number} width
508
+ * @param {number} margin
509
+ * @return {number}
510
+ */
511
+ _clampX(x, width, margin) {
512
+ return Math.max(margin, Math.min(x, window.innerWidth - width - margin));
513
+ }
514
+
515
+ /**
516
+ * Clamps Y to viewport with margin.
517
+ * @param {number} y
518
+ * @param {number} height
519
+ * @param {number} margin
520
+ * @return {number}
521
+ */
522
+ _clampY(y, height, margin) {
523
+ return Math.max(margin, Math.min(y, window.innerHeight - height - margin));
524
+ }
525
+ }