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