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.
- package/dist/CircleSpinner.svelte +29 -0
- package/dist/CircleSpinner.svelte.d.ts +8 -0
- package/dist/CmdPalette.svelte +74 -0
- package/dist/CmdPalette.svelte.d.ts +76 -0
- package/dist/CodeExample.svelte +85 -0
- package/dist/CodeExample.svelte.d.ts +25 -0
- package/dist/CopyButton.svelte +67 -0
- package/dist/CopyButton.svelte.d.ts +25 -0
- package/dist/FileDetails.svelte +65 -0
- package/dist/FileDetails.svelte.d.ts +22 -0
- package/dist/GitHubCorner.svelte +82 -0
- package/dist/GitHubCorner.svelte.d.ts +13 -0
- package/dist/Icon.svelte +23 -0
- package/dist/Icon.svelte.d.ts +8 -0
- package/dist/MultiSelect.svelte +1725 -0
- package/dist/MultiSelect.svelte.d.ts +25 -0
- package/dist/Nav.svelte +627 -0
- package/dist/Nav.svelte.d.ts +43 -0
- package/dist/PrevNext.svelte +105 -0
- package/dist/PrevNext.svelte.d.ts +56 -0
- package/dist/Toggle.svelte +77 -0
- package/dist/Toggle.svelte.d.ts +11 -0
- package/dist/Wiggle.svelte +22 -0
- package/dist/Wiggle.svelte.d.ts +18 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +698 -0
- package/dist/heading-anchors.d.ts +14 -0
- package/dist/heading-anchors.js +120 -0
- package/dist/icons.d.ts +55 -0
- package/dist/icons.js +54 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +43 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +63 -0
- package/package.json +20 -17
- package/readme.md +25 -1
|
@@ -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
|
+
};
|