svelte-multiselect
Version:
Svelte multi-select component
871 lines (870 loc) • 44.3 kB
JavaScript
import { fuzzy_match_indices, get_uuid } from './utils';
// Computed CSS lengths resolve to `<number>px`; strip the unit so Number() can coerce.
// Empty and non-px values (e.g. `none`, `0.5rem`) yield NaN so callers can apply fallbacks.
const css_px = (css_length) => {
const trimmed = css_length.trim();
return trimmed ? Number(trimmed.replace(/px$/, ``)) : NaN;
};
// 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 undefined;
if (!(element instanceof HTMLElement))
return undefined;
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 undefined;
}
function handle_mousedown(event) {
// Only drag if mousedown is on the handle or its children
if (!(event.target instanceof Node) || !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`;
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 unmounted mid-drag, undo global side effects mouseup would have reset
if (dragging)
document.body.style.userSelect = ``;
if (handle) {
handle.removeEventListener(`mousedown`, handle_mousedown);
handle.style.cursor = ``; // Reset cursor
}
};
};
// Automatically sets `position: relative` on elements with `position: static`
// to enable proper positioning during resize. This may affect existing layouts.
export const resizable = (options = {}) => (element) => {
if (options.disabled)
return undefined;
if (!(element instanceof HTMLElement))
return undefined;
const node = element;
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;
if (min_width > max_width || min_height > max_height) {
console.warn(`resizable: min dimensions exceed max dimensions (min_width=${min_width}, max_width=${max_width}, min_height=${min_height}, max_height=${max_height})`);
return undefined; // Invalid config would cause clamp() to produce inconsistent results
}
let active_edge = null;
let start = { x: 0, y: 0 };
let initial = { width: 0, height: 0, left: 0, top: 0 };
const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
if (getComputedStyle(node).position === `static`)
node.style.position = `relative`;
const get_edge = ({ clientX: cx, clientY: cy }) => {
const { left, right, top, bottom } = node.getBoundingClientRect();
if (edges.includes(`right`) && cx >= right - handle_size && cx <= right) {
return `right`;
}
if (edges.includes(`bottom`) && cy >= bottom - handle_size && cy <= bottom) {
return `bottom`;
}
if (edges.includes(`left`) && cx >= left && cx <= left + handle_size)
return `left`;
if (edges.includes(`top`) && cy >= top && cy <= top + handle_size)
return `top`;
return null;
};
function on_mousedown(event) {
active_edge = get_edge(event);
if (!active_edge)
return;
start = { x: event.clientX, y: event.clientY };
initial = {
width: node.offsetWidth,
height: node.offsetHeight,
left: node.offsetLeft,
top: node.offsetTop,
};
document.body.style.userSelect = `none`;
on_resize_start?.(event, { width: initial.width, height: initial.height });
globalThis.addEventListener(`mousemove`, on_mousemove);
globalThis.addEventListener(`mouseup`, on_mouseup);
}
function on_mousemove(event) {
if (!active_edge)
return;
const dx = event.clientX - start.x, dy = event.clientY - start.y;
let { width, height } = initial;
if (active_edge === `right`)
width = clamp(initial.width + dx, min_width, max_width);
else if (active_edge === `left`) {
const clamped = clamp(initial.width - dx, min_width, max_width);
node.style.left = `${initial.left - (clamped - initial.width)}px`;
width = clamped;
}
if (active_edge === `bottom`) {
height = clamp(initial.height + dy, min_height, max_height);
}
else if (active_edge === `top`) {
const clamped = clamp(initial.height - dy, min_height, max_height);
node.style.top = `${initial.top - (clamped - initial.height)}px`;
height = clamped;
}
node.style.width = `${width}px`;
node.style.height = `${height}px`;
on_resize?.(event, { width, height });
}
function on_mouseup(event) {
if (!active_edge)
return;
document.body.style.userSelect = ``;
on_resize_end?.(event, { width: node.offsetWidth, height: node.offsetHeight });
globalThis.removeEventListener(`mousemove`, on_mousemove);
globalThis.removeEventListener(`mouseup`, on_mouseup);
active_edge = null;
}
function on_hover(event) {
const edge = get_edge(event);
node.style.cursor =
edge === `right` || edge === `left`
? `ew-resize`
: edge === `top` || edge === `bottom`
? `ns-resize`
: ``;
}
node.addEventListener(`mousedown`, on_mousedown);
node.addEventListener(`mousemove`, on_hover);
return () => {
node.removeEventListener(`mousedown`, on_mousedown);
node.removeEventListener(`mousemove`, on_hover);
globalThis.removeEventListener(`mousemove`, on_mousemove);
globalThis.removeEventListener(`mouseup`, on_mouseup);
// If unmounted mid-resize, undo global side effects mouseup would have reset
if (active_edge)
document.body.style.userSelect = ``;
node.style.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)) {
if (!(child instanceof HTMLElement))
continue;
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 undefined;
// 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 = [];
const restore_header = ({ header, original_html, original_style }) => {
// Restore innerHTML (not textContent) to preserve child markup like icons
header.innerHTML = original_html;
header.classList.remove(asc_class, desc_class);
if (original_style)
header.setAttribute(`style`, original_style);
else
header.removeAttribute(`style`);
};
headers.forEach((header, idx) => {
const original_html = header.innerHTML;
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 state of header_state) {
restore_header(state);
state.header.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);
// Render arrow in a separate span so the header's own markup stays intact
// (restore_header above already removed any previous arrow span)
const arrow_span = document.createElement(`span`);
arrow_span.className = `sort-arrow`;
arrow_span.textContent = ` ${sort_dir > 0 ? `↑` : `↓`}`;
header.append(arrow_span);
const table_body = node.querySelector(`tbody`);
if (!table_body)
return;
// re-sort table (:scope > tr so rows of nested tables aren't re-parented)
const rows = Array.from(table_body.querySelectorAll(`:scope > 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];
// Rows can have fewer cells than the sort column (colspan placeholders,
// ragged rows) — treat missing cells as empty so they sort last
const val_1 = cell_1 ? get_html_sort_value(cell_1) : ``;
const val_2 = cell_2 ? get_html_sort_value(cell_2) : ``;
const [trimmed_1, trimmed_2] = [val_1.trim(), val_2.trim()];
if (trimmed_1 === trimmed_2)
return 0;
if (trimmed_1 === ``)
return 1; // treat empty/whitespace as lower than any value
if (trimmed_2 === ``)
return -1;
const num_1 = Number(trimmed_1);
const num_2 = Number(trimmed_2);
if (isNaN(num_1) && isNaN(num_2)) {
return (sort_dir * trimmed_1.localeCompare(trimmed_2, undefined, { numeric: true }));
}
// sort non-numeric values after numeric ones
if (isNaN(num_1))
return sort_dir;
if (isNaN(num_2))
return -sort_dir;
return sort_dir * (num_1 - num_2);
});
for (const row of rows)
table_body.append(row);
};
header.addEventListener(`click`, click_handler);
header_state.push({ header, handler: click_handler, original_html, original_style });
});
// Return cleanup function that fully restores original state
return () => {
for (const state of header_state) {
state.header.removeEventListener(`click`, state.handler);
restore_header(state);
}
};
};
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 undefined;
// always clear our own highlight first
CSS.highlights.delete(css_class);
// if disabled or empty query, stop after cleanup
if (!query || disabled)
return undefined;
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 find_ranges = (el) => {
const original_text = el.textContent;
if (!original_text)
return [];
const text = original_text.toLowerCase();
// Offsets are computed on the lowercased text but applied to the original
// node. Lowercasing can grow some Unicode chars (e.g. İ → i̇, 2 UTF-16 units),
// shifting offsets. Build a lowered→original offset map (per-unit lowercase
// preserves alignment; only length-changing chars need mapping) so ranges
// land on the right original characters.
const node_length = original_text.length;
let orig_offsets = null; // lowered offset → original offset
if (text.length !== node_length) {
orig_offsets = [];
for (let orig_idx = 0; orig_idx < node_length; orig_idx++) {
const lowered_char = original_text[orig_idx].toLowerCase();
// one entry per lowered UTF-16 unit
orig_offsets.push(...Array.from(lowered_char, () => orig_idx));
}
}
const make_range = (start, end) => {
const orig_start = orig_offsets ? (orig_offsets[start] ?? node_length) : start;
const orig_end = orig_offsets ? (orig_offsets[end - 1] ?? node_length - 1) + 1 : end;
if (orig_start >= node_length)
return [];
const range = new Range();
range.setStart(el, orig_start);
range.setEnd(el, Math.min(orig_end, node_length));
return [range];
};
const search = query.toLowerCase();
if (fuzzy) {
// Fuzzy highlighting: highlight individual characters that match in order.
// null means not all characters matched, so highlight nothing.
const matching_indices = fuzzy_match_indices(search, text);
// highlight single characters
return (matching_indices ?? []).flatMap((index) => make_range(index, index + 1));
}
// 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.flatMap((index) => make_range(index, index + search.length));
};
const ranges = text_nodes.map((text_node) => find_ranges(text_node));
// 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;
// Element that scheduled the pending show_timeout. owner_element is only set once
// the timeout fires, so pending shows need their own ownership tracking to let
// cleanup of one tooltip instance leave another instance's pending show alone.
let show_timeout_owner = null;
function clear_tooltip() {
if (show_timeout)
clearTimeout(show_timeout);
show_timeout_owner = null;
if (hide_timeout)
clearTimeout(hide_timeout);
if (current_tooltip) {
// Remove aria-describedby from the trigger element
current_tooltip.owner_element?.removeAttribute(`aria-describedby`);
current_tooltip.remove();
current_tooltip = null;
}
}
// whether the element owns the visible tooltip or a pending (delayed) show
const owns_tooltip_state = (element) => current_tooltip?.owner_element === element || show_timeout_owner === element;
function render_tooltip_content(content_el, content, options) {
if (options.allow_html !== true) {
content_el.textContent = content;
return;
}
// Convert all newline flavors: HTML parsing normalizes CR/CRLF to LF in
// attributes, and JS callers pass \n, so matching only \r would never fire
let html = content.replaceAll(/\r\n?|\n/gu, `<br/>`);
if (options.sanitize_html)
html = options.sanitize_html(html);
content_el.innerHTML = html;
}
export const tooltip = (options = {}) => (node) => {
// SSR guard + element validation
if (typeof document === `undefined` || !(node instanceof HTMLElement))
return undefined;
const cleanup_functions = [];
// Handle disabled option
if (options.disabled === true)
return undefined;
// Track current input method for 'touch-devices' option (runtime detection, not capability sniffing)
// This allows tooltips on hybrid devices (Surface, iPad with mouse) when using mouse/stylus
let last_pointer_type = `mouse`;
const track_pointer = (event) => {
last_pointer_type = event.pointerType;
};
if (options.disabled === `touch-devices`) {
document.addEventListener(`pointerdown`, track_pointer, true);
cleanup_functions.push(() => document.removeEventListener(`pointerdown`, track_pointer, true));
}
function setup_tooltip(element) {
const tooltip_attrs = [`title`, `aria-label`, `data-title`];
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string should fall through
let content = ((options.content ?? element.title) || element.getAttribute(`aria-label`)) ??
element.getAttribute(`data-title`);
if (!content)
return undefined;
// Store original title and remove it to prevent native browser tooltip.
// This must happen even when options.content is provided, otherwise both
// custom and native tooltips may appear.
if (element.hasAttribute(`title`)) {
element.setAttribute(`data-original-title`, element.title);
element.removeAttribute(`title`);
}
// Reactively update content when tooltip attributes change
const observer = new MutationObserver((mutations) => {
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,
});
// Preserve original title for cleanup if title is set later dynamically
if (!element.hasAttribute(`data-original-title`) && new_content) {
element.setAttribute(`data-original-title`, new_content);
}
}
if (options.content)
continue; // custom content takes precedence
// 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 === element) {
const content_el = current_tooltip.querySelector(`.tooltip-content`);
if (content_el) {
render_tooltip_content(content_el, content, options);
// Re-run sizing/positioning after content change
resize_and_position_tooltip(current_tooltip, element);
}
}
}
});
observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
const opposite = {
top: `bottom`,
bottom: `top`,
left: `right`,
right: `left`,
};
const apply_triangle_style = (el, placement, px, color) => {
el.style.cssText = `position: absolute; width: 0; height: 0; pointer-events: none;`;
const vert = placement === `top` || placement === `bottom`;
const set = (prop, val) => el.style.setProperty(prop, val);
set(vert ? `left` : `top`, `calc(50% - ${px}px)`);
set(opposite[placement], `-${px}px`);
set(`border-${vert ? `left` : `top`}`, `${px}px solid transparent`);
set(`border-${vert ? `right` : `bottom`}`, `${px}px solid transparent`);
set(`border-${placement}`, `${px}px solid ${color}`);
};
const sync_arrow_styles = (tooltip_el, placement) => {
const arrow = tooltip_el.querySelector(`.custom-tooltip-arrow`);
if (!arrow)
return;
const tooltip_styles = getComputedStyle(tooltip_el);
const default_arrow_color = `var(--tooltip-bg, light-dark(#fff, #2a2a2e))`;
const tooltip_bg = tooltip_styles.backgroundColor.trim();
const transparent_colors = new Set([``, `transparent`, `rgba(0, 0, 0, 0)`]);
const is_transparent = (color) => transparent_colors.has(color.trim());
const arrow_size_raw = tooltip_styles
.getPropertyValue(`--tooltip-arrow-size`)
.trim();
const arrow_size_num = css_px(arrow_size_raw);
const arrow_px = Number.isFinite(arrow_size_num) ? arrow_size_num : 6;
apply_triangle_style(arrow, placement, arrow_px, is_transparent(tooltip_bg) ? default_arrow_color : tooltip_bg);
const border_arrow = tooltip_el.querySelector(`.custom-tooltip-arrow-border`);
if (!border_arrow)
return;
const border_color = tooltip_styles.borderTopColor;
const border_width_num = css_px(tooltip_styles.borderTopWidth);
const has_border = !is_transparent(border_color) && border_width_num > 0;
if (!has_border) {
border_arrow.remove();
return;
}
apply_triangle_style(border_arrow, placement, arrow_px + border_width_num * 1.4, border_color);
};
// Shrink tooltip to fit content, then position for correct centering
function resize_and_position_tooltip(tooltip_el, trigger) {
// Reset width to allow natural sizing before measuring
tooltip_el.style.width = ``;
requestAnimationFrame(() => {
if (!document.body.contains(tooltip_el))
return;
const computed = getComputedStyle(tooltip_el);
const padding_h = css_px(computed.paddingLeft) + css_px(computed.paddingRight);
const border_h = css_px(computed.borderLeftWidth) + css_px(computed.borderRightWidth);
// Handle max-width: none as unbounded, fallback to 280 only for invalid values
const max_width_raw = computed.maxWidth;
const max_width_parsed = css_px(max_width_raw);
const max_width = max_width_raw === `none`
? Infinity
: Number.isFinite(max_width_parsed)
? max_width_parsed
: 280;
// With border-box, width includes padding+border; with content-box, subtract them
const box_adjust = computed.boxSizing === `border-box` ? 0 : padding_h + border_h;
const style = tooltip_el.style;
const raw = tooltip_el.getAttribute(`data-placement`) ?? ``;
const requested_placement = raw === `top` || raw === `right` || raw === `bottom` || raw === `left`
? raw
: `bottom`;
// Save styles, measure single-line width with wrapping disabled
const saved = {
maxWidth: style.maxWidth,
overflowWrap: style.overflowWrap,
textWrap: style.textWrap,
whiteSpace: style.whiteSpace,
};
Object.assign(style, {
maxWidth: `none`,
overflowWrap: `normal`,
textWrap: `nowrap`,
whiteSpace: `nowrap`,
width: `auto`,
});
const single_line_width = tooltip_el.offsetWidth;
Object.assign(style, saved);
// Convert offsetWidth to the value needed for style.width
const content_width = single_line_width - box_adjust;
if (content_width <= max_width) {
// Single-line: set exact width and prevent wrapping
style.width = `${Math.max(0, content_width)}px`;
style.textWrap = `nowrap`;
}
else {
// Multi-line: binary search for minimum width that doesn't add line breaks
style.width = ``;
const baseline_height = tooltip_el.offsetHeight;
const initial_width = tooltip_el.offsetWidth;
// Get min-content (longest word) as lower bound
Object.assign(style, {
maxWidth: `none`,
overflowWrap: `normal`,
width: `min-content`,
});
const min_width = tooltip_el.offsetWidth;
Object.assign(style, {
maxWidth: saved.maxWidth,
overflowWrap: saved.overflowWrap,
width: `${initial_width - box_adjust}px`,
});
// If longest word exceeds wrapped width, grow past max-width so the
// word stays intact (restoring maxWidth would clamp width back down).
if (min_width >= initial_width) {
style.maxWidth = `none`;
style.width = `${min_width - box_adjust}px`;
}
else {
// Binary search for minimum width that maintains baseline height
// Work in offsetWidth units, convert to style.width only when setting
let [best, high, low] = [initial_width, initial_width, min_width];
while (high - low > 1) {
const mid = Math.floor((low + high) / 2);
style.width = `${mid - box_adjust}px`;
if (tooltip_el.offsetHeight > baseline_height)
low = mid;
else {
best = mid;
high = mid;
}
}
style.width = `${best - box_adjust}px`;
}
}
// Position tooltip after width adjustment so centering uses final dimensions
const rect = trigger.getBoundingClientRect();
const tooltip_rect = tooltip_el.getBoundingClientRect();
const margin = options.offset ?? 12;
const center_h = rect.left + rect.width / 2 - tooltip_rect.width / 2;
const center_v = rect.top + rect.height / 2 - tooltip_rect.height / 2;
const position_by_placement = {
top: { top: rect.top - tooltip_rect.height - margin, left: center_h },
bottom: { top: rect.bottom + margin, left: center_h },
left: { top: center_v, left: rect.left - tooltip_rect.width - margin },
right: { top: center_v, left: rect.right + margin },
};
const fallback_order = {
bottom: [`bottom`, `top`, `right`, `left`],
top: [`top`, `bottom`, `right`, `left`],
right: [`right`, `left`, `bottom`, `top`],
left: [`left`, `right`, `bottom`, `top`],
};
const min_padding = 8;
const { innerWidth, innerHeight } = globalThis;
const get_overflow = ({ top, left }) => Math.max(0, min_padding - top) +
Math.max(0, min_padding - left) +
Math.max(0, top + tooltip_rect.height + min_padding - innerHeight) +
Math.max(0, left + tooltip_rect.width + min_padding - innerWidth);
let chosen_placement = requested_placement;
let best_overflow = Infinity;
for (const candidate of fallback_order[requested_placement]) {
const overflow = get_overflow(position_by_placement[candidate]);
if (overflow < best_overflow) {
chosen_placement = candidate;
best_overflow = overflow;
}
if (overflow === 0)
break;
}
// Persist final placement for downstream styling.
tooltip_el.setAttribute(`data-placement`, chosen_placement);
sync_arrow_styles(tooltip_el, chosen_placement);
let { top, left } = position_by_placement[chosen_placement];
// Keep in viewport
const max_left = innerWidth - tooltip_rect.width - min_padding;
left = Math.max(min_padding, Math.min(left, max_left));
const max_top = innerHeight - tooltip_rect.height - min_padding;
top = Math.max(min_padding, Math.min(top, max_top));
style.left = `${left + globalThis.scrollX}px`;
style.top = `${top + globalThis.scrollY}px`;
style.opacity =
getComputedStyle(trigger).getPropertyValue(`--tooltip-opacity`).trim() || `1`;
});
}
function show_tooltip() {
// Skip tooltip on touch input when 'touch-devices' option is set
if (options.disabled === `touch-devices` && last_pointer_type === `touch`)
return;
clear_tooltip();
show_timeout_owner = element;
show_timeout = setTimeout(() => {
show_timeout_owner = null;
const tooltip_el = document.createElement(`div`);
tooltip_el.className = `custom-tooltip`;
const placement = options.placement ?? `bottom`;
tooltip_el.setAttribute(`data-placement`, placement);
// Accessibility: link tooltip to trigger element
const tooltip_id = `tooltip-${get_uuid()}`;
tooltip_el.id = tooltip_id;
tooltip_el.setAttribute(`role`, `tooltip`);
element.setAttribute(`aria-describedby`, tooltip_id);
// light-dark() inherits the page's color-scheme (defaults to light if unset)
tooltip_el.style.cssText = `
position: absolute; z-index: 9999; opacity: 0; display: inline-block;
background-color: var(--tooltip-bg, light-dark(#fff, #2a2a2e)); color: var(--text-color, light-dark(#222, #eee)); border: var(--tooltip-border, 1px solid light-dark(lightgray, #555));
padding: var(--tooltip-padding, 2px 6px); border-radius: var(--tooltip-radius, 5pt); font-size: var(--tooltip-font-size, 0.8rem); line-height: 1.4;
max-width: var(--tooltip-max-width, 280px); overflow-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 (options.style) {
const custom_style = document.createElement(`div`).style;
custom_style.cssText = options.style;
for (let idx = 0; idx < custom_style.length; idx++) {
const property = custom_style.item(idx);
tooltip_el.style.setProperty(property, custom_style.getPropertyValue(property), custom_style.getPropertyPriority(property));
}
}
// Wrap content in a span for reactive content updates
const content_span = document.createElement(`span`);
content_span.className = `tooltip-content`;
// Security: content renders as plain text unless allow_html is explicitly true.
render_tooltip_content(content_span, content ?? ``, options);
tooltip_el.append(content_span);
// Mirror CSS custom properties from the trigger node onto the tooltip element
const trigger_styles = getComputedStyle(element);
for (const css_var_name of [
`--tooltip-bg`,
`--text-color`,
`--tooltip-border`,
`--tooltip-padding`,
`--tooltip-radius`,
`--tooltip-font-size`,
`--tooltip-shadow`,
`--tooltip-max-width`,
`--tooltip-opacity`,
`--tooltip-arrow-size`,
]) {
const value = trigger_styles.getPropertyValue(css_var_name).trim();
if (value)
tooltip_el.style.setProperty(css_var_name, value);
}
// Pages that style themselves dark via CSS vars but never declare `color-scheme`
// resolve the default light-dark() background to LIGHT while the inherited
// --text-color may be near-white → unreadable tooltip. Follow the OS preference
// and pair the text color with it. Pages that declare a scheme keep control
// (see #405), as do triggers with custom --tooltip-bg or own --text-color.
// Read the scheme from document.body (inherits any root-declared scheme), not
// the trigger: the tooltip is appended to body, so a scheme on some container
// around the trigger never reaches it.
const body_styles = getComputedStyle(document.body);
const page_scheme = body_styles.colorScheme;
if (!page_scheme || page_scheme === `normal`) {
// A var counts as a per-trigger override only if it differs from the value
// inherited from body/:root (page-level styling shouldn't disable the
// fallback). Compare against body, not documentElement: the body-mounted
// tooltip inherits from body, which in turn inherits :root-declared vars.
const overrides_page = (css_var) => {
const value = tooltip_el.style.getPropertyValue(css_var);
return value && value !== body_styles.getPropertyValue(css_var).trim();
};
if (!overrides_page(`--tooltip-bg`) && !overrides_page(`--text-color`)) {
tooltip_el.style.setProperty(`color-scheme`, `light dark`);
tooltip_el.style.setProperty(`--text-color`, `light-dark(#222, #eee)`);
}
}
// Append early so we can read computed border styles for arrow border
document.body.append(tooltip_el);
// Create arrow elements before resize so the rAF inside
// resize_and_position_tooltip can find and style them with the resolved placement.
if (options.show_arrow !== false) {
const border_arrow = document.createElement(`div`);
border_arrow.className = `custom-tooltip-arrow-border`;
const arrow = document.createElement(`div`);
arrow.className = `custom-tooltip-arrow`;
tooltip_el.append(border_arrow, arrow);
}
resize_and_position_tooltip(tooltip_el, element);
current_tooltip = Object.assign(tooltip_el, { owner_element: element });
}, options.delay ?? 100);
}
function handle_keydown(event) {
if (event.key === `Escape` && current_tooltip?.owner_element === element) {
clear_tooltip();
}
}
function hide_tooltip() {
if (show_timeout)
clearTimeout(show_timeout);
const delay = options.hide_delay ?? 0;
if (delay > 0) {
hide_timeout = setTimeout(() => clear_tooltip(), delay);
}
else
clear_tooltip();
}
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);
// Add Escape key listener to dismiss tooltip
document.addEventListener(`keydown`, handle_keydown);
// Watch for element removal from DOM to prevent orphaned tooltips
const removal_observer = new MutationObserver((mutations) => {
const was_removed = mutations.some((mut) => Array.from(mut.removedNodes).some((removed_node) => removed_node === element ||
(removed_node instanceof Element && removed_node.contains(element))));
// Clear owned visible tooltip AND owned pending show — otherwise the
// pending timeout fires after removal and appends an orphaned tooltip
// positioned against a detached element
if (was_removed && owns_tooltip_state(element))
clear_tooltip();
});
if (element.parentElement) {
removal_observer.observe(element.parentElement, {
childList: true,
subtree: true,
});
}
return () => {
observer.disconnect();
removal_observer.disconnect();
events.forEach((event, idx) => element.removeEventListener(event, handlers[idx]));
globalThis.removeEventListener(`scroll`, handle_scroll, true);
document.removeEventListener(`keydown`, handle_keydown);
// Clear owned tooltip/pending show (also removes aria-describedby)
if (owns_tooltip_state(element))
clear_tooltip();
if (element.hasAttribute(`data-original-title`)) {
element.setAttribute(`title`, element.getAttribute(`data-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((el) => {
if (!(el instanceof HTMLElement))
return;
const child_cleanup = setup_tooltip(el);
if (child_cleanup)
cleanup_functions.push(child_cleanup);
});
if (cleanup_functions.length === 0)
return undefined;
// per-element cleanups already clear any tooltip/pending show they own, and
// only elements set up here can own one — no extra outer clearing needed
return () => cleanup_functions.forEach((cleanup) => cleanup());
};
export const click_outside = (config = {}) => (node) => {
const { callback, enabled = true, exclude = [] } = config;
if (!enabled)
return undefined; // Early return avoids registering unused listener
function handle_click(event) {
const { target } = event;
// Element (not HTMLElement) so clicks on SVG elements still count; .closest
// below exists on all Elements
if (!(target instanceof Element))
return;
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);
};
};