svelte-multiselect
Version:
Svelte multi-select component
346 lines (345 loc) • 15.1 kB
JavaScript
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);
};