@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.
- package/dist/jsenv_navi.js +4487 -4178
- package/index.js +5 -2
- package/package.json +4 -3
- package/src/components/callout/callout.js +944 -0
- package/src/{validation/demos/validation_message_demo.html → components/callout/callout_demo.html} +40 -42
- package/src/components/callout/test_dynamic_positioning.html +161 -0
- package/src/components/field/button.jsx +4 -5
- package/src/components/field/input_checkbox.jsx +1 -2
- package/src/components/field/input_radio.jsx +1 -1
- package/src/components/field/input_textual.jsx +3 -3
- package/src/{components/field/navi_css_vars.js → navi_css_vars.js} +4 -0
- package/src/validation/custom_constraint_validation.js +3 -3
- package/src/validation/demos/form_validation_vs_native_demo.html +2 -2
- package/src/validation/validation_message.js +0 -753
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allowWheelThrough,
|
|
3
|
+
createPubSub,
|
|
4
|
+
createStyleController,
|
|
5
|
+
createValueEffect,
|
|
6
|
+
getBorderSizes,
|
|
7
|
+
getFirstVisuallyVisibleAncestor,
|
|
8
|
+
getPaddingSizes,
|
|
9
|
+
getVisuallyVisibleInfo,
|
|
10
|
+
pickPositionRelativeTo,
|
|
11
|
+
visibleRectEffect,
|
|
12
|
+
} from "@jsenv/dom";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A callout component that mimics native browser validation messages.
|
|
16
|
+
* Features:
|
|
17
|
+
* - Positions above or below target element based on available space
|
|
18
|
+
* - Follows target element during scrolling and resizing
|
|
19
|
+
* - Automatically hides when target element is not visible
|
|
20
|
+
* - Arrow automatically shows when pointing at a valid anchor element
|
|
21
|
+
* - Centers in viewport when no anchor element provided or anchor is too big
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shows a callout attached to the specified element
|
|
26
|
+
* @param {string} message - HTML content for the callout
|
|
27
|
+
* @param {Object} options - Configuration options
|
|
28
|
+
* @param {HTMLElement} [options.anchorElement] - Element the callout should follow. If not provided or too big, callout will be centered in viewport
|
|
29
|
+
* @param {string} [options.level="warning"] - Callout level: "info" | "warning" | "error"
|
|
30
|
+
* @param {Function} [options.onClose] - Callback when callout is closed
|
|
31
|
+
* @param {boolean} [options.closeOnClickOutside] - Whether to close on outside clicks (defaults to true for "info" level)
|
|
32
|
+
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
33
|
+
* @returns {Object} - Callout object with properties:
|
|
34
|
+
* - {Function} close - Function to close the callout
|
|
35
|
+
* - {Function} update - Function to update message and options
|
|
36
|
+
* - {Function} updatePosition - Function to update position
|
|
37
|
+
* - {HTMLElement} element - The callout DOM element
|
|
38
|
+
* - {boolean} opened - Whether the callout is currently open
|
|
39
|
+
*/
|
|
40
|
+
export const openCallout = (
|
|
41
|
+
message,
|
|
42
|
+
{
|
|
43
|
+
anchorElement,
|
|
44
|
+
// Level determines visual styling and behavior:
|
|
45
|
+
// "info" - polite announcement (e.g., "This element cannot be modified")
|
|
46
|
+
// "warning" - expected failure requiring user action (e.g., "Field is required")
|
|
47
|
+
// "error" - unexpected failure, may not be actionable (e.g., "Server error")
|
|
48
|
+
level = "warning",
|
|
49
|
+
onClose,
|
|
50
|
+
closeOnClickOutside = level === "info",
|
|
51
|
+
debug = false,
|
|
52
|
+
} = {},
|
|
53
|
+
) => {
|
|
54
|
+
const callout = {
|
|
55
|
+
opened: true,
|
|
56
|
+
close: null,
|
|
57
|
+
level: undefined,
|
|
58
|
+
|
|
59
|
+
update: null,
|
|
60
|
+
updatePosition: null,
|
|
61
|
+
|
|
62
|
+
element: null,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (debug) {
|
|
66
|
+
console.debug("open callout", {
|
|
67
|
+
anchorElement,
|
|
68
|
+
message,
|
|
69
|
+
level,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const [teardown, addTeardown] = createPubSub(true);
|
|
74
|
+
const close = (reason) => {
|
|
75
|
+
if (!callout.opened) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (debug) {
|
|
79
|
+
console.debug(`callout closed (reason: ${reason})`);
|
|
80
|
+
}
|
|
81
|
+
callout.opened = false;
|
|
82
|
+
teardown(reason);
|
|
83
|
+
};
|
|
84
|
+
if (onClose) {
|
|
85
|
+
addTeardown(onClose);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const [updateLevel, addLevelEffect] = createValueEffect(undefined);
|
|
89
|
+
|
|
90
|
+
// Create and add callout to document
|
|
91
|
+
const calloutElement = createCalloutElement();
|
|
92
|
+
const calloutMessageElement = calloutElement.querySelector(
|
|
93
|
+
".navi_callout_message",
|
|
94
|
+
);
|
|
95
|
+
const calloutCloseButton = calloutElement.querySelector(
|
|
96
|
+
".navi_callout_close_button",
|
|
97
|
+
);
|
|
98
|
+
calloutCloseButton.onclick = () => {
|
|
99
|
+
close("click_close_button");
|
|
100
|
+
};
|
|
101
|
+
const calloutId = `navi_callout_${Date.now()}`;
|
|
102
|
+
calloutElement.id = calloutId;
|
|
103
|
+
calloutStyleController.set(calloutElement, { opacity: 0 });
|
|
104
|
+
const update = (newMessage, options = {}) => {
|
|
105
|
+
// Connect callout with target element for accessibility
|
|
106
|
+
if (options.level && options.level !== callout.level) {
|
|
107
|
+
callout.level = level;
|
|
108
|
+
updateLevel(level);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.closeOnClickOutside) {
|
|
112
|
+
closeOnClickOutside = options.closeOnClickOutside;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (Error.isError(newMessage)) {
|
|
116
|
+
const error = newMessage;
|
|
117
|
+
newMessage = error.message;
|
|
118
|
+
newMessage += `<pre class="navi_callout_error_stack">${escapeHtml(error.stack)}</pre>`;
|
|
119
|
+
}
|
|
120
|
+
calloutMessageElement.innerHTML = newMessage;
|
|
121
|
+
};
|
|
122
|
+
close_on_click_outside: {
|
|
123
|
+
const handleClickOutside = (event) => {
|
|
124
|
+
if (!closeOnClickOutside) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const clickTarget = event.target;
|
|
129
|
+
if (
|
|
130
|
+
clickTarget === calloutElement ||
|
|
131
|
+
calloutElement.contains(clickTarget)
|
|
132
|
+
) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// if (
|
|
136
|
+
// clickTarget === targetElement ||
|
|
137
|
+
// targetElement.contains(clickTarget)
|
|
138
|
+
// ) {
|
|
139
|
+
// return;
|
|
140
|
+
// }
|
|
141
|
+
close("click_outside");
|
|
142
|
+
};
|
|
143
|
+
document.addEventListener("click", handleClickOutside, true);
|
|
144
|
+
addTeardown(() => {
|
|
145
|
+
document.removeEventListener("click", handleClickOutside, true);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
Object.assign(callout, {
|
|
149
|
+
element: calloutElement,
|
|
150
|
+
update,
|
|
151
|
+
close,
|
|
152
|
+
});
|
|
153
|
+
addLevelEffect(() => {
|
|
154
|
+
calloutElement.setAttribute("data-level", level);
|
|
155
|
+
if (level === "info") {
|
|
156
|
+
calloutElement.setAttribute("role", "status");
|
|
157
|
+
} else {
|
|
158
|
+
calloutElement.setAttribute("role", "alert");
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
document.body.appendChild(calloutElement);
|
|
162
|
+
addTeardown(() => {
|
|
163
|
+
calloutElement.remove();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (anchorElement) {
|
|
167
|
+
const anchorVisuallyVisibleInfo = getVisuallyVisibleInfo(anchorElement, {
|
|
168
|
+
countOffscreenAsVisible: true,
|
|
169
|
+
});
|
|
170
|
+
if (!anchorVisuallyVisibleInfo.visible) {
|
|
171
|
+
console.warn(
|
|
172
|
+
`anchor element is not visually visible (${anchorVisuallyVisibleInfo.reason}) -> will be anchored to first visually visible ancestor`,
|
|
173
|
+
);
|
|
174
|
+
anchorElement = getFirstVisuallyVisibleAncestor(anchorElement);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
allowWheelThrough(calloutElement, anchorElement);
|
|
178
|
+
anchorElement.setAttribute("data-callout", calloutId);
|
|
179
|
+
addTeardown(() => {
|
|
180
|
+
anchorElement.removeAttribute("data-callout");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
addLevelEffect(() => {
|
|
184
|
+
anchorElement.style.setProperty(
|
|
185
|
+
"--callout-color",
|
|
186
|
+
`var(--navi-${level}-color)`,
|
|
187
|
+
);
|
|
188
|
+
return () => {
|
|
189
|
+
anchorElement.style.removeProperty("--callout-color");
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
addLevelEffect((level) => {
|
|
193
|
+
if (level === "info") {
|
|
194
|
+
anchorElement.setAttribute("aria-describedby", calloutId);
|
|
195
|
+
return () => {
|
|
196
|
+
anchorElement.removeAttribute("aria-describedby");
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
anchorElement.setAttribute("aria-errormessage", calloutId);
|
|
200
|
+
anchorElement.setAttribute("aria-invalid", "true");
|
|
201
|
+
return () => {
|
|
202
|
+
anchorElement.removeAttribute("aria-errormessage");
|
|
203
|
+
anchorElement.removeAttribute("aria-invalid");
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
close_on_anchor_focus: {
|
|
208
|
+
const onfocus = () => {
|
|
209
|
+
if (level === "error") {
|
|
210
|
+
// error messages must be explicitely closed by the user
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (anchorElement.hasAttribute("data-callout-stay-on-focus")) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
close("target_element_focus");
|
|
217
|
+
};
|
|
218
|
+
anchorElement.addEventListener("focus", onfocus);
|
|
219
|
+
addTeardown(() => {
|
|
220
|
+
anchorElement.removeEventListener("focus", onfocus);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
anchorElement.callout = callout;
|
|
224
|
+
addTeardown(() => {
|
|
225
|
+
delete anchorElement.callout;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
update(message, { level });
|
|
230
|
+
|
|
231
|
+
positioning: {
|
|
232
|
+
let positioner;
|
|
233
|
+
let strategy;
|
|
234
|
+
const determine = () => {
|
|
235
|
+
if (!anchorElement) {
|
|
236
|
+
return "centered";
|
|
237
|
+
}
|
|
238
|
+
// Check if anchor element is too big to reasonably position callout relative to it
|
|
239
|
+
const anchorRect = anchorElement.getBoundingClientRect();
|
|
240
|
+
const viewportHeight = window.innerHeight;
|
|
241
|
+
const anchorTooBig = anchorRect.height > viewportHeight - 50;
|
|
242
|
+
if (anchorTooBig) {
|
|
243
|
+
return "centered";
|
|
244
|
+
}
|
|
245
|
+
return "anchored";
|
|
246
|
+
};
|
|
247
|
+
const updatePositioner = () => {
|
|
248
|
+
const newStrategy = determine(anchorElement);
|
|
249
|
+
if (newStrategy === strategy) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
positioner?.stop();
|
|
253
|
+
if (newStrategy === "centered") {
|
|
254
|
+
positioner = centerCalloutInViewport(calloutElement);
|
|
255
|
+
} else {
|
|
256
|
+
positioner = stickCalloutToAnchor(calloutElement, anchorElement);
|
|
257
|
+
}
|
|
258
|
+
strategy = newStrategy;
|
|
259
|
+
};
|
|
260
|
+
updatePositioner();
|
|
261
|
+
addTeardown(() => {
|
|
262
|
+
positioner.stop();
|
|
263
|
+
});
|
|
264
|
+
auto_update_positioner: {
|
|
265
|
+
const handleResize = () => {
|
|
266
|
+
updatePositioner();
|
|
267
|
+
};
|
|
268
|
+
window.addEventListener("resize", handleResize);
|
|
269
|
+
addTeardown(() => {
|
|
270
|
+
window.removeEventListener("resize", handleResize);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
callout.updatePosition = () => positioner.update();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return callout;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Configuration parameters for callout appearance
|
|
280
|
+
const BORDER_WIDTH = 1;
|
|
281
|
+
const CORNER_RADIUS = 3;
|
|
282
|
+
const ARROW_WIDTH = 16;
|
|
283
|
+
const ARROW_HEIGHT = 8;
|
|
284
|
+
const ARROW_SPACING = 8;
|
|
285
|
+
|
|
286
|
+
import.meta.css = /* css */ `
|
|
287
|
+
@layer navi {
|
|
288
|
+
:root {
|
|
289
|
+
--navi-callout-background-color: white;
|
|
290
|
+
--navi-callout-padding: 8px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.navi_callout {
|
|
294
|
+
position: absolute;
|
|
295
|
+
top: 0;
|
|
296
|
+
left: 0;
|
|
297
|
+
z-index: 1;
|
|
298
|
+
display: block;
|
|
299
|
+
height: auto;
|
|
300
|
+
opacity: 0;
|
|
301
|
+
/* will be positioned with transform: translate */
|
|
302
|
+
transition: opacity 0.2s ease-in-out;
|
|
303
|
+
overflow: visible;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.navi_callout_frame {
|
|
307
|
+
position: absolute;
|
|
308
|
+
filter: drop-shadow(4px 4px 3px rgba(0, 0, 0, 0.2));
|
|
309
|
+
pointer-events: none;
|
|
310
|
+
}
|
|
311
|
+
.navi_callout[data-level="info"] .navi_callout_border {
|
|
312
|
+
fill: var(--navi-info-color);
|
|
313
|
+
}
|
|
314
|
+
.navi_callout[data-level="warning"] .navi_callout_border {
|
|
315
|
+
fill: var(--navi-warning-color);
|
|
316
|
+
}
|
|
317
|
+
.navi_callout[data-level="error"] .navi_callout_border {
|
|
318
|
+
fill: var(--navi-error-color);
|
|
319
|
+
}
|
|
320
|
+
.navi_callout_frame svg {
|
|
321
|
+
position: absolute;
|
|
322
|
+
inset: 0;
|
|
323
|
+
overflow: visible;
|
|
324
|
+
}
|
|
325
|
+
.navi_callout_background {
|
|
326
|
+
fill: var(--navi-callout-background-color);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.navi_callout_box {
|
|
330
|
+
position: relative;
|
|
331
|
+
border-style: solid;
|
|
332
|
+
border-color: transparent;
|
|
333
|
+
}
|
|
334
|
+
.navi_callout_body {
|
|
335
|
+
position: relative;
|
|
336
|
+
display: flex;
|
|
337
|
+
max-width: 47vw;
|
|
338
|
+
padding: var(--navi-callout-padding);
|
|
339
|
+
flex-direction: row;
|
|
340
|
+
gap: 10px;
|
|
341
|
+
}
|
|
342
|
+
.navi_callout_icon {
|
|
343
|
+
display: flex;
|
|
344
|
+
width: 22px;
|
|
345
|
+
height: 22px;
|
|
346
|
+
flex-shrink: 0;
|
|
347
|
+
align-items: center;
|
|
348
|
+
align-self: flex-start;
|
|
349
|
+
justify-content: center;
|
|
350
|
+
border-radius: 2px;
|
|
351
|
+
}
|
|
352
|
+
.navi_callout_icon_svg {
|
|
353
|
+
width: 16px;
|
|
354
|
+
height: 12px;
|
|
355
|
+
color: white;
|
|
356
|
+
}
|
|
357
|
+
.navi_callout[data-level="info"] .navi_callout_icon {
|
|
358
|
+
background-color: var(--navi-info-color);
|
|
359
|
+
}
|
|
360
|
+
.navi_callout[data-level="warning"] .navi_callout_icon {
|
|
361
|
+
background-color: var(--navi-warning-color);
|
|
362
|
+
}
|
|
363
|
+
.navi_callout[data-level="error"] .navi_callout_icon {
|
|
364
|
+
background-color: var(--navi-error-color);
|
|
365
|
+
}
|
|
366
|
+
.navi_callout_message {
|
|
367
|
+
min-width: 0;
|
|
368
|
+
align-self: center;
|
|
369
|
+
word-break: break-word;
|
|
370
|
+
overflow-wrap: anywhere;
|
|
371
|
+
}
|
|
372
|
+
.navi_callout_close_button_column {
|
|
373
|
+
display: flex;
|
|
374
|
+
height: 22px;
|
|
375
|
+
}
|
|
376
|
+
.navi_callout_close_button {
|
|
377
|
+
width: 1em;
|
|
378
|
+
height: 1em;
|
|
379
|
+
padding: 0;
|
|
380
|
+
align-self: center;
|
|
381
|
+
color: currentColor;
|
|
382
|
+
font-size: inherit;
|
|
383
|
+
background: none;
|
|
384
|
+
border: none;
|
|
385
|
+
border-radius: 0.2em;
|
|
386
|
+
cursor: pointer;
|
|
387
|
+
}
|
|
388
|
+
.navi_callout_close_button:hover {
|
|
389
|
+
background: rgba(0, 0, 0, 0.1);
|
|
390
|
+
}
|
|
391
|
+
.navi_callout_close_button_svg {
|
|
392
|
+
width: 100%;
|
|
393
|
+
height: 100%;
|
|
394
|
+
}
|
|
395
|
+
.navi_callout_error_stack {
|
|
396
|
+
max-height: 200px;
|
|
397
|
+
overflow: auto;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
`;
|
|
401
|
+
|
|
402
|
+
// HTML template for the callout
|
|
403
|
+
const calloutTemplate = /* html */ `
|
|
404
|
+
<div class="navi_callout">
|
|
405
|
+
<div class="navi_callout_box">
|
|
406
|
+
<div class="navi_callout_frame"></div>
|
|
407
|
+
<div class="navi_callout_body">
|
|
408
|
+
<div class="navi_callout_icon">
|
|
409
|
+
<svg
|
|
410
|
+
class="navi_callout_icon_svg"
|
|
411
|
+
viewBox="0 0 125 300"
|
|
412
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
413
|
+
>
|
|
414
|
+
<path
|
|
415
|
+
fill="currentColor"
|
|
416
|
+
d="m25,1 8,196h59l8-196zm37,224a37,37 0 1,0 2,0z"
|
|
417
|
+
/>
|
|
418
|
+
</svg>
|
|
419
|
+
</div>
|
|
420
|
+
<div class="navi_callout_message">Default message</div>
|
|
421
|
+
<div class="navi_callout_close_button_column">
|
|
422
|
+
<button class="navi_callout_close_button">
|
|
423
|
+
<svg
|
|
424
|
+
class="navi_callout_close_button_svg"
|
|
425
|
+
viewBox="0 0 24 24"
|
|
426
|
+
fill="none"
|
|
427
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
428
|
+
>
|
|
429
|
+
<path
|
|
430
|
+
fill-rule="evenodd"
|
|
431
|
+
clip-rule="evenodd"
|
|
432
|
+
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"
|
|
433
|
+
fill="currentColor"
|
|
434
|
+
/>
|
|
435
|
+
</svg>
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
`;
|
|
442
|
+
|
|
443
|
+
const calloutStyleController = createStyleController("callout");
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Creates a new callout element from template
|
|
447
|
+
* @returns {HTMLElement} - The callout element
|
|
448
|
+
*/
|
|
449
|
+
const createCalloutElement = () => {
|
|
450
|
+
const div = document.createElement("div");
|
|
451
|
+
div.innerHTML = calloutTemplate;
|
|
452
|
+
const calloutElement = div.firstElementChild;
|
|
453
|
+
return calloutElement;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const centerCalloutInViewport = (calloutElement) => {
|
|
457
|
+
// Set up initial styles for centered positioning
|
|
458
|
+
const calloutBoxElement = calloutElement.querySelector(".navi_callout_box");
|
|
459
|
+
const calloutFrameElement = calloutElement.querySelector(
|
|
460
|
+
".navi_callout_frame",
|
|
461
|
+
);
|
|
462
|
+
const calloutBodyElement = calloutElement.querySelector(".navi_callout_body");
|
|
463
|
+
const calloutMessageElement = calloutElement.querySelector(
|
|
464
|
+
".navi_callout_message",
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
// Remove any margins and set frame positioning for no arrow
|
|
468
|
+
calloutBoxElement.style.marginTop = "";
|
|
469
|
+
calloutBoxElement.style.marginBottom = "";
|
|
470
|
+
calloutBoxElement.style.borderWidth = `${BORDER_WIDTH}px`;
|
|
471
|
+
calloutFrameElement.style.left = `-${BORDER_WIDTH}px`;
|
|
472
|
+
calloutFrameElement.style.right = `-${BORDER_WIDTH}px`;
|
|
473
|
+
calloutFrameElement.style.top = `-${BORDER_WIDTH}px`;
|
|
474
|
+
calloutFrameElement.style.bottom = `-${BORDER_WIDTH}px`;
|
|
475
|
+
|
|
476
|
+
// Generate simple rectangle SVG without arrow and position in center
|
|
477
|
+
const updateCenteredPosition = () => {
|
|
478
|
+
const calloutElementClone =
|
|
479
|
+
cloneCalloutToMeasureNaturalSize(calloutElement);
|
|
480
|
+
const { height } = calloutElementClone.getBoundingClientRect();
|
|
481
|
+
calloutElementClone.remove();
|
|
482
|
+
|
|
483
|
+
// Handle content overflow when viewport is too small
|
|
484
|
+
const viewportHeight = window.innerHeight;
|
|
485
|
+
const maxAllowedHeight = viewportHeight - 40; // Leave some margin from viewport edges
|
|
486
|
+
|
|
487
|
+
if (height > maxAllowedHeight) {
|
|
488
|
+
const paddingSizes = getPaddingSizes(calloutBodyElement);
|
|
489
|
+
const paddingY = paddingSizes.top + paddingSizes.bottom;
|
|
490
|
+
const spaceNeededAroundContent = BORDER_WIDTH * 2 + paddingY;
|
|
491
|
+
const spaceAvailableForContent =
|
|
492
|
+
maxAllowedHeight - spaceNeededAroundContent;
|
|
493
|
+
calloutMessageElement.style.maxHeight = `${spaceAvailableForContent}px`;
|
|
494
|
+
calloutMessageElement.style.overflowY = "scroll";
|
|
495
|
+
} else {
|
|
496
|
+
// Reset overflow styles if not needed
|
|
497
|
+
calloutMessageElement.style.maxHeight = "";
|
|
498
|
+
calloutMessageElement.style.overflowY = "";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Get final dimensions after potential overflow adjustments
|
|
502
|
+
const { width: finalWidth, height: finalHeight } =
|
|
503
|
+
calloutElement.getBoundingClientRect();
|
|
504
|
+
calloutFrameElement.innerHTML = generateSvgWithoutArrow(
|
|
505
|
+
finalWidth,
|
|
506
|
+
finalHeight,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Center in viewport
|
|
510
|
+
const viewportWidth = window.innerWidth;
|
|
511
|
+
const left = (viewportWidth - finalWidth) / 2;
|
|
512
|
+
const top = (viewportHeight - finalHeight) / 2;
|
|
513
|
+
|
|
514
|
+
calloutStyleController.set(calloutElement, {
|
|
515
|
+
opacity: 1,
|
|
516
|
+
transform: {
|
|
517
|
+
translateX: left,
|
|
518
|
+
translateY: top,
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Initial positioning
|
|
524
|
+
updateCenteredPosition();
|
|
525
|
+
|
|
526
|
+
window.addEventListener("resize", updateCenteredPosition);
|
|
527
|
+
|
|
528
|
+
// Return positioning function for dynamic updates
|
|
529
|
+
return {
|
|
530
|
+
update: updateCenteredPosition,
|
|
531
|
+
stop: () => {
|
|
532
|
+
window.removeEventListener("resize", updateCenteredPosition);
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Positions a callout relative to an anchor element with an arrow pointing to it
|
|
539
|
+
* @param {HTMLElement} calloutElement - The callout element to position
|
|
540
|
+
* @param {HTMLElement} anchorElement - The anchor element to stick to
|
|
541
|
+
* @returns {Object} - Object with update and stop functions
|
|
542
|
+
*/
|
|
543
|
+
const stickCalloutToAnchor = (calloutElement, anchorElement) => {
|
|
544
|
+
// Get references to callout parts
|
|
545
|
+
const calloutBoxElement = calloutElement.querySelector(".navi_callout_box");
|
|
546
|
+
const calloutFrameElement = calloutElement.querySelector(
|
|
547
|
+
".navi_callout_frame",
|
|
548
|
+
);
|
|
549
|
+
const calloutBodyElement = calloutElement.querySelector(".navi_callout_body");
|
|
550
|
+
const calloutMessageElement = calloutElement.querySelector(
|
|
551
|
+
".navi_callout_message",
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// Set initial border styles
|
|
555
|
+
calloutBoxElement.style.borderWidth = `${BORDER_WIDTH}px`;
|
|
556
|
+
calloutFrameElement.style.left = `-${BORDER_WIDTH}px`;
|
|
557
|
+
calloutFrameElement.style.right = `-${BORDER_WIDTH}px`;
|
|
558
|
+
|
|
559
|
+
const anchorVisibleRectEffect = visibleRectEffect(
|
|
560
|
+
anchorElement,
|
|
561
|
+
({ left: anchorLeft, right: anchorRight, visibilityRatio }) => {
|
|
562
|
+
const calloutElementClone =
|
|
563
|
+
cloneCalloutToMeasureNaturalSize(calloutElement);
|
|
564
|
+
const {
|
|
565
|
+
position,
|
|
566
|
+
left: calloutLeft,
|
|
567
|
+
top: calloutTop,
|
|
568
|
+
width: calloutWidth,
|
|
569
|
+
height: calloutHeight,
|
|
570
|
+
spaceAboveTarget,
|
|
571
|
+
spaceBelowTarget,
|
|
572
|
+
} = pickPositionRelativeTo(calloutElementClone, anchorElement, {
|
|
573
|
+
alignToViewportEdgeWhenTargetNearEdge: 20,
|
|
574
|
+
// when fully to the left, the border color is collé to the browser window making it hard to see
|
|
575
|
+
minLeft: 1,
|
|
576
|
+
});
|
|
577
|
+
calloutElementClone.remove();
|
|
578
|
+
|
|
579
|
+
// Calculate arrow position to point at anchorElement element
|
|
580
|
+
let arrowLeftPosOnCallout;
|
|
581
|
+
// Determine arrow target position based on attribute
|
|
582
|
+
const arrowPositionAttribute = anchorElement.getAttribute(
|
|
583
|
+
"data-callout-arrow-x",
|
|
584
|
+
);
|
|
585
|
+
let arrowAnchorLeft;
|
|
586
|
+
if (arrowPositionAttribute === "center") {
|
|
587
|
+
// Target the center of the anchorElement element
|
|
588
|
+
arrowAnchorLeft = (anchorLeft + anchorRight) / 2;
|
|
589
|
+
} else {
|
|
590
|
+
const anchorBorderSizes = getBorderSizes(anchorElement);
|
|
591
|
+
// Default behavior: target the left edge of the anchorElement element (after borders)
|
|
592
|
+
arrowAnchorLeft = anchorLeft + anchorBorderSizes.left;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Calculate arrow position within the callout
|
|
596
|
+
if (calloutLeft < arrowAnchorLeft) {
|
|
597
|
+
// Callout is left of the target point, move arrow right
|
|
598
|
+
const diff = arrowAnchorLeft - calloutLeft;
|
|
599
|
+
arrowLeftPosOnCallout = diff;
|
|
600
|
+
} else if (calloutLeft + calloutWidth < arrowAnchorLeft) {
|
|
601
|
+
// Edge case: target point is beyond right edge of callout
|
|
602
|
+
arrowLeftPosOnCallout = calloutWidth - ARROW_WIDTH;
|
|
603
|
+
} else {
|
|
604
|
+
// Target point is within callout width
|
|
605
|
+
arrowLeftPosOnCallout = arrowAnchorLeft - calloutLeft;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Ensure arrow stays within callout bounds with some padding
|
|
609
|
+
const minArrowPos = CORNER_RADIUS + ARROW_WIDTH / 2 + ARROW_SPACING;
|
|
610
|
+
const maxArrowPos = calloutWidth - minArrowPos;
|
|
611
|
+
arrowLeftPosOnCallout = Math.max(
|
|
612
|
+
minArrowPos,
|
|
613
|
+
Math.min(arrowLeftPosOnCallout, maxArrowPos),
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
// Force content overflow when there is not enough space to display
|
|
617
|
+
// the entirety of the callout
|
|
618
|
+
const spaceAvailable =
|
|
619
|
+
position === "below" ? spaceBelowTarget : spaceAboveTarget;
|
|
620
|
+
const paddingSizes = getPaddingSizes(calloutBodyElement);
|
|
621
|
+
const paddingY = paddingSizes.top + paddingSizes.bottom;
|
|
622
|
+
const spaceNeededAroundContent =
|
|
623
|
+
ARROW_HEIGHT + BORDER_WIDTH * 2 + paddingY;
|
|
624
|
+
const spaceAvailableForContent =
|
|
625
|
+
spaceAvailable - spaceNeededAroundContent;
|
|
626
|
+
const contentHeight = calloutHeight - spaceNeededAroundContent;
|
|
627
|
+
const spaceRemainingAfterContent =
|
|
628
|
+
spaceAvailableForContent - contentHeight;
|
|
629
|
+
if (spaceRemainingAfterContent < 2) {
|
|
630
|
+
const maxHeight = spaceAvailableForContent;
|
|
631
|
+
calloutMessageElement.style.maxHeight = `${maxHeight}px`;
|
|
632
|
+
calloutMessageElement.style.overflowY = "scroll";
|
|
633
|
+
} else {
|
|
634
|
+
calloutMessageElement.style.maxHeight = "";
|
|
635
|
+
calloutMessageElement.style.overflowY = "";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const { width, height } = calloutElement.getBoundingClientRect();
|
|
639
|
+
if (position === "above") {
|
|
640
|
+
// Position above target element
|
|
641
|
+
calloutBoxElement.style.marginTop = "";
|
|
642
|
+
calloutBoxElement.style.marginBottom = `${ARROW_HEIGHT}px`;
|
|
643
|
+
calloutFrameElement.style.top = `-${BORDER_WIDTH}px`;
|
|
644
|
+
calloutFrameElement.style.bottom = `-${BORDER_WIDTH + ARROW_HEIGHT - 0.5}px`;
|
|
645
|
+
calloutFrameElement.innerHTML = generateSvgWithBottomArrow(
|
|
646
|
+
width,
|
|
647
|
+
height,
|
|
648
|
+
arrowLeftPosOnCallout,
|
|
649
|
+
);
|
|
650
|
+
} else {
|
|
651
|
+
calloutBoxElement.style.marginTop = `${ARROW_HEIGHT}px`;
|
|
652
|
+
calloutBoxElement.style.marginBottom = "";
|
|
653
|
+
calloutFrameElement.style.top = `-${BORDER_WIDTH + ARROW_HEIGHT - 0.5}px`;
|
|
654
|
+
calloutFrameElement.style.bottom = `-${BORDER_WIDTH}px`;
|
|
655
|
+
calloutFrameElement.innerHTML = generateSvgWithTopArrow(
|
|
656
|
+
width,
|
|
657
|
+
height,
|
|
658
|
+
arrowLeftPosOnCallout,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
calloutElement.setAttribute("data-position", position);
|
|
663
|
+
calloutStyleController.set(calloutElement, {
|
|
664
|
+
opacity: visibilityRatio ? 1 : 0,
|
|
665
|
+
transform: {
|
|
666
|
+
translateX: calloutLeft,
|
|
667
|
+
translateY: calloutTop,
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
},
|
|
671
|
+
);
|
|
672
|
+
const calloutSizeChangeObserver = observeCalloutSizeChange(
|
|
673
|
+
calloutMessageElement,
|
|
674
|
+
(width, height) => {
|
|
675
|
+
anchorVisibleRectEffect.check(`callout_size_change (${width}x${height})`);
|
|
676
|
+
},
|
|
677
|
+
);
|
|
678
|
+
anchorVisibleRectEffect.onBeforeAutoCheck(() => {
|
|
679
|
+
// prevent feedback loop because check triggers size change which triggers check...
|
|
680
|
+
calloutSizeChangeObserver.disable();
|
|
681
|
+
return () => {
|
|
682
|
+
calloutSizeChangeObserver.enable();
|
|
683
|
+
};
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
update: anchorVisibleRectEffect.check,
|
|
688
|
+
stop: () => {
|
|
689
|
+
calloutSizeChangeObserver.disconnect();
|
|
690
|
+
anchorVisibleRectEffect.disconnect();
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const observeCalloutSizeChange = (elementSizeToObserve, callback) => {
|
|
696
|
+
let lastContentWidth;
|
|
697
|
+
let lastContentHeight;
|
|
698
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
699
|
+
const [entry] = entries;
|
|
700
|
+
const { width, height } = entry.contentRect;
|
|
701
|
+
// Debounce tiny changes that are likely sub-pixel rounding
|
|
702
|
+
if (lastContentWidth !== undefined) {
|
|
703
|
+
const widthDiff = Math.abs(width - lastContentWidth);
|
|
704
|
+
const heightDiff = Math.abs(height - lastContentHeight);
|
|
705
|
+
const threshold = 1; // Ignore changes smaller than 1px
|
|
706
|
+
if (widthDiff < threshold && heightDiff < threshold) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
lastContentWidth = width;
|
|
711
|
+
lastContentHeight = height;
|
|
712
|
+
callback(width, height);
|
|
713
|
+
});
|
|
714
|
+
resizeObserver.observe(elementSizeToObserve);
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
disable: () => {
|
|
718
|
+
resizeObserver.unobserve(elementSizeToObserve);
|
|
719
|
+
},
|
|
720
|
+
enable: () => {
|
|
721
|
+
resizeObserver.observe(elementSizeToObserve);
|
|
722
|
+
},
|
|
723
|
+
disconnect: () => {
|
|
724
|
+
resizeObserver.disconnect();
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const escapeHtml = (string) => {
|
|
730
|
+
return string
|
|
731
|
+
.replace(/&/g, "&")
|
|
732
|
+
.replace(/</g, "<")
|
|
733
|
+
.replace(/>/g, ">")
|
|
734
|
+
.replace(/"/g, """)
|
|
735
|
+
.replace(/'/g, "'");
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// It's ok to do this because the element is absolutely positioned
|
|
739
|
+
const cloneCalloutToMeasureNaturalSize = (calloutElement) => {
|
|
740
|
+
// Create invisible clone to measure natural size
|
|
741
|
+
const calloutElementClone = calloutElement.cloneNode(true);
|
|
742
|
+
calloutElementClone.style.visibility = "hidden";
|
|
743
|
+
const calloutMessageElementClone = calloutElementClone.querySelector(
|
|
744
|
+
".navi_callout_message",
|
|
745
|
+
);
|
|
746
|
+
// Reset any overflow constraints on the clone
|
|
747
|
+
calloutMessageElementClone.style.maxHeight = "";
|
|
748
|
+
calloutMessageElementClone.style.overflowY = "";
|
|
749
|
+
|
|
750
|
+
// Add clone to DOM to measure
|
|
751
|
+
calloutElement.parentNode.appendChild(calloutElementClone);
|
|
752
|
+
|
|
753
|
+
return calloutElementClone;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Generates SVG path for callout with arrow on top
|
|
758
|
+
* @param {number} width - Callout width
|
|
759
|
+
* @param {number} height - Callout height
|
|
760
|
+
* @param {number} arrowPosition - Horizontal position of arrow
|
|
761
|
+
* @returns {string} - SVG markup
|
|
762
|
+
*/
|
|
763
|
+
const generateSvgWithTopArrow = (width, height, arrowPosition) => {
|
|
764
|
+
// Calculate valid arrow position range
|
|
765
|
+
const arrowLeft =
|
|
766
|
+
ARROW_WIDTH / 2 + CORNER_RADIUS + BORDER_WIDTH + ARROW_SPACING;
|
|
767
|
+
const minArrowPos = arrowLeft;
|
|
768
|
+
const maxArrowPos = width - arrowLeft;
|
|
769
|
+
const constrainedArrowPos = Math.max(
|
|
770
|
+
minArrowPos,
|
|
771
|
+
Math.min(arrowPosition, maxArrowPos),
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
// Calculate content height
|
|
775
|
+
const contentHeight = height - ARROW_HEIGHT;
|
|
776
|
+
|
|
777
|
+
// Create two paths: one for the border (outer) and one for the content (inner)
|
|
778
|
+
const adjustedWidth = width;
|
|
779
|
+
const adjustedHeight = contentHeight + ARROW_HEIGHT;
|
|
780
|
+
|
|
781
|
+
// Slight adjustment for visual balance
|
|
782
|
+
const innerArrowWidthReduction = Math.min(BORDER_WIDTH * 0.3, 1);
|
|
783
|
+
|
|
784
|
+
// Outer path (border)
|
|
785
|
+
const outerPath = `
|
|
786
|
+
M${CORNER_RADIUS},${ARROW_HEIGHT}
|
|
787
|
+
H${constrainedArrowPos - ARROW_WIDTH / 2}
|
|
788
|
+
L${constrainedArrowPos},0
|
|
789
|
+
L${constrainedArrowPos + ARROW_WIDTH / 2},${ARROW_HEIGHT}
|
|
790
|
+
H${width - CORNER_RADIUS}
|
|
791
|
+
Q${width},${ARROW_HEIGHT} ${width},${ARROW_HEIGHT + CORNER_RADIUS}
|
|
792
|
+
V${adjustedHeight - CORNER_RADIUS}
|
|
793
|
+
Q${width},${adjustedHeight} ${width - CORNER_RADIUS},${adjustedHeight}
|
|
794
|
+
H${CORNER_RADIUS}
|
|
795
|
+
Q0,${adjustedHeight} 0,${adjustedHeight - CORNER_RADIUS}
|
|
796
|
+
V${ARROW_HEIGHT + CORNER_RADIUS}
|
|
797
|
+
Q0,${ARROW_HEIGHT} ${CORNER_RADIUS},${ARROW_HEIGHT}
|
|
798
|
+
`;
|
|
799
|
+
|
|
800
|
+
// Inner path (content) - keep arrow width almost the same
|
|
801
|
+
const innerRadius = Math.max(0, CORNER_RADIUS - BORDER_WIDTH);
|
|
802
|
+
const innerPath = `
|
|
803
|
+
M${innerRadius + BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH}
|
|
804
|
+
H${constrainedArrowPos - ARROW_WIDTH / 2 + innerArrowWidthReduction}
|
|
805
|
+
L${constrainedArrowPos},${BORDER_WIDTH}
|
|
806
|
+
L${constrainedArrowPos + ARROW_WIDTH / 2 - innerArrowWidthReduction},${ARROW_HEIGHT + BORDER_WIDTH}
|
|
807
|
+
H${width - innerRadius - BORDER_WIDTH}
|
|
808
|
+
Q${width - BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH} ${width - BORDER_WIDTH},${ARROW_HEIGHT + innerRadius + BORDER_WIDTH}
|
|
809
|
+
V${adjustedHeight - innerRadius - BORDER_WIDTH}
|
|
810
|
+
Q${width - BORDER_WIDTH},${adjustedHeight - BORDER_WIDTH} ${width - innerRadius - BORDER_WIDTH},${adjustedHeight - BORDER_WIDTH}
|
|
811
|
+
H${innerRadius + BORDER_WIDTH}
|
|
812
|
+
Q${BORDER_WIDTH},${adjustedHeight - BORDER_WIDTH} ${BORDER_WIDTH},${adjustedHeight - innerRadius - BORDER_WIDTH}
|
|
813
|
+
V${ARROW_HEIGHT + innerRadius + BORDER_WIDTH}
|
|
814
|
+
Q${BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH} ${innerRadius + BORDER_WIDTH},${ARROW_HEIGHT + BORDER_WIDTH}
|
|
815
|
+
`;
|
|
816
|
+
|
|
817
|
+
return /* html */ `
|
|
818
|
+
<svg
|
|
819
|
+
width="${adjustedWidth}"
|
|
820
|
+
height="${adjustedHeight}"
|
|
821
|
+
viewBox="0 0 ${adjustedWidth} ${adjustedHeight}"
|
|
822
|
+
fill="none"
|
|
823
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
824
|
+
role="presentation"
|
|
825
|
+
aria-hidden="true"
|
|
826
|
+
>
|
|
827
|
+
<path d="${outerPath}" class="navi_callout_border" />
|
|
828
|
+
<path d="${innerPath}" class="navi_callout_background" />
|
|
829
|
+
</svg>`;
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Generates SVG path for callout with arrow on bottom
|
|
834
|
+
* @param {number} width - Callout width
|
|
835
|
+
* @param {number} height - Callout height
|
|
836
|
+
* @param {number} arrowPosition - Horizontal position of arrow
|
|
837
|
+
* @returns {string} - SVG markup
|
|
838
|
+
*/
|
|
839
|
+
const generateSvgWithBottomArrow = (width, height, arrowPosition) => {
|
|
840
|
+
// Calculate valid arrow position range
|
|
841
|
+
const arrowLeft =
|
|
842
|
+
ARROW_WIDTH / 2 + CORNER_RADIUS + BORDER_WIDTH + ARROW_SPACING;
|
|
843
|
+
const minArrowPos = arrowLeft;
|
|
844
|
+
const maxArrowPos = width - arrowLeft;
|
|
845
|
+
const constrainedArrowPos = Math.max(
|
|
846
|
+
minArrowPos,
|
|
847
|
+
Math.min(arrowPosition, maxArrowPos),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
// Calculate content height
|
|
851
|
+
const contentHeight = height - ARROW_HEIGHT;
|
|
852
|
+
|
|
853
|
+
// Create two paths: one for the border (outer) and one for the content (inner)
|
|
854
|
+
const adjustedWidth = width;
|
|
855
|
+
const adjustedHeight = contentHeight + ARROW_HEIGHT;
|
|
856
|
+
|
|
857
|
+
// For small border widths, keep inner arrow nearly the same size as outer
|
|
858
|
+
const innerArrowWidthReduction = Math.min(BORDER_WIDTH * 0.3, 1);
|
|
859
|
+
|
|
860
|
+
// Outer path with rounded corners
|
|
861
|
+
const outerPath = `
|
|
862
|
+
M${CORNER_RADIUS},0
|
|
863
|
+
H${width - CORNER_RADIUS}
|
|
864
|
+
Q${width},0 ${width},${CORNER_RADIUS}
|
|
865
|
+
V${contentHeight - CORNER_RADIUS}
|
|
866
|
+
Q${width},${contentHeight} ${width - CORNER_RADIUS},${contentHeight}
|
|
867
|
+
H${constrainedArrowPos + ARROW_WIDTH / 2}
|
|
868
|
+
L${constrainedArrowPos},${adjustedHeight}
|
|
869
|
+
L${constrainedArrowPos - ARROW_WIDTH / 2},${contentHeight}
|
|
870
|
+
H${CORNER_RADIUS}
|
|
871
|
+
Q0,${contentHeight} 0,${contentHeight - CORNER_RADIUS}
|
|
872
|
+
V${CORNER_RADIUS}
|
|
873
|
+
Q0,0 ${CORNER_RADIUS},0
|
|
874
|
+
`;
|
|
875
|
+
|
|
876
|
+
// Inner path with correct arrow direction and color
|
|
877
|
+
const innerRadius = Math.max(0, CORNER_RADIUS - BORDER_WIDTH);
|
|
878
|
+
const innerPath = `
|
|
879
|
+
M${innerRadius + BORDER_WIDTH},${BORDER_WIDTH}
|
|
880
|
+
H${width - innerRadius - BORDER_WIDTH}
|
|
881
|
+
Q${width - BORDER_WIDTH},${BORDER_WIDTH} ${width - BORDER_WIDTH},${innerRadius + BORDER_WIDTH}
|
|
882
|
+
V${contentHeight - innerRadius - BORDER_WIDTH}
|
|
883
|
+
Q${width - BORDER_WIDTH},${contentHeight - BORDER_WIDTH} ${width - innerRadius - BORDER_WIDTH},${contentHeight - BORDER_WIDTH}
|
|
884
|
+
H${constrainedArrowPos + ARROW_WIDTH / 2 - innerArrowWidthReduction}
|
|
885
|
+
L${constrainedArrowPos},${adjustedHeight - BORDER_WIDTH}
|
|
886
|
+
L${constrainedArrowPos - ARROW_WIDTH / 2 + innerArrowWidthReduction},${contentHeight - BORDER_WIDTH}
|
|
887
|
+
H${innerRadius + BORDER_WIDTH}
|
|
888
|
+
Q${BORDER_WIDTH},${contentHeight - BORDER_WIDTH} ${BORDER_WIDTH},${contentHeight - innerRadius - BORDER_WIDTH}
|
|
889
|
+
V${innerRadius + BORDER_WIDTH}
|
|
890
|
+
Q${BORDER_WIDTH},${BORDER_WIDTH} ${innerRadius + BORDER_WIDTH},${BORDER_WIDTH}
|
|
891
|
+
`;
|
|
892
|
+
|
|
893
|
+
return /* html */ `
|
|
894
|
+
<svg
|
|
895
|
+
width="${adjustedWidth}"
|
|
896
|
+
height="${adjustedHeight}"
|
|
897
|
+
viewBox="0 0 ${adjustedWidth} ${adjustedHeight}"
|
|
898
|
+
fill="none"
|
|
899
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
900
|
+
role="presentation"
|
|
901
|
+
aria-hidden="true"
|
|
902
|
+
>
|
|
903
|
+
<path d="${outerPath}" class="navi_callout_border" />
|
|
904
|
+
<path d="${innerPath}" class="navi_callout_background" />
|
|
905
|
+
</svg>`;
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Generates SVG path for callout without arrow (simple rectangle)
|
|
910
|
+
* @param {number} width - Callout width
|
|
911
|
+
* @param {number} height - Callout height
|
|
912
|
+
* @returns {string} - SVG markup
|
|
913
|
+
*/
|
|
914
|
+
const generateSvgWithoutArrow = (width, height) => {
|
|
915
|
+
return /* html */ `
|
|
916
|
+
<svg
|
|
917
|
+
width="${width}"
|
|
918
|
+
height="${height}"
|
|
919
|
+
viewBox="0 0 ${width} ${height}"
|
|
920
|
+
fill="none"
|
|
921
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
922
|
+
role="presentation"
|
|
923
|
+
aria-hidden="true"
|
|
924
|
+
>
|
|
925
|
+
<rect
|
|
926
|
+
class="navi_callout_border"
|
|
927
|
+
x="0"
|
|
928
|
+
y="0"
|
|
929
|
+
width="${width}"
|
|
930
|
+
height="${height}"
|
|
931
|
+
rx="${CORNER_RADIUS}"
|
|
932
|
+
ry="${CORNER_RADIUS}"
|
|
933
|
+
/>
|
|
934
|
+
<rect
|
|
935
|
+
class="navi_callout_background"
|
|
936
|
+
x="${BORDER_WIDTH}"
|
|
937
|
+
y="${BORDER_WIDTH}"
|
|
938
|
+
width="${width - BORDER_WIDTH * 2}"
|
|
939
|
+
height="${height - BORDER_WIDTH * 2}"
|
|
940
|
+
rx="${Math.max(0, CORNER_RADIUS - BORDER_WIDTH)}"
|
|
941
|
+
ry="${Math.max(0, CORNER_RADIUS - BORDER_WIDTH)}"
|
|
942
|
+
/>
|
|
943
|
+
</svg>`;
|
|
944
|
+
};
|