svelte-multiselect 11.5.0 → 11.5.2

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.
@@ -0,0 +1,698 @@
1
+ import {} from 'svelte/attachments';
2
+ // Svelte 5 attachment factory to make an element draggable
3
+ // @param options - Configuration options for dragging behavior
4
+ // @returns Attachment function that sets up dragging on an element
5
+ export const draggable = (options = {}) => (element) => {
6
+ if (options.disabled)
7
+ return;
8
+ const node = element;
9
+ // Use simple variables for maximum performance
10
+ let dragging = false;
11
+ let start = { x: 0, y: 0 };
12
+ const initial = { left: 0, top: 0 };
13
+ const handle = options.handle_selector
14
+ ? node.querySelector(options.handle_selector)
15
+ : node;
16
+ if (!handle) {
17
+ console.warn(`Draggable: handle not found with selector "${options.handle_selector}"`);
18
+ return;
19
+ }
20
+ function handle_mousedown(event) {
21
+ // Only drag if mousedown is on the handle or its children
22
+ if (!handle?.contains?.(event.target))
23
+ return;
24
+ dragging = true;
25
+ // For position: fixed elements, use getBoundingClientRect for viewport-relative position
26
+ const computed_style = getComputedStyle(node);
27
+ if (computed_style.position === `fixed`) {
28
+ const rect = node.getBoundingClientRect();
29
+ initial.left = rect.left;
30
+ initial.top = rect.top;
31
+ }
32
+ else {
33
+ // For other positioning, use offset values
34
+ initial.left = node.offsetLeft;
35
+ initial.top = node.offsetTop;
36
+ }
37
+ node.style.left = `${initial.left}px`;
38
+ node.style.top = `${initial.top}px`;
39
+ node.style.right = `auto`; // Prevent conflict with left
40
+ start = { x: event.clientX, y: event.clientY };
41
+ document.body.style.userSelect = `none`; // Prevent text selection during drag
42
+ if (handle)
43
+ handle.style.cursor = `grabbing`;
44
+ globalThis.addEventListener(`mousemove`, handle_mousemove);
45
+ globalThis.addEventListener(`mouseup`, handle_mouseup);
46
+ options.on_drag_start?.(event); // Call optional callback
47
+ }
48
+ function handle_mousemove(event) {
49
+ if (!dragging)
50
+ return;
51
+ // Use the exact same calculation as the fast old implementation
52
+ const dx = event.clientX - start.x;
53
+ const dy = event.clientY - start.y;
54
+ node.style.left = `${initial.left + dx}px`;
55
+ node.style.top = `${initial.top + dy}px`;
56
+ // Only call callback if it exists (minimize overhead)
57
+ if (options.on_drag)
58
+ options.on_drag(event);
59
+ }
60
+ function handle_mouseup(event) {
61
+ if (!dragging)
62
+ return;
63
+ dragging = false;
64
+ event.stopPropagation();
65
+ document.body.style.userSelect = ``;
66
+ if (handle)
67
+ handle.style.cursor = `grab`;
68
+ globalThis.removeEventListener(`mousemove`, handle_mousemove);
69
+ globalThis.removeEventListener(`mouseup`, handle_mouseup);
70
+ options.on_drag_end?.(event); // Call optional callback
71
+ }
72
+ if (handle) {
73
+ handle.addEventListener(`mousedown`, handle_mousedown);
74
+ handle.style.cursor = `grab`;
75
+ }
76
+ // Return cleanup function (this is the attachment pattern)
77
+ return () => {
78
+ globalThis.removeEventListener(`mousemove`, handle_mousemove);
79
+ globalThis.removeEventListener(`mouseup`, handle_mouseup);
80
+ if (handle) {
81
+ handle.removeEventListener(`mousedown`, handle_mousedown);
82
+ handle.style.cursor = ``; // Reset cursor
83
+ }
84
+ };
85
+ };
86
+ // Automatically sets `position: relative` on elements with `position: static`
87
+ // to enable proper positioning during resize. This may affect existing layouts.
88
+ export const resizable = (options = {}) => (element) => {
89
+ if (options.disabled)
90
+ return;
91
+ const node = element;
92
+ const { edges = [`right`, `bottom`], min_width = 50, min_height = 50, max_width = Infinity, max_height = Infinity, handle_size = 8, on_resize_start, on_resize, on_resize_end, } = options;
93
+ if (min_width > max_width || min_height > max_height) {
94
+ console.warn(`resizable: min dimensions exceed max dimensions (min_width=${min_width}, max_width=${max_width}, min_height=${min_height}, max_height=${max_height})`);
95
+ return; // Invalid config would cause clamp() to produce inconsistent results
96
+ }
97
+ let active_edge = null;
98
+ let start = { x: 0, y: 0 };
99
+ let initial = { width: 0, height: 0, left: 0, top: 0 };
100
+ const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
101
+ if (getComputedStyle(node).position === `static`)
102
+ node.style.position = `relative`;
103
+ const get_edge = ({ clientX: cx, clientY: cy }) => {
104
+ const { left, right, top, bottom } = node.getBoundingClientRect();
105
+ if (edges.includes(`right`) && cx >= right - handle_size && cx <= right) {
106
+ return `right`;
107
+ }
108
+ if (edges.includes(`bottom`) && cy >= bottom - handle_size && cy <= bottom) {
109
+ return `bottom`;
110
+ }
111
+ if (edges.includes(`left`) && cx >= left && cx <= left + handle_size)
112
+ return `left`;
113
+ if (edges.includes(`top`) && cy >= top && cy <= top + handle_size)
114
+ return `top`;
115
+ return null;
116
+ };
117
+ function on_mousedown(event) {
118
+ active_edge = get_edge(event);
119
+ if (!active_edge)
120
+ return;
121
+ start = { x: event.clientX, y: event.clientY };
122
+ initial = {
123
+ width: node.offsetWidth,
124
+ height: node.offsetHeight,
125
+ left: node.offsetLeft,
126
+ top: node.offsetTop,
127
+ };
128
+ document.body.style.userSelect = `none`;
129
+ on_resize_start?.(event, { width: initial.width, height: initial.height });
130
+ globalThis.addEventListener(`mousemove`, on_mousemove);
131
+ globalThis.addEventListener(`mouseup`, on_mouseup);
132
+ }
133
+ function on_mousemove(event) {
134
+ if (!active_edge)
135
+ return;
136
+ const dx = event.clientX - start.x, dy = event.clientY - start.y;
137
+ let { width, height } = initial;
138
+ if (active_edge === `right`)
139
+ width = clamp(initial.width + dx, min_width, max_width);
140
+ else if (active_edge === `left`) {
141
+ const clamped = clamp(initial.width - dx, min_width, max_width);
142
+ node.style.left = `${initial.left - (clamped - initial.width)}px`;
143
+ width = clamped;
144
+ }
145
+ if (active_edge === `bottom`) {
146
+ height = clamp(initial.height + dy, min_height, max_height);
147
+ }
148
+ else if (active_edge === `top`) {
149
+ const clamped = clamp(initial.height - dy, min_height, max_height);
150
+ node.style.top = `${initial.top - (clamped - initial.height)}px`;
151
+ height = clamped;
152
+ }
153
+ node.style.width = `${width}px`;
154
+ node.style.height = `${height}px`;
155
+ on_resize?.(event, { width, height });
156
+ }
157
+ function on_mouseup(event) {
158
+ if (!active_edge)
159
+ return;
160
+ document.body.style.userSelect = ``;
161
+ on_resize_end?.(event, { width: node.offsetWidth, height: node.offsetHeight });
162
+ globalThis.removeEventListener(`mousemove`, on_mousemove);
163
+ globalThis.removeEventListener(`mouseup`, on_mouseup);
164
+ active_edge = null;
165
+ }
166
+ function on_hover(event) {
167
+ const edge = get_edge(event);
168
+ node.style.cursor = edge === `right` || edge === `left`
169
+ ? `ew-resize`
170
+ : edge === `top` || edge === `bottom`
171
+ ? `ns-resize`
172
+ : ``;
173
+ }
174
+ node.addEventListener(`mousedown`, on_mousedown);
175
+ node.addEventListener(`mousemove`, on_hover);
176
+ return () => {
177
+ node.removeEventListener(`mousedown`, on_mousedown);
178
+ node.removeEventListener(`mousemove`, on_hover);
179
+ globalThis.removeEventListener(`mousemove`, on_mousemove);
180
+ globalThis.removeEventListener(`mouseup`, on_mouseup);
181
+ node.style.cursor = ``;
182
+ };
183
+ };
184
+ export function get_html_sort_value(element) {
185
+ if (element.dataset.sortValue !== undefined) {
186
+ return element.dataset.sortValue;
187
+ }
188
+ for (const child of Array.from(element.children)) {
189
+ const child_val = get_html_sort_value(child);
190
+ if (child_val !== ``)
191
+ return child_val;
192
+ }
193
+ return element.textContent ?? ``;
194
+ }
195
+ export const sortable = (options = {}) => (node) => {
196
+ const { header_selector = `thead th`, asc_class = `table-sort-asc`, desc_class = `table-sort-desc`, sorted_style = { backgroundColor: `rgba(255, 255, 255, 0.1)` }, disabled = false, } = options;
197
+ if (disabled)
198
+ return;
199
+ // This action can be applied to standard HTML tables to make them sortable by
200
+ // clicking on column headers (and clicking again to toggle sorting direction)
201
+ const headers = Array.from(node.querySelectorAll(header_selector));
202
+ let sort_col_idx;
203
+ let sort_dir = 1; // 1 = asc, -1 = desc
204
+ // Store original state for cleanup
205
+ const header_state = [];
206
+ for (const [idx, header] of headers.entries()) {
207
+ const original_text = header.textContent ?? ``;
208
+ const original_style = header.getAttribute(`style`) ?? ``;
209
+ header.style.cursor = `pointer`; // add cursor pointer to headers
210
+ const click_handler = () => {
211
+ // reset all headers to unsorted state
212
+ for (const { header: hdr, original_text, original_style } of header_state) {
213
+ hdr.textContent = original_text;
214
+ hdr.classList.remove(asc_class, desc_class);
215
+ if (original_style) {
216
+ hdr.setAttribute(`style`, original_style);
217
+ }
218
+ else {
219
+ hdr.removeAttribute(`style`);
220
+ }
221
+ hdr.style.cursor = `pointer`;
222
+ }
223
+ if (idx === sort_col_idx) {
224
+ sort_dir *= -1; // reverse sort direction
225
+ }
226
+ else {
227
+ sort_col_idx = idx; // set new sort column index
228
+ sort_dir = 1; // reset sort direction
229
+ }
230
+ header.classList.add(sort_dir > 0 ? asc_class : desc_class);
231
+ Object.assign(header.style, sorted_style);
232
+ header.textContent = `${header.textContent?.replace(/ ↑| ↓/, ``)} ${sort_dir > 0 ? `↑` : `↓`}`;
233
+ const table_body = node.querySelector(`tbody`);
234
+ if (!table_body)
235
+ return;
236
+ // re-sort table
237
+ const rows = Array.from(table_body.querySelectorAll(`tr`));
238
+ rows.sort((row_1, row_2) => {
239
+ const cell_1 = row_1.cells[sort_col_idx];
240
+ const cell_2 = row_2.cells[sort_col_idx];
241
+ const val_1 = get_html_sort_value(cell_1);
242
+ const val_2 = get_html_sort_value(cell_2);
243
+ if (val_1 === val_2)
244
+ return 0;
245
+ if (val_1 === ``)
246
+ return 1; // treat empty string as lower than any value
247
+ if (val_2 === ``)
248
+ return -1; // any value is considered higher than empty string
249
+ const num_1 = Number(val_1);
250
+ const num_2 = Number(val_2);
251
+ if (isNaN(num_1) && isNaN(num_2)) {
252
+ return sort_dir * val_1.localeCompare(val_2, undefined, { numeric: true });
253
+ }
254
+ return sort_dir * (num_1 - num_2);
255
+ });
256
+ for (const row of rows)
257
+ table_body.appendChild(row);
258
+ };
259
+ header.addEventListener(`click`, click_handler);
260
+ header_state.push({ header, handler: click_handler, original_text, original_style });
261
+ }
262
+ // Return cleanup function that fully restores original state
263
+ return () => {
264
+ for (const { header, handler, original_text, original_style } of header_state) {
265
+ header.removeEventListener(`click`, handler);
266
+ header.textContent = original_text;
267
+ header.classList.remove(asc_class, desc_class);
268
+ if (original_style) {
269
+ header.setAttribute(`style`, original_style);
270
+ }
271
+ else {
272
+ header.removeAttribute(`style`);
273
+ }
274
+ }
275
+ };
276
+ };
277
+ export const highlight_matches = (ops) => (node) => {
278
+ const { query = ``, disabled = false, fuzzy = false, node_filter = () => NodeFilter.FILTER_ACCEPT, css_class = `highlight-match`, } = ops;
279
+ // abort if CSS highlight API not supported
280
+ if (typeof CSS === `undefined` || !CSS.highlights)
281
+ return;
282
+ // always clear our own highlight first
283
+ CSS.highlights.delete(css_class);
284
+ // if disabled or empty query, stop after cleanup
285
+ if (!query || disabled)
286
+ return;
287
+ const tree_walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
288
+ acceptNode: node_filter,
289
+ });
290
+ const text_nodes = [];
291
+ let current_node = tree_walker.nextNode();
292
+ while (current_node) {
293
+ text_nodes.push(current_node);
294
+ current_node = tree_walker.nextNode();
295
+ }
296
+ // iterate over all text nodes and find matches
297
+ const ranges = text_nodes.map((el) => {
298
+ const text = el.textContent?.toLowerCase();
299
+ if (!text)
300
+ return [];
301
+ const search = query.toLowerCase();
302
+ if (fuzzy) {
303
+ // Fuzzy highlighting: highlight individual characters that match in order
304
+ const matching_indices = [];
305
+ let search_idx = 0;
306
+ let target_idx = 0;
307
+ // Find matching character indices
308
+ while (search_idx < search.length && target_idx < text.length) {
309
+ if (search[search_idx] === text[target_idx]) {
310
+ matching_indices.push(target_idx);
311
+ search_idx++;
312
+ }
313
+ target_idx++;
314
+ }
315
+ // Only create ranges if we found all characters in order
316
+ if (search_idx === search.length) {
317
+ return matching_indices.map((index) => {
318
+ const range = new Range();
319
+ range.setStart(el, index);
320
+ range.setEnd(el, index + 1); // highlight single character
321
+ return range;
322
+ });
323
+ }
324
+ return [];
325
+ }
326
+ else {
327
+ // Substring highlighting: highlight consecutive substrings
328
+ const indices = [];
329
+ let start_pos = 0;
330
+ while (start_pos < text.length) {
331
+ const index = text.indexOf(search, start_pos);
332
+ if (index === -1)
333
+ break;
334
+ indices.push(index);
335
+ start_pos = index + search.length;
336
+ }
337
+ // create range object for each substring found in the text node
338
+ return indices.map((index) => {
339
+ const range = new Range();
340
+ range.setStart(el, index);
341
+ range.setEnd(el, index + search.length);
342
+ return range;
343
+ });
344
+ }
345
+ });
346
+ // create Highlight object from ranges and add to registry
347
+ CSS.highlights.set(css_class, new Highlight(...ranges.flat()));
348
+ // Return cleanup function
349
+ return () => CSS.highlights.delete(css_class);
350
+ };
351
+ // Global tooltip state to ensure only one tooltip is shown at a time
352
+ let current_tooltip = null;
353
+ let show_timeout;
354
+ let hide_timeout;
355
+ function clear_tooltip() {
356
+ if (show_timeout)
357
+ clearTimeout(show_timeout);
358
+ if (hide_timeout)
359
+ clearTimeout(hide_timeout);
360
+ if (current_tooltip) {
361
+ // Remove aria-describedby from the trigger element
362
+ current_tooltip._owner?.removeAttribute(`aria-describedby`);
363
+ current_tooltip.remove();
364
+ current_tooltip = null;
365
+ }
366
+ }
367
+ export const tooltip = (options = {}) => (node) => {
368
+ // SSR guard + element validation
369
+ if (typeof document === `undefined` || !(node instanceof HTMLElement))
370
+ return;
371
+ const cleanup_functions = [];
372
+ // Handle disabled option
373
+ if (options.disabled === true)
374
+ return;
375
+ // Track current input method for 'touch-devices' option (runtime detection, not capability sniffing)
376
+ // This allows tooltips on hybrid devices (Surface, iPad with mouse) when using mouse/stylus
377
+ let last_pointer_type = `mouse`;
378
+ const track_pointer = (event) => {
379
+ last_pointer_type = event.pointerType;
380
+ };
381
+ if (options.disabled === `touch-devices`) {
382
+ document.addEventListener(`pointerdown`, track_pointer, true);
383
+ cleanup_functions.push(() => document.removeEventListener(`pointerdown`, track_pointer, true));
384
+ }
385
+ function setup_tooltip(element) {
386
+ // Use let so content can be updated reactively
387
+ let content = options.content || element.title ||
388
+ element.getAttribute(`aria-label`) || element.getAttribute(`data-title`);
389
+ if (!content)
390
+ return;
391
+ // Store original title and remove it to prevent default tooltip
392
+ // Only store title if we're not using custom content
393
+ if (element.title && !options.content) {
394
+ element.setAttribute(`data-original-title`, element.title);
395
+ element.removeAttribute(`title`);
396
+ }
397
+ // Reactively update content when tooltip attributes change
398
+ const tooltip_attrs = [`title`, `aria-label`, `data-title`];
399
+ const observer = new MutationObserver((mutations) => {
400
+ if (options.content)
401
+ return; // custom content takes precedence
402
+ for (const { type, attributeName } of mutations) {
403
+ if (type !== `attributes` || !attributeName)
404
+ continue;
405
+ const new_content = element.getAttribute(attributeName);
406
+ // null = attribute removed (by us), skip entirely
407
+ if (new_content === null)
408
+ continue;
409
+ // Always remove title to prevent browser's native tooltip (even if empty)
410
+ // Disconnect observer temporarily to avoid re-entrancy from our own removal
411
+ if (attributeName === `title`) {
412
+ observer.disconnect();
413
+ element.removeAttribute(`title`);
414
+ observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
415
+ }
416
+ // Only update content if non-empty
417
+ if (!new_content)
418
+ continue;
419
+ content = new_content;
420
+ // Only update tooltip if this element owns it
421
+ if (current_tooltip?._owner === element) {
422
+ if (options.allow_html !== false) {
423
+ current_tooltip.innerHTML = content.replace(/\r/g, `<br/>`);
424
+ }
425
+ else {
426
+ current_tooltip.textContent = content;
427
+ }
428
+ }
429
+ }
430
+ });
431
+ observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
432
+ function show_tooltip() {
433
+ // Skip tooltip on touch input when 'touch-devices' option is set
434
+ if (options.disabled === `touch-devices` && last_pointer_type === `touch`)
435
+ return;
436
+ clear_tooltip();
437
+ show_timeout = setTimeout(() => {
438
+ const tooltip_el = document.createElement(`div`);
439
+ tooltip_el.className = `custom-tooltip`;
440
+ const placement = options.placement || `bottom`;
441
+ tooltip_el.setAttribute(`data-placement`, placement);
442
+ // Accessibility: link tooltip to trigger element
443
+ const tooltip_id = `tooltip-${crypto.randomUUID()}`;
444
+ tooltip_el.id = tooltip_id;
445
+ tooltip_el.setAttribute(`role`, `tooltip`);
446
+ element.setAttribute(`aria-describedby`, tooltip_id);
447
+ // Apply base styles
448
+ tooltip_el.style.cssText = `
449
+ position: absolute; z-index: 9999; opacity: 0;
450
+ background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
451
+ padding: var(--tooltip-padding, 6px 10px); border-radius: var(--tooltip-radius, 6px); font-size: var(--tooltip-font-size, 13px); line-height: 1.4;
452
+ max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; text-wrap: balance; pointer-events: none;
453
+ filter: var(--tooltip-shadow, drop-shadow(0 2px 8px rgba(0,0,0,0.25))); transition: opacity 0.15s ease-out;
454
+ `;
455
+ // Apply custom styles if provided (these will override base styles due to CSS specificity)
456
+ if (options.style) {
457
+ // Parse and apply custom styles as individual properties for better control
458
+ const custom_styles = options.style.split(`;`).filter((style) => style.trim());
459
+ custom_styles.forEach((style) => {
460
+ const [property, value] = style.split(`:`).map((s) => s.trim());
461
+ if (property && value)
462
+ tooltip_el.style.setProperty(property, value);
463
+ });
464
+ }
465
+ // Security: allow_html defaults to true; set to false for plain text rendering
466
+ if (options.allow_html !== false) {
467
+ tooltip_el.innerHTML = content?.replace(/\r/g, `<br/>`) ?? ``;
468
+ }
469
+ else {
470
+ tooltip_el.textContent = content ?? ``;
471
+ }
472
+ // Mirror CSS custom properties from the trigger node onto the tooltip element
473
+ const trigger_styles = getComputedStyle(element);
474
+ [
475
+ `--tooltip-bg`,
476
+ `--text-color`,
477
+ `--tooltip-border`,
478
+ `--tooltip-padding`,
479
+ `--tooltip-radius`,
480
+ `--tooltip-font-size`,
481
+ `--tooltip-shadow`,
482
+ `--tooltip-max-width`,
483
+ `--tooltip-opacity`,
484
+ `--tooltip-arrow-size`,
485
+ ].forEach((name) => {
486
+ const value = trigger_styles.getPropertyValue(name).trim();
487
+ if (value)
488
+ tooltip_el.style.setProperty(name, value);
489
+ });
490
+ // Append early so we can read computed border styles for arrow border
491
+ document.body.appendChild(tooltip_el);
492
+ // Create arrow elements only if show_arrow is not false
493
+ if (options.show_arrow !== false) {
494
+ const tooltip_styles = getComputedStyle(tooltip_el);
495
+ const arrow = document.createElement(`div`);
496
+ arrow.className = `custom-tooltip-arrow`;
497
+ arrow.style.cssText =
498
+ `position: absolute; width: 0; height: 0; pointer-events: none;`;
499
+ const arrow_size_raw = trigger_styles.getPropertyValue(`--tooltip-arrow-size`)
500
+ .trim();
501
+ const arrow_size_num = Number.parseInt(arrow_size_raw || ``, 10);
502
+ const arrow_px = Number.isFinite(arrow_size_num) ? arrow_size_num : 6;
503
+ const border_color = tooltip_styles.borderTopColor;
504
+ const border_width_num = Number.parseFloat(tooltip_styles.borderTopWidth || `0`);
505
+ const has_border = !!border_color && border_color !== `rgba(0, 0, 0, 0)` &&
506
+ border_width_num > 0;
507
+ // Helper to create border arrow behind fill arrow
508
+ const append_border_arrow = () => {
509
+ if (!has_border)
510
+ return;
511
+ const border_arrow = document.createElement(`div`);
512
+ border_arrow.className = `custom-tooltip-arrow-border`;
513
+ border_arrow.style.cssText =
514
+ `position: absolute; width: 0; height: 0; pointer-events: none;`;
515
+ const border_size = arrow_px + (border_width_num * 1.4);
516
+ if (placement === `top`) {
517
+ border_arrow.style.left = `calc(50% - ${border_size}px)`;
518
+ border_arrow.style.bottom = `-${border_size}px`;
519
+ border_arrow.style.borderLeft = `${border_size}px solid transparent`;
520
+ border_arrow.style.borderRight = `${border_size}px solid transparent`;
521
+ border_arrow.style.borderTop = `${border_size}px solid ${border_color}`;
522
+ }
523
+ else if (placement === `left`) {
524
+ border_arrow.style.top = `calc(50% - ${border_size}px)`;
525
+ border_arrow.style.right = `-${border_size}px`;
526
+ border_arrow.style.borderTop = `${border_size}px solid transparent`;
527
+ border_arrow.style.borderBottom = `${border_size}px solid transparent`;
528
+ border_arrow.style.borderLeft = `${border_size}px solid ${border_color}`;
529
+ }
530
+ else if (placement === `right`) {
531
+ border_arrow.style.top = `calc(50% - ${border_size}px)`;
532
+ border_arrow.style.left = `-${border_size}px`;
533
+ border_arrow.style.borderTop = `${border_size}px solid transparent`;
534
+ border_arrow.style.borderBottom = `${border_size}px solid transparent`;
535
+ border_arrow.style.borderRight = `${border_size}px solid ${border_color}`;
536
+ }
537
+ else { // bottom
538
+ border_arrow.style.left = `calc(50% - ${border_size}px)`;
539
+ border_arrow.style.top = `-${border_size}px`;
540
+ border_arrow.style.borderLeft = `${border_size}px solid transparent`;
541
+ border_arrow.style.borderRight = `${border_size}px solid transparent`;
542
+ border_arrow.style.borderBottom = `${border_size}px solid ${border_color}`;
543
+ }
544
+ tooltip_el.appendChild(border_arrow);
545
+ };
546
+ // Style and position fill arrow
547
+ if (placement === `top`) {
548
+ arrow.style.left = `calc(50% - ${arrow_px}px)`;
549
+ arrow.style.bottom = `-${arrow_px}px`;
550
+ arrow.style.borderLeft = `${arrow_px}px solid transparent`;
551
+ arrow.style.borderRight = `${arrow_px}px solid transparent`;
552
+ arrow.style.borderTop = `${arrow_px}px solid var(--tooltip-bg, #333)`;
553
+ }
554
+ else if (placement === `left`) {
555
+ arrow.style.top = `calc(50% - ${arrow_px}px)`;
556
+ arrow.style.right = `-${arrow_px}px`;
557
+ arrow.style.borderTop = `${arrow_px}px solid transparent`;
558
+ arrow.style.borderBottom = `${arrow_px}px solid transparent`;
559
+ arrow.style.borderLeft = `${arrow_px}px solid var(--tooltip-bg, #333)`;
560
+ }
561
+ else if (placement === `right`) {
562
+ arrow.style.top = `calc(50% - ${arrow_px}px)`;
563
+ arrow.style.left = `-${arrow_px}px`;
564
+ arrow.style.borderTop = `${arrow_px}px solid transparent`;
565
+ arrow.style.borderBottom = `${arrow_px}px solid transparent`;
566
+ arrow.style.borderRight = `${arrow_px}px solid var(--tooltip-bg, #333)`;
567
+ }
568
+ else { // bottom
569
+ arrow.style.left = `calc(50% - ${arrow_px}px)`;
570
+ arrow.style.top = `-${arrow_px}px`;
571
+ arrow.style.borderLeft = `${arrow_px}px solid transparent`;
572
+ arrow.style.borderRight = `${arrow_px}px solid transparent`;
573
+ arrow.style.borderBottom = `${arrow_px}px solid var(--tooltip-bg, #333)`;
574
+ }
575
+ append_border_arrow();
576
+ tooltip_el.appendChild(arrow);
577
+ }
578
+ // Position tooltip
579
+ const rect = element.getBoundingClientRect();
580
+ const tooltip_rect = tooltip_el.getBoundingClientRect();
581
+ const margin = options.offset ?? 12;
582
+ let top = 0, left = 0;
583
+ if (placement === `top`) {
584
+ top = rect.top - tooltip_rect.height - margin;
585
+ left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
586
+ }
587
+ else if (placement === `left`) {
588
+ top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
589
+ left = rect.left - tooltip_rect.width - margin;
590
+ }
591
+ else if (placement === `right`) {
592
+ top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
593
+ left = rect.right + margin;
594
+ }
595
+ else { // bottom
596
+ top = rect.bottom + margin;
597
+ left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
598
+ }
599
+ // Keep in viewport
600
+ left = Math.max(8, Math.min(left, globalThis.innerWidth - tooltip_rect.width - 8));
601
+ top = Math.max(8, Math.min(top, globalThis.innerHeight - tooltip_rect.height - 8));
602
+ tooltip_el.style.left = `${left + globalThis.scrollX}px`;
603
+ tooltip_el.style.top = `${top + globalThis.scrollY}px`;
604
+ const custom_opacity = trigger_styles.getPropertyValue(`--tooltip-opacity`).trim();
605
+ tooltip_el.style.opacity = custom_opacity || `1`;
606
+ current_tooltip = Object.assign(tooltip_el, { _owner: element });
607
+ }, options.delay || 100);
608
+ }
609
+ function handle_keydown(event) {
610
+ if (event.key === `Escape` && current_tooltip?._owner === element)
611
+ clear_tooltip();
612
+ }
613
+ function hide_tooltip() {
614
+ if (show_timeout)
615
+ clearTimeout(show_timeout);
616
+ const delay = options.hide_delay ?? 0;
617
+ if (delay > 0) {
618
+ hide_timeout = setTimeout(() => clear_tooltip(), delay);
619
+ }
620
+ else
621
+ clear_tooltip();
622
+ }
623
+ function handle_scroll(event) {
624
+ // Hide if document or any ancestor scrolls (would move element). Skip internal element scrolls.
625
+ const target = event.target;
626
+ if (target instanceof Node && target !== element && target.contains(element)) {
627
+ hide_tooltip();
628
+ }
629
+ }
630
+ const events = [`mouseenter`, `mouseleave`, `focus`, `blur`];
631
+ const handlers = [show_tooltip, hide_tooltip, show_tooltip, hide_tooltip];
632
+ events.forEach((event, idx) => element.addEventListener(event, handlers[idx]));
633
+ // Hide tooltip when user scrolls the page (not element-level scrolls like input fields)
634
+ globalThis.addEventListener(`scroll`, handle_scroll, true);
635
+ // Add Escape key listener to dismiss tooltip
636
+ document.addEventListener(`keydown`, handle_keydown);
637
+ // Watch for element removal from DOM to prevent orphaned tooltips
638
+ const removal_observer = new MutationObserver((mutations) => {
639
+ const was_removed = mutations.some((mut) => Array.from(mut.removedNodes).some((node) => node === element || (node instanceof Element && node.contains(element))));
640
+ if (was_removed && current_tooltip?._owner === element)
641
+ clear_tooltip();
642
+ });
643
+ if (element.parentElement) {
644
+ removal_observer.observe(element.parentElement, { childList: true, subtree: true });
645
+ }
646
+ return () => {
647
+ observer.disconnect();
648
+ removal_observer.disconnect();
649
+ events.forEach((event, idx) => element.removeEventListener(event, handlers[idx]));
650
+ globalThis.removeEventListener(`scroll`, handle_scroll, true);
651
+ document.removeEventListener(`keydown`, handle_keydown);
652
+ // Clear tooltip if this element owns it (also removes aria-describedby)
653
+ if (current_tooltip?._owner === element)
654
+ clear_tooltip();
655
+ const original_title = element.getAttribute(`data-original-title`);
656
+ if (original_title) {
657
+ element.setAttribute(`title`, original_title);
658
+ element.removeAttribute(`data-original-title`);
659
+ }
660
+ };
661
+ }
662
+ // Setup tooltip for main node and children
663
+ const main_cleanup = setup_tooltip(node);
664
+ if (main_cleanup)
665
+ cleanup_functions.push(main_cleanup);
666
+ node.querySelectorAll(`[title], [aria-label], [data-title]`).forEach((element) => {
667
+ const child_cleanup = setup_tooltip(element);
668
+ if (child_cleanup)
669
+ cleanup_functions.push(child_cleanup);
670
+ });
671
+ if (cleanup_functions.length === 0)
672
+ return;
673
+ return () => {
674
+ cleanup_functions.forEach((cleanup) => cleanup());
675
+ clear_tooltip();
676
+ };
677
+ };
678
+ export const click_outside = (config = {}) => (node) => {
679
+ const { callback, enabled = true, exclude = [] } = config;
680
+ if (!enabled)
681
+ return; // Early return avoids registering unused listener
682
+ function handle_click(event) {
683
+ const target = event.target;
684
+ const path = event.composedPath();
685
+ // Check if click target is the node or inside it
686
+ if (path.includes(node))
687
+ return;
688
+ // Check excluded selectors
689
+ if (exclude.some((selector) => target.closest(selector)))
690
+ return;
691
+ // Execute callback if provided, passing node and full config
692
+ callback?.(node, { callback, enabled, exclude });
693
+ // Dispatch custom event if click was outside
694
+ node.dispatchEvent(new CustomEvent(`outside-click`));
695
+ }
696
+ document.addEventListener(`click`, handle_click, true);
697
+ return () => document.removeEventListener(`click`, handle_click, true);
698
+ };