@jsenv/navi 0.0.1 → 0.1.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 (138) hide show
  1. package/dist/jsenv_navi.js +22954 -0
  2. package/index.js +66 -16
  3. package/package.json +22 -11
  4. package/src/actions.js +50 -26
  5. package/src/browser_integration/browser_integration.js +31 -6
  6. package/src/browser_integration/via_history.js +42 -9
  7. package/src/components/action_execution/render_actionable_component.jsx +6 -4
  8. package/src/components/action_execution/use_action.js +51 -282
  9. package/src/components/action_execution/use_execute_action.js +106 -92
  10. package/src/components/action_execution/use_run_on_mount.js +9 -0
  11. package/src/components/action_renderer.jsx +21 -32
  12. package/src/components/demos/0_button_demo.html +574 -103
  13. package/src/components/demos/10_column_reordering_debug.html +277 -0
  14. package/src/components/demos/11_table_selection_debug.html +432 -0
  15. package/src/components/demos/1_checkbox_demo.html +579 -202
  16. package/src/components/demos/2_input_textual_demo.html +81 -138
  17. package/src/components/demos/3_radio_demo.html +0 -2
  18. package/src/components/demos/4_select_demo.html +19 -23
  19. package/src/components/demos/6_tablist_demo.html +77 -0
  20. package/src/components/demos/7_table_selection_demo.html +176 -0
  21. package/src/components/demos/8_table_fixed_headers_demo.html +584 -0
  22. package/src/components/demos/9_table_column_drag_demo.html +325 -0
  23. package/src/components/demos/action/0_button_demo.html +2 -4
  24. package/src/components/demos/action/1_input_text_demo.html +643 -222
  25. package/src/components/demos/action/3_details_demo.html +146 -115
  26. package/src/components/demos/action/4_input_checkbox_demo.html +442 -322
  27. package/src/components/demos/action/5_input_checkbox_state_demo.html +270 -0
  28. package/src/components/demos/action/6_checkbox_list_demo.html +304 -72
  29. package/src/components/demos/action/7_radio_list_demo.html +310 -170
  30. package/src/components/demos/action/{8_editable_text_demo.html → 8_editable_demo.html} +65 -76
  31. package/src/components/demos/action/9_link_demo.html +84 -62
  32. package/src/components/demos/ui_transition/0_action_renderer_ui_transition_demo.html +695 -0
  33. package/src/components/demos/ui_transition/1_nested_ui_transition_demo.html +429 -0
  34. package/src/components/demos/ui_transition/2_height_transition_test.html +295 -0
  35. package/src/components/details/details.jsx +62 -64
  36. package/src/components/edition/editable.jsx +186 -0
  37. package/src/components/field/README.md +247 -0
  38. package/src/components/{input → field}/button.jsx +151 -130
  39. package/src/components/field/checkbox_list.jsx +184 -0
  40. package/src/components/{collect_form_element_values.js → field/collect_form_element_values.js} +7 -4
  41. package/src/components/{input → field}/field_css.js +4 -1
  42. package/src/components/field/form.jsx +211 -0
  43. package/src/components/{input → field}/input.jsx +1 -0
  44. package/src/components/{input → field}/input_checkbox.jsx +132 -155
  45. package/src/components/{input → field}/input_radio.jsx +135 -46
  46. package/src/components/{input → field}/input_textual.jsx +247 -173
  47. package/src/components/field/label.jsx +32 -0
  48. package/src/components/field/radio_list.jsx +182 -0
  49. package/src/components/{input → field}/select.jsx +17 -32
  50. package/src/components/field/use_action_events.js +132 -0
  51. package/src/components/field/use_form_events.js +55 -0
  52. package/src/components/field/use_ui_state_controller.js +506 -0
  53. package/src/components/item_tracker/README.md +461 -0
  54. package/src/components/item_tracker/use_isolated_item_tracker.jsx +209 -0
  55. package/src/components/item_tracker/use_isolated_item_tracker_demo.html +148 -0
  56. package/src/components/item_tracker/use_isolated_item_tracker_demo.jsx +460 -0
  57. package/src/components/item_tracker/use_item_tracker.jsx +143 -0
  58. package/src/components/item_tracker/use_item_tracker_demo.html +207 -0
  59. package/src/components/item_tracker/use_item_tracker_demo.jsx +216 -0
  60. package/src/components/keyboard_shortcuts/active_keyboard_shortcuts.jsx +87 -0
  61. package/src/components/keyboard_shortcuts/aria_key_shortcuts.js +61 -0
  62. package/src/components/keyboard_shortcuts/keyboard_key_meta.js +17 -0
  63. package/src/components/keyboard_shortcuts/keyboard_shortcuts.js +371 -0
  64. package/src/components/link/link.jsx +65 -102
  65. package/src/components/link/link_with_icon.jsx +52 -0
  66. package/src/components/loader/loader_background.jsx +85 -64
  67. package/src/components/loader/rectangle_loading.jsx +38 -19
  68. package/src/components/route.jsx +8 -4
  69. package/src/components/selection/selection.jsx +1583 -0
  70. package/src/components/svg/font_sized_svg.jsx +45 -0
  71. package/src/components/svg/icon_and_text.jsx +21 -0
  72. package/src/components/svg/svg_mask_overlay.jsx +105 -0
  73. package/src/components/table/drag/table_drag.jsx +506 -0
  74. package/src/components/table/resize/table_resize.jsx +650 -0
  75. package/src/components/table/resize/table_size.js +43 -0
  76. package/src/components/table/selection/table_selection.js +106 -0
  77. package/src/components/table/selection/table_selection.jsx +203 -0
  78. package/src/components/table/sticky/sticky_group.js +354 -0
  79. package/src/components/table/sticky/table_sticky.js +25 -0
  80. package/src/components/table/sticky/table_sticky.jsx +501 -0
  81. package/src/components/table/table.jsx +721 -0
  82. package/src/components/table/table_css.js +211 -0
  83. package/src/components/table/table_ui.jsx +49 -0
  84. package/src/components/table/use_cells_and_columns.js +90 -0
  85. package/src/components/table/use_object_array_to_cells.js +46 -0
  86. package/src/components/table/z_indexes.js +23 -0
  87. package/src/components/tablist/tablist.jsx +99 -0
  88. package/src/components/text/overflow.jsx +15 -0
  89. package/src/components/text/text_and_count.jsx +28 -0
  90. package/src/components/ui_transition.jsx +128 -0
  91. package/src/components/use_auto_focus.js +58 -7
  92. package/src/components/use_batch_during_render.js +33 -0
  93. package/src/components/use_debounce_true.js +7 -7
  94. package/src/components/use_dependencies_diff.js +35 -0
  95. package/src/components/use_focus_group.js +4 -3
  96. package/src/components/use_initial_value.js +8 -34
  97. package/src/components/use_signal_sync.js +1 -1
  98. package/src/components/use_stable_callback.js +68 -0
  99. package/src/components/use_state_array.js +16 -9
  100. package/src/docs/actions.md +22 -0
  101. package/src/notes.md +33 -12
  102. package/src/route/route.js +97 -47
  103. package/src/store/resource_graph.js +2 -1
  104. package/src/store/tests/{resource_graph_dependencies.test.js → resource_graph_dependencies.test_manual.js} +13 -13
  105. package/src/utils/is_signal.js +20 -0
  106. package/src/utils/stringify_for_display.js +4 -23
  107. package/src/validation/constraints/confirm_constraint.js +14 -0
  108. package/src/validation/constraints/create_unique_value_constraint.js +27 -0
  109. package/src/validation/constraints/native_constraints.js +313 -0
  110. package/src/validation/constraints/readonly_constraint.js +36 -0
  111. package/src/validation/constraints/single_space_constraint.js +13 -0
  112. package/src/validation/custom_constraint_validation.js +599 -0
  113. package/src/validation/custom_message.js +18 -0
  114. package/src/validation/demos/browser_style.png +0 -0
  115. package/src/validation/demos/form_validation_demo.html +142 -0
  116. package/src/validation/demos/form_validation_demo_preact.html +87 -0
  117. package/src/validation/demos/form_validation_native_popover_demo.html +168 -0
  118. package/src/validation/demos/form_validation_vs_native_demo.html +172 -0
  119. package/src/validation/demos/validation_message_demo.html +203 -0
  120. package/src/validation/hooks/use_constraints.js +23 -0
  121. package/src/validation/hooks/use_custom_validation_ref.js +73 -0
  122. package/src/validation/hooks/use_validation_message.js +19 -0
  123. package/src/validation/validation_message.js +741 -0
  124. package/src/components/editable_text/editable_text.jsx +0 -96
  125. package/src/components/form.jsx +0 -144
  126. package/src/components/input/checkbox_list.jsx +0 -294
  127. package/src/components/input/field.jsx +0 -61
  128. package/src/components/input/radio_list.jsx +0 -283
  129. package/src/components/input/use_form_event.js +0 -20
  130. package/src/components/input/use_on_change.js +0 -12
  131. package/src/components/selection/selection.js +0 -5
  132. package/src/components/selection/selection_context.jsx +0 -262
  133. package/src/components/shortcut/shortcut_context.jsx +0 -390
  134. package/src/components/use_action_events.js +0 -37
  135. package/src/utils/iterable_weak_set.js +0 -62
  136. /package/src/components/demos/action/{11_nested_shortcuts_demo.html → 11_nested_shortcuts_demo.xhtml} +0 -0
  137. /package/src/components/{shortcut → keyboard_shortcuts}/os.js +0 -0
  138. /package/src/route/{route.test.html → route.xtest.html} +0 -0
@@ -0,0 +1,741 @@
1
+ import {
2
+ allowWheelThrough,
3
+ getBorderSizes,
4
+ pickPositionRelativeTo,
5
+ visibleRectEffect,
6
+ } from "@jsenv/dom";
7
+
8
+ /**
9
+ * A validation message component that mimics native browser validation messages.
10
+ * Features:
11
+ * - Positions above or below target element based on available space
12
+ * - Follows target element during scrolling and resizing
13
+ * - Automatically hides when target element is not visible
14
+ * - Arrow points at the target element
15
+ */
16
+
17
+ /**
18
+ * Shows a validation message attached to the specified element
19
+ * @param {HTMLElement} targetElement - Element the validation message should follow
20
+ * @param {string} message - HTML content for the validation message
21
+ * @param {Object} options - Configuration options
22
+ * @param {boolean} options.scrollIntoView - Whether to scroll the target element into view
23
+ * @returns {Function} - Function to hide and remove the validation message
24
+ */
25
+
26
+ import.meta.css = /* css */ `
27
+ /* Ensure the validation message CANNOT cause overflow */
28
+ /* might be important to ensure it cannot create scrollbars in the document */
29
+ /* When measuring the size it should take */
30
+ .jsenv_validation_message_container {
31
+ position: fixed;
32
+ inset: 0;
33
+ overflow: hidden;
34
+ }
35
+
36
+ .jsenv_validation_message {
37
+ display: block;
38
+ overflow: visible;
39
+ height: auto;
40
+ position: absolute;
41
+ z-index: 1;
42
+ opacity: 0;
43
+ left: 0;
44
+ top: 0;
45
+ /* will be positioned with transform: translate */
46
+ transition: opacity 0.2s ease-in-out;
47
+ }
48
+
49
+ .jsenv_validation_message_border {
50
+ position: absolute;
51
+ pointer-events: none;
52
+ filter: drop-shadow(4px 4px 3px rgba(0, 0, 0, 0.2));
53
+ }
54
+
55
+ .jsenv_validation_message_body_wrapper {
56
+ border-style: solid;
57
+ border-color: transparent;
58
+ position: relative;
59
+ }
60
+
61
+ .jsenv_validation_message_body {
62
+ padding: 8px;
63
+ position: relative;
64
+ max-width: 47vw;
65
+ display: flex;
66
+ flex-direction: row;
67
+ gap: 10px;
68
+ }
69
+
70
+ .jsenv_validation_message_icon {
71
+ display: flex;
72
+ align-self: flex-start;
73
+ align-items: center;
74
+ justify-content: center;
75
+ width: 22px;
76
+ height: 22px;
77
+ border-radius: 2px;
78
+ flex-shrink: 0;
79
+ }
80
+
81
+ .jsenv_validation_message_exclamation_svg {
82
+ width: 16px;
83
+ height: 12px;
84
+ color: white;
85
+ }
86
+
87
+ .jsenv_validation_message[data-level="info"] .jsenv_validation_message_icon {
88
+ background-color: #2196f3;
89
+ }
90
+ .jsenv_validation_message[data-level="warning"]
91
+ .jsenv_validation_message_icon {
92
+ background-color: #ff9800;
93
+ }
94
+ .jsenv_validation_message[data-level="error"] .jsenv_validation_message_icon {
95
+ background-color: #f44336;
96
+ }
97
+
98
+ .jsenv_validation_message_content {
99
+ align-self: center;
100
+ word-break: break-word;
101
+ min-width: 0;
102
+ overflow-wrap: anywhere;
103
+ }
104
+
105
+ .jsenv_validation_message_border svg {
106
+ position: absolute;
107
+ inset: 0;
108
+ overflow: visible;
109
+ }
110
+
111
+ .border_path {
112
+ fill: var(--border-color);
113
+ }
114
+
115
+ .background_path {
116
+ fill: var(--background-color);
117
+ }
118
+
119
+ .jsenv_validation_message_close_button_column {
120
+ display: flex;
121
+ height: 22px;
122
+ }
123
+ .jsenv_validation_message_close_button {
124
+ border: none;
125
+ background: none;
126
+ padding: 0;
127
+ width: 1em;
128
+ height: 1em;
129
+ font-size: inherit;
130
+ cursor: pointer;
131
+ border-radius: 0.2em;
132
+ align-self: center;
133
+ color: currentColor;
134
+ }
135
+ .jsenv_validation_message_close_button:hover {
136
+ background: rgba(0, 0, 0, 0.1);
137
+ }
138
+ .close_svg {
139
+ width: 100%;
140
+ height: 100%;
141
+ }
142
+
143
+ .error_stack {
144
+ overflow: auto;
145
+ max-height: 200px;
146
+ }
147
+ `;
148
+
149
+ // HTML template for the validation message
150
+ const validationMessageTemplate = /* html */ `
151
+ <div
152
+ class="jsenv_validation_message_container"
153
+ >
154
+ <div class="jsenv_validation_message" role="alert" aria-live="assertive">
155
+ <div class="jsenv_validation_message_body_wrapper">
156
+ <div class="jsenv_validation_message_border"></div>
157
+ <div class="jsenv_validation_message_body">
158
+ <div class="jsenv_validation_message_icon">
159
+ <svg
160
+ class="jsenv_validation_message_exclamation_svg"
161
+ viewBox="0 0 125 300"
162
+ xmlns="http://www.w3.org/2000/svg"
163
+ >
164
+ <path
165
+ fill="currentColor"
166
+ d="m25,1 8,196h59l8-196zm37,224a37,37 0 1,0 2,0z"
167
+ />
168
+ </svg>
169
+ </div>
170
+ <div class="jsenv_validation_message_content">Default message</div>
171
+ <div class="jsenv_validation_message_close_button_column">
172
+ <button class="jsenv_validation_message_close_button">
173
+ <svg
174
+ class="close_svg"
175
+ viewBox="0 0 24 24"
176
+ fill="none"
177
+ xmlns="http://www.w3.org/2000/svg"
178
+ >
179
+ <path
180
+ fill-rule="evenodd"
181
+ clip-rule="evenodd"
182
+ d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
183
+ fill="currentColor"
184
+ />
185
+ </svg>
186
+ </button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ `;
193
+
194
+ export const openValidationMessage = (
195
+ targetElement,
196
+ message,
197
+ {
198
+ level = "warning",
199
+ onClose,
200
+ closeOnClickOutside = level === "info",
201
+ debug = false,
202
+ } = {},
203
+ ) => {
204
+ let _closeOnClickOutside = closeOnClickOutside;
205
+
206
+ if (debug) {
207
+ console.debug("open validation message on", targetElement, {
208
+ message,
209
+ level,
210
+ });
211
+ }
212
+
213
+ let opened = true;
214
+ const closeCallbackSet = new Set();
215
+ const close = (reason) => {
216
+ if (!opened) {
217
+ return;
218
+ }
219
+ if (debug) {
220
+ console.debug(`validation message closed (reason: ${reason})`);
221
+ }
222
+ opened = false;
223
+ for (const closeCallback of closeCallbackSet) {
224
+ closeCallback();
225
+ }
226
+ closeCallbackSet.clear();
227
+ };
228
+
229
+ // Create and add validation message to document
230
+ const jsenvValidationMessage = createValidationMessage();
231
+ const jsenvValidationMessageContent = jsenvValidationMessage.querySelector(
232
+ ".jsenv_validation_message_content",
233
+ );
234
+ const jsenvValidationMessageCloseButton =
235
+ jsenvValidationMessage.querySelector(
236
+ ".jsenv_validation_message_close_button",
237
+ );
238
+ jsenvValidationMessageCloseButton.onclick = () => {
239
+ close("click_close_button");
240
+ };
241
+
242
+ const update = (
243
+ newMessage,
244
+ { level = "warning", closeOnClickOutside = level === "info" } = {},
245
+ ) => {
246
+ _closeOnClickOutside = closeOnClickOutside;
247
+ const borderColor =
248
+ level === "info" ? "blue" : level === "warning" ? "grey" : "red";
249
+ const backgroundColor = "white";
250
+
251
+ jsenvValidationMessage.style.setProperty("--border-color", borderColor);
252
+ jsenvValidationMessage.style.setProperty(
253
+ "--background-color",
254
+ backgroundColor,
255
+ );
256
+
257
+ if (Error.isError(newMessage)) {
258
+ const error = newMessage;
259
+ newMessage = error.message;
260
+ newMessage += `<pre class="error_stack">${escapeHtml(error.stack)}</pre>`;
261
+ }
262
+
263
+ jsenvValidationMessage.setAttribute("data-level", level);
264
+ jsenvValidationMessageContent.innerHTML = newMessage;
265
+ };
266
+ update(message, { level });
267
+
268
+ jsenvValidationMessage.style.opacity = "0";
269
+
270
+ allowWheelThrough(jsenvValidationMessage, targetElement);
271
+
272
+ // Connect validation message with target element for accessibility
273
+ const validationMessageId = `jsenv_validation_message-${Date.now()}`;
274
+ jsenvValidationMessage.id = validationMessageId;
275
+ targetElement.setAttribute("aria-invalid", "true");
276
+ targetElement.setAttribute("aria-errormessage", validationMessageId);
277
+ closeCallbackSet.add(() => {
278
+ targetElement.removeAttribute("aria-invalid");
279
+ targetElement.removeAttribute("aria-errormessage");
280
+ });
281
+
282
+ document.body.appendChild(jsenvValidationMessage);
283
+ closeCallbackSet.add(() => {
284
+ jsenvValidationMessage.remove();
285
+ });
286
+
287
+ const positionFollower = stickValidationMessageToTarget(
288
+ jsenvValidationMessage,
289
+ targetElement,
290
+ {
291
+ debug,
292
+ },
293
+ );
294
+ closeCallbackSet.add(() => {
295
+ positionFollower.stop();
296
+ });
297
+
298
+ if (onClose) {
299
+ closeCallbackSet.add(onClose);
300
+ }
301
+ close_on_target_focus: {
302
+ const onfocus = () => {
303
+ if (level === "error") {
304
+ // error messages must be explicitely closed by the user
305
+ return;
306
+ }
307
+ if (targetElement.hasAttribute("data-validation-message-stay-on-focus")) {
308
+ return;
309
+ }
310
+ close("target_element_focus");
311
+ };
312
+ targetElement.addEventListener("focus", onfocus);
313
+ closeCallbackSet.add(() => {
314
+ targetElement.removeEventListener("focus", onfocus);
315
+ });
316
+ }
317
+
318
+ close_on_click_outside: {
319
+ const handleClickOutside = (event) => {
320
+ if (!_closeOnClickOutside) {
321
+ return;
322
+ }
323
+
324
+ const clickTarget = event.target;
325
+ if (
326
+ clickTarget === jsenvValidationMessage ||
327
+ jsenvValidationMessage.contains(clickTarget)
328
+ ) {
329
+ return;
330
+ }
331
+ // if (
332
+ // clickTarget === targetElement ||
333
+ // targetElement.contains(clickTarget)
334
+ // ) {
335
+ // return;
336
+ // }
337
+ close("click_outside");
338
+ };
339
+ document.addEventListener("click", handleClickOutside, true);
340
+ closeCallbackSet.add(() => {
341
+ document.removeEventListener("click", handleClickOutside, true);
342
+ });
343
+ }
344
+
345
+ const validationMessage = {
346
+ jsenvValidationMessage,
347
+ update,
348
+ close,
349
+ updatePosition: positionFollower.updatePosition,
350
+ };
351
+ targetElement.jsenvValidationMessage = validationMessage;
352
+ closeCallbackSet.add(() => {
353
+ delete targetElement.jsenvValidationMessage;
354
+ });
355
+ return validationMessage;
356
+ };
357
+
358
+ // Configuration parameters for validation message appearance
359
+ const ARROW_WIDTH = 16;
360
+ const ARROW_HEIGHT = 8;
361
+ const CORNER_RADIUS = 3;
362
+ const BORDER_WIDTH = 1;
363
+ const ARROW_SPACING = 8;
364
+
365
+ /**
366
+ * Generates SVG path for validation message with arrow on top
367
+ * @param {number} width - Validation message width
368
+ * @param {number} height - Validation message height
369
+ * @param {number} arrowPosition - Horizontal position of arrow
370
+ * @returns {string} - SVG markup
371
+ */
372
+ const generateSvgWithTopArrow = (width, height, arrowPosition) => {
373
+ // Calculate valid arrow position range
374
+ const arrowLeft =
375
+ ARROW_WIDTH / 2 + CORNER_RADIUS + BORDER_WIDTH + ARROW_SPACING;
376
+ const minArrowPos = arrowLeft;
377
+ const maxArrowPos = width - arrowLeft;
378
+ const constrainedArrowPos = Math.max(
379
+ minArrowPos,
380
+ Math.min(arrowPosition, maxArrowPos),
381
+ );
382
+
383
+ // Calculate content height
384
+ const contentHeight = height - ARROW_HEIGHT;
385
+
386
+ // Create two paths: one for the border (outer) and one for the content (inner)
387
+ const adjustedWidth = width;
388
+ const adjustedHeight = contentHeight + ARROW_HEIGHT;
389
+
390
+ // Slight adjustment for visual balance
391
+ const innerArrowWidthReduction = Math.min(BORDER_WIDTH * 0.3, 1);
392
+
393
+ // Outer path (border)
394
+ const outerPath = `
395
+ M${CORNER_RADIUS},${ARROW_HEIGHT}
396
+ H${constrainedArrowPos - ARROW_WIDTH / 2}
397
+ L${constrainedArrowPos},0
398
+ L${constrainedArrowPos + ARROW_WIDTH / 2},${ARROW_HEIGHT}
399
+ H${width - CORNER_RADIUS}
400
+ Q${width},${ARROW_HEIGHT} ${width},${ARROW_HEIGHT + CORNER_RADIUS}
401
+ V${adjustedHeight - CORNER_RADIUS}
402
+ Q${width},${adjustedHeight} ${width - CORNER_RADIUS},${adjustedHeight}
403
+ H${CORNER_RADIUS}
404
+ Q0,${adjustedHeight} 0,${adjustedHeight - CORNER_RADIUS}
405
+ V${ARROW_HEIGHT + CORNER_RADIUS}
406
+ Q0,${ARROW_HEIGHT} ${CORNER_RADIUS},${ARROW_HEIGHT}
407
+ `;
408
+
409
+ // Inner path (content) - keep arrow width almost the same
410
+ const innerRadius = Math.max(0, CORNER_RADIUS - BORDER_WIDTH);
411
+ const innerPath = `
412
+ M${innerRadius + BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH}
413
+ H${constrainedArrowPos - ARROW_WIDTH / 2 + innerArrowWidthReduction}
414
+ L${constrainedArrowPos},${BORDER_WIDTH}
415
+ L${constrainedArrowPos + ARROW_WIDTH / 2 - innerArrowWidthReduction},${ARROW_HEIGHT + BORDER_WIDTH}
416
+ H${width - innerRadius - BORDER_WIDTH}
417
+ Q${width - BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH} ${width - BORDER_WIDTH},${ARROW_HEIGHT + innerRadius + BORDER_WIDTH}
418
+ V${adjustedHeight - innerRadius - BORDER_WIDTH}
419
+ Q${width - BORDER_WIDTH},${adjustedHeight - BORDER_WIDTH} ${width - innerRadius - BORDER_WIDTH},${adjustedHeight - BORDER_WIDTH}
420
+ H${innerRadius + BORDER_WIDTH}
421
+ Q${BORDER_WIDTH},${adjustedHeight - BORDER_WIDTH} ${BORDER_WIDTH},${adjustedHeight - innerRadius - BORDER_WIDTH}
422
+ V${ARROW_HEIGHT + innerRadius + BORDER_WIDTH}
423
+ Q${BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH} ${innerRadius + BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH}
424
+ `;
425
+
426
+ return /*html */ `<svg
427
+ width="${adjustedWidth}"
428
+ height="${adjustedHeight}"
429
+ viewBox="0 0 ${adjustedWidth} ${adjustedHeight}"
430
+ fill="none"
431
+ xmlns="http://www.w3.org/2000/svg"
432
+ role="presentation"
433
+ aria-hidden="true"
434
+ >
435
+ <path d="${outerPath}" class="border_path" />
436
+ <path d="${innerPath}" class="background_path" />
437
+ </svg>`;
438
+ };
439
+
440
+ /**
441
+ * Generates SVG path for validation message with arrow on bottom
442
+ * @param {number} width - Validation message width
443
+ * @param {number} height - Validation message height
444
+ * @param {number} arrowPosition - Horizontal position of arrow
445
+ * @returns {string} - SVG markup
446
+ */
447
+ const generateSvgWithBottomArrow = (width, height, arrowPosition) => {
448
+ // Calculate valid arrow position range
449
+ const arrowLeft =
450
+ ARROW_WIDTH / 2 + CORNER_RADIUS + BORDER_WIDTH + ARROW_SPACING;
451
+ const minArrowPos = arrowLeft;
452
+ const maxArrowPos = width - arrowLeft;
453
+ const constrainedArrowPos = Math.max(
454
+ minArrowPos,
455
+ Math.min(arrowPosition, maxArrowPos),
456
+ );
457
+
458
+ // Calculate content height
459
+ const contentHeight = height - ARROW_HEIGHT;
460
+
461
+ // Create two paths: one for the border (outer) and one for the content (inner)
462
+ const adjustedWidth = width;
463
+ const adjustedHeight = contentHeight + ARROW_HEIGHT;
464
+
465
+ // For small border widths, keep inner arrow nearly the same size as outer
466
+ const innerArrowWidthReduction = Math.min(BORDER_WIDTH * 0.3, 1);
467
+
468
+ // Outer path with rounded corners
469
+ const outerPath = `
470
+ M${CORNER_RADIUS},0
471
+ H${width - CORNER_RADIUS}
472
+ Q${width},0 ${width},${CORNER_RADIUS}
473
+ V${contentHeight - CORNER_RADIUS}
474
+ Q${width},${contentHeight} ${width - CORNER_RADIUS},${contentHeight}
475
+ H${constrainedArrowPos + ARROW_WIDTH / 2}
476
+ L${constrainedArrowPos},${adjustedHeight}
477
+ L${constrainedArrowPos - ARROW_WIDTH / 2},${contentHeight}
478
+ H${CORNER_RADIUS}
479
+ Q0,${contentHeight} 0,${contentHeight - CORNER_RADIUS}
480
+ V${CORNER_RADIUS}
481
+ Q0,0 ${CORNER_RADIUS},0
482
+ `;
483
+
484
+ // Inner path with correct arrow direction and color
485
+ const innerRadius = Math.max(0, CORNER_RADIUS - BORDER_WIDTH);
486
+ const innerPath = `
487
+ M${innerRadius + BORDER_WIDTH},${BORDER_WIDTH}
488
+ H${width - innerRadius - BORDER_WIDTH}
489
+ Q${width - BORDER_WIDTH},${BORDER_WIDTH} ${width - BORDER_WIDTH},${innerRadius + BORDER_WIDTH}
490
+ V${contentHeight - innerRadius - BORDER_WIDTH}
491
+ Q${width - BORDER_WIDTH},${contentHeight - BORDER_WIDTH} ${width - innerRadius - BORDER_WIDTH},${contentHeight - BORDER_WIDTH}
492
+ H${constrainedArrowPos + ARROW_WIDTH / 2 - innerArrowWidthReduction}
493
+ L${constrainedArrowPos},${adjustedHeight - BORDER_WIDTH}
494
+ L${constrainedArrowPos - ARROW_WIDTH / 2 + innerArrowWidthReduction},${contentHeight - BORDER_WIDTH}
495
+ H${innerRadius + BORDER_WIDTH}
496
+ Q${BORDER_WIDTH},${contentHeight - BORDER_WIDTH} ${BORDER_WIDTH},${contentHeight - innerRadius - BORDER_WIDTH}
497
+ V${innerRadius + BORDER_WIDTH}
498
+ Q${BORDER_WIDTH},${BORDER_WIDTH} ${innerRadius + BORDER_WIDTH},${BORDER_WIDTH}
499
+ `;
500
+
501
+ return /*html */ `<svg
502
+ width="${adjustedWidth}"
503
+ height="${adjustedHeight}"
504
+ viewBox="0 0 ${adjustedWidth} ${adjustedHeight}"
505
+ fill="none"
506
+ xmlns="http://www.w3.org/2000/svg"
507
+ role="presentation"
508
+ aria-hidden="true"
509
+ >
510
+ <path d="${outerPath}" class="border_path" />
511
+ <path d="${innerPath}" class="background_path" />
512
+ </svg>`;
513
+ };
514
+
515
+ /**
516
+ * Creates a new validation message element with specified content
517
+ * @param {string} content - HTML content for the validation message
518
+ * @returns {HTMLElement} - The validation message element
519
+ */
520
+ const createValidationMessage = () => {
521
+ const div = document.createElement("div");
522
+ div.innerHTML = validationMessageTemplate;
523
+ const validationMessage = div.querySelector(".jsenv_validation_message");
524
+ return validationMessage;
525
+ };
526
+
527
+ const stickValidationMessageToTarget = (validationMessage, targetElement) => {
528
+ // Get references to validation message parts
529
+ const validationMessageBodyWrapper = validationMessage.querySelector(
530
+ ".jsenv_validation_message_body_wrapper",
531
+ );
532
+ const validationMessageBorder = validationMessage.querySelector(
533
+ ".jsenv_validation_message_border",
534
+ );
535
+ const validationMessageContent = validationMessage.querySelector(
536
+ ".jsenv_validation_message_content",
537
+ );
538
+
539
+ // Set initial border styles
540
+ validationMessageBodyWrapper.style.borderWidth = `${BORDER_WIDTH}px`;
541
+ validationMessageBorder.style.left = `-${BORDER_WIDTH}px`;
542
+ validationMessageBorder.style.right = `-${BORDER_WIDTH}px`;
543
+
544
+ const targetVisibleRectEffect = visibleRectEffect(
545
+ targetElement,
546
+ ({ left: targetLeft, right: targetRight, visibilityRatio }) => {
547
+ // reset max height and overflow because it impacts the element size
548
+ // and we need to re-check if we need to have an overflow or not.
549
+ // to avoid visual impact we do this on an invisible clone.
550
+ // It's ok to do this because the element is absolutely positioned
551
+ const validationMessageClone = validationMessage.cloneNode(true);
552
+ validationMessageClone.style.visibility = "hidden";
553
+ const validationMessageContentClone =
554
+ validationMessageClone.querySelector(
555
+ ".jsenv_validation_message_content",
556
+ );
557
+ validationMessageContentClone.style.maxHeight = "";
558
+ validationMessageContentClone.style.overflowY = "";
559
+ validationMessage.parentNode.appendChild(validationMessageClone);
560
+ const {
561
+ position,
562
+ left: validationMessageLeft,
563
+ top: validationMessageTop,
564
+ width: validationMessageWidth,
565
+ height: validationMessageHeight,
566
+ spaceAboveTarget,
567
+ spaceBelowTarget,
568
+ } = pickPositionRelativeTo(validationMessageClone, targetElement, {
569
+ alignToViewportEdgeWhenTargetNearEdge: 20,
570
+ });
571
+
572
+ // Get element padding and border to properly position arrow
573
+ const targetBorderSizes = getBorderSizes(targetElement);
574
+
575
+ // Calculate arrow position to point at target element
576
+ let arrowLeftPosOnValidationMessage;
577
+ // Determine arrow target position based on attribute
578
+ const arrowPositionAttribute = targetElement.getAttribute(
579
+ "data-validation-message-arrow-x",
580
+ );
581
+ let arrowTargetLeft;
582
+ if (arrowPositionAttribute === "center") {
583
+ // Target the center of the element
584
+ arrowTargetLeft = targetRight / 2;
585
+ } else {
586
+ // Default behavior: target the left edge of the element (after borders)
587
+ arrowTargetLeft = targetLeft + targetBorderSizes.left;
588
+ }
589
+
590
+ // Calculate arrow position within the validation message
591
+ if (validationMessageLeft < arrowTargetLeft) {
592
+ // Validation message is left of the target point, move arrow right
593
+ const diff = arrowTargetLeft - validationMessageLeft;
594
+ arrowLeftPosOnValidationMessage = diff;
595
+ } else if (
596
+ validationMessageLeft + validationMessageWidth <
597
+ arrowTargetLeft
598
+ ) {
599
+ // Edge case: target point is beyond right edge of validation message
600
+ arrowLeftPosOnValidationMessage = validationMessageWidth - ARROW_WIDTH;
601
+ } else {
602
+ // Target point is within validation message width
603
+ arrowLeftPosOnValidationMessage =
604
+ arrowTargetLeft - validationMessageLeft;
605
+ }
606
+
607
+ // Ensure arrow stays within validation message bounds with some padding
608
+ const minArrowPos = CORNER_RADIUS + ARROW_WIDTH / 2 + ARROW_SPACING;
609
+ const maxArrowPos = validationMessageWidth - minArrowPos;
610
+ arrowLeftPosOnValidationMessage = Math.max(
611
+ minArrowPos,
612
+ Math.min(arrowLeftPosOnValidationMessage, maxArrowPos),
613
+ );
614
+
615
+ // Force content overflow when there is not enough space to display
616
+ // the entirety of the validation message
617
+ const spaceAvailable =
618
+ position === "below" ? spaceBelowTarget : spaceAboveTarget;
619
+ let spaceAvailableForContent = spaceAvailable;
620
+ spaceAvailableForContent -= ARROW_HEIGHT;
621
+ spaceAvailableForContent -= BORDER_WIDTH * 2;
622
+ spaceAvailableForContent -= 16; // padding * 2
623
+ let contentHeight = validationMessageHeight;
624
+ contentHeight -= ARROW_HEIGHT;
625
+ contentHeight -= BORDER_WIDTH * 2;
626
+ contentHeight -= 16; // padding * 2
627
+ const spaceRemainingAfterContent =
628
+ spaceAvailableForContent - contentHeight;
629
+ console.log({
630
+ position,
631
+ spaceBelowTarget,
632
+ validationMessageHeight,
633
+ spaceAvailableForContent,
634
+ contentHeight,
635
+ spaceRemainingAfterContent,
636
+ });
637
+ if (spaceRemainingAfterContent < 2) {
638
+ const maxHeight = spaceAvailableForContent;
639
+ validationMessageContent.style.maxHeight = `${maxHeight}px`;
640
+ validationMessageContent.style.overflowY = "scroll";
641
+ } else {
642
+ validationMessageContent.style.maxHeight = "";
643
+ validationMessageContent.style.overflowY = "";
644
+ }
645
+
646
+ const { width, height } = validationMessage.getBoundingClientRect();
647
+ if (position === "above") {
648
+ // Position above target element
649
+ validationMessageBodyWrapper.style.marginTop = "";
650
+ validationMessageBodyWrapper.style.marginBottom = `${ARROW_HEIGHT}px`;
651
+ validationMessageBorder.style.top = `-${BORDER_WIDTH}px`;
652
+ validationMessageBorder.style.bottom = `-${BORDER_WIDTH + ARROW_HEIGHT - 0.5}px`;
653
+ validationMessageBorder.innerHTML = generateSvgWithBottomArrow(
654
+ width,
655
+ height,
656
+ arrowLeftPosOnValidationMessage,
657
+ );
658
+ } else {
659
+ validationMessageBodyWrapper.style.marginTop = `${ARROW_HEIGHT}px`;
660
+ validationMessageBodyWrapper.style.marginBottom = "";
661
+ validationMessageBorder.style.top = `-${BORDER_WIDTH + ARROW_HEIGHT - 0.5}px`;
662
+ validationMessageBorder.style.bottom = `-${BORDER_WIDTH}px`;
663
+ validationMessageBorder.innerHTML = generateSvgWithTopArrow(
664
+ width,
665
+ height,
666
+ arrowLeftPosOnValidationMessage,
667
+ );
668
+ }
669
+
670
+ validationMessage.style.opacity = visibilityRatio ? "1" : "0";
671
+ validationMessage.setAttribute("data-position", position);
672
+ validationMessage.style.transform = `translateX(${validationMessageLeft}px) translateY(${validationMessageTop}px)`;
673
+
674
+ validationMessageClone.remove();
675
+ },
676
+ );
677
+ const messageSizeChangeObserver = observeValidationMessageSizeChange(
678
+ validationMessageContent,
679
+ (width, height) => {
680
+ targetVisibleRectEffect.check(`content_size_change (${width}x${height})`);
681
+ },
682
+ );
683
+ targetVisibleRectEffect.onBeforeAutoCheck(() => {
684
+ // prevent feedback loop because check triggers size change which triggers check...
685
+ messageSizeChangeObserver.disable();
686
+ return () => {
687
+ messageSizeChangeObserver.enable();
688
+ };
689
+ });
690
+
691
+ return {
692
+ updatePosition: targetVisibleRectEffect.check,
693
+ stop: () => {
694
+ messageSizeChangeObserver.disconnect();
695
+ targetVisibleRectEffect.disconnect();
696
+ },
697
+ };
698
+ };
699
+
700
+ const observeValidationMessageSizeChange = (elementSizeToObserve, callback) => {
701
+ let lastContentWidth;
702
+ let lastContentHeight;
703
+ const resizeObserver = new ResizeObserver((entries) => {
704
+ const [entry] = entries;
705
+ const { width, height } = entry.contentRect;
706
+ // Debounce tiny changes that are likely sub-pixel rounding
707
+ if (lastContentWidth !== undefined) {
708
+ const widthDiff = Math.abs(width - lastContentWidth);
709
+ const heightDiff = Math.abs(height - lastContentHeight);
710
+ const threshold = 1; // Ignore changes smaller than 1px
711
+ if (widthDiff < threshold && heightDiff < threshold) {
712
+ return;
713
+ }
714
+ }
715
+ lastContentWidth = width;
716
+ lastContentHeight = height;
717
+ callback(width, height);
718
+ });
719
+ resizeObserver.observe(elementSizeToObserve);
720
+
721
+ return {
722
+ disable: () => {
723
+ resizeObserver.unobserve(elementSizeToObserve);
724
+ },
725
+ enable: () => {
726
+ resizeObserver.observe(elementSizeToObserve);
727
+ },
728
+ disconnect: () => {
729
+ resizeObserver.disconnect();
730
+ },
731
+ };
732
+ };
733
+
734
+ const escapeHtml = (string) => {
735
+ return string
736
+ .replace(/&/g, "&amp;")
737
+ .replace(/</g, "&lt;")
738
+ .replace(/>/g, "&gt;")
739
+ .replace(/"/g, "&quot;")
740
+ .replace(/'/g, "&#039;");
741
+ };