UNPKG

svelte-multiselect

Version:
346 lines (345 loc) 15.1 kB
import {} from 'svelte/attachments'; // Svelte 5 attachment factory to make an element draggable // @param options - Configuration options for dragging behavior // @returns Attachment function that sets up dragging on an element export const draggable = (options = {}) => (element) => { const node = element; // Use simple variables for maximum performance let dragging = false; let start = { x: 0, y: 0 }; const initial = { left: 0, top: 0, width: 0 }; const handle = options.handle_selector ? node.querySelector(options.handle_selector) : node; if (!handle) { console.warn(`Draggable: handle not found with selector "${options.handle_selector}"`); return; } function handle_mousedown(event) { // Only drag if mousedown is on the handle or its children if (!handle?.contains?.(event.target)) return; dragging = true; // For position: fixed elements, use getBoundingClientRect for viewport-relative position const computed_style = getComputedStyle(node); if (computed_style.position === `fixed`) { const rect = node.getBoundingClientRect(); initial.left = rect.left; initial.top = rect.top; initial.width = rect.width; } else { // For other positioning, use offset values initial.left = node.offsetLeft; initial.top = node.offsetTop; initial.width = node.offsetWidth; } node.style.left = `${initial.left}px`; node.style.top = `${initial.top}px`; node.style.width = `${initial.width}px`; node.style.right = `auto`; // Prevent conflict with left start = { x: event.clientX, y: event.clientY }; document.body.style.userSelect = `none`; // Prevent text selection during drag if (handle) handle.style.cursor = `grabbing`; globalThis.addEventListener(`mousemove`, handle_mousemove); globalThis.addEventListener(`mouseup`, handle_mouseup); options.on_drag_start?.(event); // Call optional callback } function handle_mousemove(event) { if (!dragging) return; // Use the exact same calculation as the fast old implementation const dx = event.clientX - start.x; const dy = event.clientY - start.y; node.style.left = `${initial.left + dx}px`; node.style.top = `${initial.top + dy}px`; // Only call callback if it exists (minimize overhead) if (options.on_drag) options.on_drag(event); } function handle_mouseup(event) { if (!dragging) return; dragging = false; event.stopPropagation(); document.body.style.userSelect = ``; if (handle) handle.style.cursor = `grab`; globalThis.removeEventListener(`mousemove`, handle_mousemove); globalThis.removeEventListener(`mouseup`, handle_mouseup); options.on_drag_end?.(event); // Call optional callback } if (handle) { handle.addEventListener(`mousedown`, handle_mousedown); handle.style.cursor = `grab`; } // Return cleanup function (this is the attachment pattern) return () => { globalThis.removeEventListener(`mousemove`, handle_mousemove); globalThis.removeEventListener(`mouseup`, handle_mouseup); if (handle) { handle.removeEventListener(`mousedown`, handle_mousedown); handle.style.cursor = ``; // Reset cursor } }; }; export function get_html_sort_value(element) { if (element.dataset.sortValue !== undefined) { return element.dataset.sortValue; } for (const child of Array.from(element.children)) { const child_val = get_html_sort_value(child); if (child_val !== ``) return child_val; } return element.textContent ?? ``; } export const sortable = ({ header_selector = `thead th`, asc_class = `table-sort-asc`, desc_class = `table-sort-desc`, sorted_style = { backgroundColor: `rgba(255, 255, 255, 0.1)` }, } = {}) => (node) => { // this action can be applied to bob-standard HTML tables to make them sortable by // clicking on column headers (and clicking again to toggle sorting direction) const headers = Array.from(node.querySelectorAll(header_selector)); let sort_col_idx; let sort_dir = 1; // 1 = asc, -1 = desc // Store event listeners for cleanup const event_listeners = []; for (const [idx, header] of headers.entries()) { header.style.cursor = `pointer`; // add cursor pointer to headers const init_styles = header.getAttribute(`style`) ?? ``; const click_handler = () => { // reset all headers to initial state for (const header of headers) { header.textContent = header.textContent?.replace(/ ↑| ↓/, ``) ?? ``; header.classList.remove(asc_class, desc_class); header.setAttribute(`style`, init_styles); } if (idx === sort_col_idx) { sort_dir *= -1; // reverse sort direction } else { sort_col_idx = idx; // set new sort column index sort_dir = 1; // reset sort direction } header.classList.add(sort_dir > 0 ? asc_class : desc_class); Object.assign(header.style, sorted_style); header.textContent = `${header.textContent?.replace(/ ↑| ↓/, ``)} ${sort_dir > 0 ? `↑` : `↓`}`; const table_body = node.querySelector(`tbody`); if (!table_body) return; // re-sort table const rows = Array.from(table_body.querySelectorAll(`tr`)); rows.sort((row_1, row_2) => { const cell_1 = row_1.cells[sort_col_idx]; const cell_2 = row_2.cells[sort_col_idx]; const val_1 = get_html_sort_value(cell_1); const val_2 = get_html_sort_value(cell_2); if (val_1 === val_2) return 0; if (val_1 === ``) return 1; // treat empty string as lower than any value if (val_2 === ``) return -1; // any value is considered higher than empty string const num_1 = Number(val_1); const num_2 = Number(val_2); if (isNaN(num_1) && isNaN(num_2)) { return sort_dir * val_1.localeCompare(val_2, undefined, { numeric: true }); } return sort_dir * (num_1 - num_2); }); for (const row of rows) table_body.appendChild(row); }; header.addEventListener(`click`, click_handler); event_listeners.push({ header, handler: click_handler }); } // Return cleanup function return () => { for (const { header, handler } of event_listeners) { header.removeEventListener(`click`, handler); header.style.cursor = ``; // Reset cursor } }; }; export const highlight_matches = (ops) => (node) => { const { query = ``, disabled = false, node_filter = () => NodeFilter.FILTER_ACCEPT, css_class = `highlight-match`, } = ops; // clear previous ranges from HighlightRegistry CSS.highlights.clear(); if (!query || disabled || typeof CSS === `undefined` || !CSS.highlights) return; // abort if CSS highlight API not supported const tree_walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { acceptNode: node_filter, }); const text_nodes = []; let current_node = tree_walker.nextNode(); while (current_node) { text_nodes.push(current_node); current_node = tree_walker.nextNode(); } // iterate over all text nodes and find matches const ranges = text_nodes.map((el) => { const text = el.textContent?.toLowerCase(); const indices = []; let start_pos = 0; while (text && start_pos < text.length) { const index = text.indexOf(query, start_pos); if (index === -1) break; indices.push(index); start_pos = index + query.length; } // create range object for each str found in the text node return indices.map((index) => { const range = new Range(); range.setStart(el, index); range.setEnd(el, index + query?.length); return range; }); }); // create Highlight object from ranges and add to registry CSS.highlights.set(css_class, new Highlight(...ranges.flat())); return () => { CSS.highlights.delete(css_class); }; }; // Global tooltip state to ensure only one tooltip is shown at a time let current_tooltip = null; let show_timeout; let hide_timeout; function clear_tooltip() { if (show_timeout) clearTimeout(show_timeout); if (hide_timeout) clearTimeout(hide_timeout); if (current_tooltip) { current_tooltip.remove(); current_tooltip = null; } } export const tooltip = (options = {}) => (node) => { // Handle null/undefined elements if (!node || !(node instanceof HTMLElement)) return; // Handle null/undefined options const safe_options = options || {}; const cleanup_functions = []; function setup_tooltip(element) { if (!element) return; const content = safe_options.content || element.title || element.getAttribute(`aria-label`) || element.getAttribute(`data-title`); if (!content) return; // Store original title and remove it to prevent default tooltip // Only store title if we're not using custom content if (element.title && !safe_options.content) { element.setAttribute(`data-original-title`, element.title); element.removeAttribute(`title`); } function show_tooltip() { clear_tooltip(); show_timeout = setTimeout(() => { const tooltip = document.createElement(`div`); tooltip.className = `custom-tooltip`; tooltip.style.cssText = ` position: absolute; z-index: 9999; opacity: 0; background: var(--tooltip-bg); color: var(--text-color); border: var(--tooltip-border); padding: 6px 10px; border-radius: 6px; font-size: 13px; line-height: 1.4; max-width: 280px; word-wrap: break-word; pointer-events: none; filter: drop-shadow(0 2px 8px rgba(0,0,0,0.25)); transition: opacity 0.15s ease-out; `; tooltip.innerHTML = content?.replace(/\r/g, `<br/>`) ?? ``; document.body.appendChild(tooltip); // Position tooltip const rect = element.getBoundingClientRect(); const tooltip_rect = tooltip.getBoundingClientRect(); const placement = safe_options.placement || `bottom`; const margin = 12; let top = 0, left = 0; if (placement === `top`) { top = rect.top - tooltip_rect.height - margin; left = rect.left + rect.width / 2 - tooltip_rect.width / 2; } else if (placement === `left`) { top = rect.top + rect.height / 2 - tooltip_rect.height / 2; left = rect.left - tooltip_rect.width - margin; } else if (placement === `right`) { top = rect.top + rect.height / 2 - tooltip_rect.height / 2; left = rect.right + margin; } else { // bottom top = rect.bottom + margin; left = rect.left + rect.width / 2 - tooltip_rect.width / 2; } // Keep in viewport left = Math.max(8, Math.min(left, globalThis.innerWidth - tooltip_rect.width - 8)); top = Math.max(8, Math.min(top, globalThis.innerHeight - tooltip_rect.height - 8)); tooltip.style.left = `${left + globalThis.scrollX}px`; tooltip.style.top = `${top + globalThis.scrollY}px`; tooltip.style.opacity = `1`; current_tooltip = tooltip; }, safe_options.delay || 100); } function hide_tooltip() { clear_tooltip(); hide_timeout = setTimeout(() => { if (current_tooltip) { current_tooltip.style.opacity = `0`; setTimeout(() => { if (current_tooltip) { current_tooltip.remove(); current_tooltip = null; } }, 150); } }, 50); } const events = [`mouseenter`, `mouseleave`, `focus`, `blur`]; const handlers = [show_tooltip, hide_tooltip, show_tooltip, hide_tooltip]; events.forEach((event, idx) => element.addEventListener(event, handlers[idx])); return () => { events.forEach((event, idx) => element.removeEventListener(event, handlers[idx])); const original_title = element.getAttribute(`data-original-title`); if (original_title) { element.setAttribute(`title`, original_title); element.removeAttribute(`data-original-title`); } }; } // Setup tooltip for main node and children const main_cleanup = setup_tooltip(node); if (main_cleanup) cleanup_functions.push(main_cleanup); node.querySelectorAll(`[title], [aria-label], [data-title]`).forEach((element) => { const child_cleanup = setup_tooltip(element); if (child_cleanup) cleanup_functions.push(child_cleanup); }); if (cleanup_functions.length === 0) return; return () => { cleanup_functions.forEach((cleanup) => cleanup()); clear_tooltip(); }; }; export const click_outside = (config = {}) => (node) => { const { callback, enabled = true, exclude = [] } = config; function handle_click(event) { if (!enabled) return; const target = event.target; const path = event.composedPath(); // Check if click target is the node or inside it if (path.includes(node)) return; // Check excluded selectors if (exclude.some((selector) => target.closest(selector))) return; // Execute callback if provided, passing node and full config callback?.(node, { callback, enabled, exclude }); // Dispatch custom event if click was outside node.dispatchEvent(new CustomEvent(`outside-click`)); } document.addEventListener(`click`, handle_click, true); return () => document.removeEventListener(`click`, handle_click, true); };