svelte-multiselect
Version:
Svelte multi-select component
548 lines (547 loc) • 26.6 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) => {
if (options.disabled)
return;
const node = element;
// Use simple variables for maximum performance
let dragging = false;
let start = { x: 0, y: 0 };
const initial = { left: 0, top: 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;
}
else {
// For other positioning, use offset values
initial.left = node.offsetLeft;
initial.top = node.offsetTop;
}
node.style.left = `${initial.left}px`;
node.style.top = `${initial.top}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 = (options = {}) => (node) => {
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;
if (disabled)
return;
// This action can be applied to 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 original state for cleanup
const header_state = [];
for (const [idx, header] of headers.entries()) {
const original_text = header.textContent ?? ``;
const original_style = header.getAttribute(`style`) ?? ``;
header.style.cursor = `pointer`; // add cursor pointer to headers
const click_handler = () => {
// reset all headers to unsorted state
for (const { header: hdr, original_text, original_style } of header_state) {
hdr.textContent = original_text;
hdr.classList.remove(asc_class, desc_class);
if (original_style) {
hdr.setAttribute(`style`, original_style);
}
else {
hdr.removeAttribute(`style`);
}
hdr.style.cursor = `pointer`;
}
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);
header_state.push({ header, handler: click_handler, original_text, original_style });
}
// Return cleanup function that fully restores original state
return () => {
for (const { header, handler, original_text, original_style } of header_state) {
header.removeEventListener(`click`, handler);
header.textContent = original_text;
header.classList.remove(asc_class, desc_class);
if (original_style) {
header.setAttribute(`style`, original_style);
}
else {
header.removeAttribute(`style`);
}
}
};
};
export const highlight_matches = (ops) => (node) => {
const { query = ``, disabled = false, fuzzy = false, node_filter = () => NodeFilter.FILTER_ACCEPT, css_class = `highlight-match`, } = ops;
// abort if CSS highlight API not supported
if (typeof CSS === `undefined` || !CSS.highlights)
return;
// always clear our own highlight first
CSS.highlights.delete(css_class);
// if disabled or empty query, stop after cleanup
if (!query || disabled)
return;
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();
if (!text)
return [];
const search = query.toLowerCase();
if (fuzzy) {
// Fuzzy highlighting: highlight individual characters that match in order
const matching_indices = [];
let search_idx = 0;
let target_idx = 0;
// Find matching character indices
while (search_idx < search.length && target_idx < text.length) {
if (search[search_idx] === text[target_idx]) {
matching_indices.push(target_idx);
search_idx++;
}
target_idx++;
}
// Only create ranges if we found all characters in order
if (search_idx === search.length) {
return matching_indices.map((index) => {
const range = new Range();
range.setStart(el, index);
range.setEnd(el, index + 1); // highlight single character
return range;
});
}
return [];
}
else {
// Substring highlighting: highlight consecutive substrings
const indices = [];
let start_pos = 0;
while (start_pos < text.length) {
const index = text.indexOf(search, start_pos);
if (index === -1)
break;
indices.push(index);
start_pos = index + search.length;
}
// create range object for each substring found in the text node
return indices.map((index) => {
const range = new Range();
range.setStart(el, index);
range.setEnd(el, index + search.length);
return range;
});
}
});
// create Highlight object from ranges and add to registry
CSS.highlights.set(css_class, new Highlight(...ranges.flat()));
// Return cleanup function
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 || safe_options.disabled)
return;
// Use let so content can be updated reactively
let 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`);
}
// Reactively update content when tooltip attributes change
const tooltip_attrs = [`title`, `aria-label`, `data-title`];
const observer = new MutationObserver((mutations) => {
if (safe_options.content)
return; // custom content takes precedence
for (const { type, attributeName } of mutations) {
if (type !== `attributes` || !attributeName)
continue;
const new_content = element.getAttribute(attributeName);
// null = attribute removed (by us), skip entirely
if (new_content === null)
continue;
// Always remove title to prevent browser's native tooltip (even if empty)
// Disconnect observer temporarily to avoid re-entrancy from our own removal
if (attributeName === `title`) {
observer.disconnect();
element.removeAttribute(`title`);
observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
}
// Only update content if non-empty
if (!new_content)
continue;
content = new_content;
// Only update tooltip if this element owns it
if (current_tooltip?._owner === element) {
current_tooltip.innerHTML = content.replace(/\r/g, `<br/>`);
}
}
});
observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
function show_tooltip() {
clear_tooltip();
show_timeout = setTimeout(() => {
const tooltip = document.createElement(`div`);
tooltip.className = `custom-tooltip`;
const placement = safe_options.placement || `bottom`;
tooltip.setAttribute(`data-placement`, placement);
// Apply base styles
tooltip.style.cssText = `
position: absolute; z-index: 9999; opacity: 0;
background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
padding: var(--tooltip-padding, 6px 10px); border-radius: var(--tooltip-radius, 6px); font-size: var(--tooltip-font-size, 13px); line-height: 1.4;
max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; text-wrap: balance; pointer-events: none;
filter: var(--tooltip-shadow, drop-shadow(0 2px 8px rgba(0,0,0,0.25))); transition: opacity 0.15s ease-out;
`;
// Apply custom styles if provided (these will override base styles due to CSS specificity)
if (safe_options.style) {
// Parse and apply custom styles as individual properties for better control
const custom_styles = safe_options.style.split(`;`).filter((style) => style.trim());
custom_styles.forEach((style) => {
const [property, value] = style.split(`:`).map((s) => s.trim());
if (property && value)
tooltip.style.setProperty(property, value);
});
}
tooltip.innerHTML = content?.replace(/\r/g, `<br/>`) ?? ``;
// Mirror CSS custom properties from the trigger node onto the tooltip element
const trigger_styles = getComputedStyle(element);
[
`--tooltip-bg`,
`--text-color`,
`--tooltip-border`,
`--tooltip-padding`,
`--tooltip-radius`,
`--tooltip-font-size`,
`--tooltip-shadow`,
`--tooltip-max-width`,
`--tooltip-opacity`,
`--tooltip-arrow-size`,
].forEach((name) => {
const value = trigger_styles.getPropertyValue(name).trim();
if (value)
tooltip.style.setProperty(name, value);
});
// Append early so we can read computed border styles for arrow border
document.body.appendChild(tooltip);
// Arrow elements: optional border triangle behind fill triangle
const tooltip_styles = getComputedStyle(tooltip);
const arrow = document.createElement(`div`);
arrow.className = `custom-tooltip-arrow`;
arrow.style.cssText =
`position: absolute; width: 0; height: 0; pointer-events: none;`;
const arrow_size_raw = trigger_styles.getPropertyValue(`--tooltip-arrow-size`)
.trim();
const arrow_size_num = Number.parseInt(arrow_size_raw || ``, 10);
const arrow_px = Number.isFinite(arrow_size_num) ? arrow_size_num : 6;
const border_color = tooltip_styles.borderTopColor;
const border_width_num = Number.parseFloat(tooltip_styles.borderTopWidth || `0`);
const has_border = !!border_color && border_color !== `rgba(0, 0, 0, 0)` &&
border_width_num > 0;
const maybe_append_border_arrow = () => {
if (!has_border)
return;
const border_arrow = document.createElement(`div`);
border_arrow.className = `custom-tooltip-arrow-border`;
border_arrow.style.cssText =
`position: absolute; width: 0; height: 0; pointer-events: none;`;
const border_size = arrow_px + (border_width_num * 1.4);
if (placement === `top`) {
border_arrow.style.left = `calc(50% - ${border_size}px)`;
border_arrow.style.bottom = `-${border_size}px`;
border_arrow.style.borderLeft = `${border_size}px solid transparent`;
border_arrow.style.borderRight = `${border_size}px solid transparent`;
border_arrow.style.borderTop = `${border_size}px solid ${border_color}`;
}
else if (placement === `left`) {
border_arrow.style.top = `calc(50% - ${border_size}px)`;
border_arrow.style.right = `-${border_size}px`;
border_arrow.style.borderTop = `${border_size}px solid transparent`;
border_arrow.style.borderBottom = `${border_size}px solid transparent`;
border_arrow.style.borderLeft = `${border_size}px solid ${border_color}`;
}
else if (placement === `right`) {
border_arrow.style.top = `calc(50% - ${border_size}px)`;
border_arrow.style.left = `-${border_size}px`;
border_arrow.style.borderTop = `${border_size}px solid transparent`;
border_arrow.style.borderBottom = `${border_size}px solid transparent`;
border_arrow.style.borderRight = `${border_size}px solid ${border_color}`;
}
else { // bottom
border_arrow.style.left = `calc(50% - ${border_size}px)`;
border_arrow.style.top = `-${border_size}px`;
border_arrow.style.borderLeft = `${border_size}px solid transparent`;
border_arrow.style.borderRight = `${border_size}px solid transparent`;
border_arrow.style.borderBottom = `${border_size}px solid ${border_color}`;
}
tooltip.appendChild(border_arrow);
};
// Create the fill arrow on top
if (placement === `top`) {
arrow.style.left = `calc(50% - ${arrow_px}px)`;
arrow.style.bottom = `-${arrow_px}px`;
arrow.style.borderLeft = `${arrow_px}px solid transparent`;
arrow.style.borderRight = `${arrow_px}px solid transparent`;
arrow.style.borderTop = `${arrow_px}px solid var(--tooltip-bg, #333)`;
}
else if (placement === `left`) {
arrow.style.top = `calc(50% - ${arrow_px}px)`;
arrow.style.right = `-${arrow_px}px`;
arrow.style.borderTop = `${arrow_px}px solid transparent`;
arrow.style.borderBottom = `${arrow_px}px solid transparent`;
arrow.style.borderLeft = `${arrow_px}px solid var(--tooltip-bg, #333)`;
}
else if (placement === `right`) {
arrow.style.top = `calc(50% - ${arrow_px}px)`;
arrow.style.left = `-${arrow_px}px`;
arrow.style.borderTop = `${arrow_px}px solid transparent`;
arrow.style.borderBottom = `${arrow_px}px solid transparent`;
arrow.style.borderRight = `${arrow_px}px solid var(--tooltip-bg, #333)`;
}
else { // bottom
arrow.style.left = `calc(50% - ${arrow_px}px)`;
arrow.style.top = `-${arrow_px}px`;
arrow.style.borderLeft = `${arrow_px}px solid transparent`;
arrow.style.borderRight = `${arrow_px}px solid transparent`;
arrow.style.borderBottom = `${arrow_px}px solid var(--tooltip-bg, #333)`;
}
maybe_append_border_arrow();
tooltip.appendChild(arrow);
// Position tooltip
const rect = element.getBoundingClientRect();
const tooltip_rect = tooltip.getBoundingClientRect();
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`;
const custom_opacity = trigger_styles.getPropertyValue(`--tooltip-opacity`).trim();
tooltip.style.opacity = custom_opacity || `1`;
current_tooltip = Object.assign(tooltip, { _owner: element });
}, safe_options.delay || 100);
}
function hide_tooltip() {
clear_tooltip();
if (current_tooltip) {
current_tooltip.style.opacity = `0`;
if (current_tooltip) {
current_tooltip.remove();
current_tooltip = null;
}
}
}
function handle_scroll(event) {
// Hide if document or any ancestor scrolls (would move element). Skip internal element scrolls.
const target = event.target;
if (target instanceof Node && target !== element && target.contains(element)) {
hide_tooltip();
}
}
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]));
// Hide tooltip when user scrolls the page (not element-level scrolls like input fields)
globalThis.addEventListener(`scroll`, handle_scroll, true);
return () => {
observer.disconnect();
events.forEach((event, idx) => element.removeEventListener(event, handlers[idx]));
globalThis.removeEventListener(`scroll`, handle_scroll, true);
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;
if (!enabled)
return; // Early return avoids registering unused listener
function handle_click(event) {
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);
};