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