melt
Version:
The next generation of Melt UI. Built for Svelte 5.
414 lines (413 loc) • 19 kB
JavaScript
import { Synced } from "../Synced.svelte";
import { dataAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { createBuilderMetadata } from "../utils/identifiers";
import { kbd } from "../utils/keyboard";
import { dequal } from "dequal";
import { createAttachmentKey } from "svelte/attachments";
import { on } from "svelte/events";
const { dataAttrs } = createBuilderMetadata("spatial-menu", ["root", "input", "item"]);
export class SpatialMenu {
/* Props */
#props;
onSelect = $derived(this.#props.onSelect);
wrap = $derived(typeof this.#props.wrap === "function" ? this.#props.wrap() : this.#props.wrap ?? false);
scrollBehavior = $derived(extract(this.#props.scrollBehavior, "smooth"));
toleranceCol = $derived(extract(this.#props.toleranceCol, 16));
toleranceRow = $derived(extract(this.#props.toleranceRow, 16));
crossAxis = $derived(extract(this.#props.crossAxis, false));
/* State */
#elMap = {};
#items = [];
selectionMode = $state("keyboard");
#highlighted;
get highlighted() {
return this.#highlighted.current;
}
set highlighted(v) {
this.#highlighted.current = v;
}
constructor(props = {}) {
this.#props = props;
this.#highlighted = new Synced({
value: props.highlighted,
onChange: props.onHighlightChange,
defaultValue: null,
});
}
#findClosestItem(direction) {
const current = this.#items.find((i) => i.highlighted);
const currentRect = current?.extendedRect;
if (!currentRect)
return null;
const candidates = this.#items.filter((item) => item !== current && item.extendedRect && !item.disabled);
if (candidates.length === 0)
return null;
// Helper function to check if two rectangles are on the same axis
const isOnSameAxis = (rect1, rect2, dir) => {
if (dir === "up" || dir === "down") {
// Same column: check if centers are aligned horizontally
if (this.toleranceCol === null) {
// If no toleranceCol set, use strict overlap detection (significant overlap required)
const overlap = Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left);
const minWidth = Math.min(rect1.width, rect2.width);
return overlap > minWidth * 0.5; // Require at least 50% overlap
}
const center1X = rect1.left + rect1.width / 2;
const center2X = rect2.left + rect2.width / 2;
return Math.abs(center1X - center2X) <= this.toleranceCol;
}
else {
// Same row: check if centers are aligned vertically
if (this.toleranceRow === null) {
// If no toleranceRow set, use strict overlap detection (significant overlap required)
const overlap = Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top);
const minHeight = Math.min(rect1.height, rect2.height);
return overlap > minHeight * 0.5; // Require at least 50% overlap
}
const center1Y = rect1.top + rect1.height / 2;
const center2Y = rect2.top + rect2.height / 2;
return Math.abs(center1Y - center2Y) <= this.toleranceRow;
}
};
// First pass: Look for items strictly on the same axis
let bestCandidate = null;
let shortest = Infinity;
for (const candidate of candidates) {
const candidateRect = candidate.extendedRect;
let isValidDirection = false;
let distance = 0;
// Check if candidate is in the correct direction
if (direction === "left") {
isValidDirection = candidateRect.right <= currentRect.left;
}
else if (direction === "right") {
isValidDirection = candidateRect.left >= currentRect.right;
}
else if (direction === "up") {
isValidDirection = candidateRect.bottom <= currentRect.top;
}
else if (direction === "down") {
isValidDirection = candidateRect.top >= currentRect.bottom;
}
if (!isValidDirection)
continue;
// Apply distance constraints if set, but only for same-axis navigation
// For cross-axis navigation, we'll handle this later in the cross-axis section
const onSameAxis = isOnSameAxis(currentRect, candidateRect, direction);
if (onSameAxis) {
if ((direction === "left" || direction === "right") && this.toleranceRow) {
const centerYDistance = Math.abs(currentRect.centerY - candidateRect.centerY);
if (centerYDistance > this.toleranceRow)
continue;
}
if ((direction === "up" || direction === "down") && this.toleranceCol) {
const centerXDistance = Math.abs(currentRect.centerX - candidateRect.centerX);
if (centerXDistance > this.toleranceCol)
continue;
}
}
// We already calculated onSameAxis above
// Calculate distance
if (direction === "left") {
distance = currentRect.left - candidateRect.right;
}
else if (direction === "right") {
distance = candidateRect.left - currentRect.right;
}
else if (direction === "up") {
distance = currentRect.top - candidateRect.bottom;
}
else if (direction === "down") {
distance = candidateRect.top - currentRect.bottom;
}
// If we haven't found a same-axis candidate yet, or this is closer on the same axis
if (onSameAxis) {
if (!bestCandidate ||
!isOnSameAxis(currentRect, bestCandidate.extendedRect, direction) ||
distance < shortest) {
shortest = distance;
bestCandidate = candidate;
}
}
else if (this.crossAxis &&
(!bestCandidate || !isOnSameAxis(currentRect, bestCandidate.extendedRect, direction))) {
// Only consider off-axis candidates if crossAxis is enabled and we haven't found any same-axis candidates
// Add penalty for being off-axis
let offAxisDistance = distance;
if (direction === "up" || direction === "down") {
// For vertical movement, add horizontal distance as penalty
const horizontalDistance = Math.min(Math.abs(currentRect.left - candidateRect.right), Math.abs(currentRect.right - candidateRect.left), Math.abs(currentRect.centerX - candidateRect.centerX));
offAxisDistance += horizontalDistance * 2;
}
else {
// For horizontal movement, add vertical distance as penalty
const verticalDistance = Math.min(Math.abs(currentRect.top - candidateRect.bottom), Math.abs(currentRect.bottom - candidateRect.top), Math.abs(currentRect.centerY - candidateRect.centerY));
offAxisDistance += verticalDistance * 2;
}
if (offAxisDistance < shortest) {
shortest = offAxisDistance;
bestCandidate = candidate;
}
}
}
if (!bestCandidate && this.wrap) {
bestCandidate = this.#findWrapAroundItem(direction);
}
return bestCandidate;
}
#findWrapAroundItem(direction) {
const current = this.#items.find((i) => i.highlighted);
if (!current?.extendedRect)
return null;
const currentRect = current.extendedRect;
const candidates = this.#items.filter((item) => item !== current && item.extendedRect && !item.disabled);
if (candidates.length === 0)
return null;
let bestCandidate = null;
let bestScore = Infinity;
for (const candidate of candidates) {
const candidateRect = candidate.extendedRect;
let score = 0;
switch (direction) {
case "up": {
if (this.toleranceCol) {
// Skip items that are too far away from the centerX of the current item
const centerXDistance = Math.abs(currentRect.centerX - candidateRect.centerX);
if (centerXDistance > this.toleranceCol && !this.crossAxis)
continue;
}
// Find the bottommost item with best horizontal alignment
score = -candidateRect.bottom; // Prioritize items at the bottom (negative for max)
const horizontalOverlap = Math.max(0, Math.min(currentRect.right, candidateRect.right) -
Math.max(currentRect.left, candidateRect.left));
if (horizontalOverlap > 0) {
score -= 10000; // Big bonus for horizontal alignment
}
else {
const horizontalDistance = Math.min(Math.abs(currentRect.left - candidateRect.left), Math.abs(currentRect.right - candidateRect.right), Math.abs(currentRect.left - candidateRect.right), Math.abs(currentRect.right - candidateRect.left));
score += horizontalDistance;
}
break;
}
case "down": {
if (this.toleranceCol) {
// Skip items that are too far away from the centerX of the current item
const centerXDistance = Math.abs(currentRect.centerX - candidateRect.centerX);
if (centerXDistance > this.toleranceCol && !this.crossAxis)
continue;
}
// Find the topmost item with best horizontal alignment
score = candidateRect.top; // Prioritize items at the top
const hOverlap = Math.max(0, Math.min(currentRect.right, candidateRect.right) -
Math.max(currentRect.left, candidateRect.left));
if (hOverlap > 0) {
score -= 10000; // Big bonus for horizontal alignment
}
else {
const hDistance = Math.min(Math.abs(currentRect.left - candidateRect.left), Math.abs(currentRect.right - candidateRect.right), Math.abs(currentRect.left - candidateRect.right), Math.abs(currentRect.right - candidateRect.left));
score += hDistance;
}
break;
}
case "left": {
if (this.toleranceRow) {
// Skip items that are too far away from the centerY of the current item
const centerYDistanceLeft = Math.abs(currentRect.centerY - candidateRect.centerY);
if (centerYDistanceLeft > this.toleranceRow && !this.crossAxis)
continue;
}
// Find the rightmost item with best vertical alignment
score = -candidateRect.right; // Prioritize items at the right (negative for max)
const verticalOverlap = Math.max(0, Math.min(currentRect.bottom, candidateRect.bottom) -
Math.max(currentRect.top, candidateRect.top));
if (verticalOverlap > 0) {
score -= 10000; // Big bonus for vertical alignment
}
else {
const verticalDistance = Math.min(Math.abs(currentRect.top - candidateRect.top), Math.abs(currentRect.bottom - candidateRect.bottom), Math.abs(currentRect.top - candidateRect.bottom), Math.abs(currentRect.bottom - candidateRect.top));
score += verticalDistance;
}
break;
}
case "right": {
if (this.toleranceRow) {
// Skip items that are too far away from the centerY of the current item
const centerYDistanceRight = Math.abs(currentRect.centerY - candidateRect.centerY);
if (centerYDistanceRight > this.toleranceRow && !this.crossAxis)
continue;
}
// Find the leftmost item with best vertical alignment
score = candidateRect.left; // Prioritize items at the left
const vOverlap = Math.max(0, Math.min(currentRect.bottom, candidateRect.bottom) -
Math.max(currentRect.top, candidateRect.top));
if (vOverlap > 0) {
score -= 10000; // Big bonus for vertical alignment
}
else {
const vDistance = Math.min(Math.abs(currentRect.top - candidateRect.top), Math.abs(currentRect.bottom - candidateRect.bottom), Math.abs(currentRect.top - candidateRect.bottom), Math.abs(currentRect.bottom - candidateRect.top));
score += vDistance;
}
break;
}
}
if (score < bestScore) {
bestScore = score;
bestCandidate = candidate;
}
}
return bestCandidate;
}
#onKeydown = (e) => {
const arrowKeys = [kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.ARROW_LEFT, kbd.ARROW_RIGHT];
if (arrowKeys.includes(e.key)) {
e.preventDefault();
e.stopPropagation();
this.selectionMode = "keyboard";
const current = this.#items.find((i) => i.highlighted);
if (!current) {
const firstEnabledItem = this.#items.find((item) => !item.disabled);
this.highlighted = firstEnabledItem?.value ?? null;
return;
}
let nextItem = null;
switch (e.key) {
case kbd.ARROW_UP:
nextItem = this.#findClosestItem("up");
break;
case kbd.ARROW_DOWN:
nextItem = this.#findClosestItem("down");
break;
case kbd.ARROW_LEFT:
nextItem = this.#findClosestItem("left");
break;
case kbd.ARROW_RIGHT:
nextItem = this.#findClosestItem("right");
break;
}
if (nextItem) {
this.highlighted = nextItem.value;
if (this.scrollBehavior !== null) {
nextItem.el?.scrollIntoView({ block: "nearest", behavior: this.scrollBehavior });
}
}
// If nextItem is null, do nothing - this maintains current highlighted state
}
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
const current = this.#items.find((i) => i.highlighted);
if (current && !current.disabled) {
current.onSelect?.();
}
}
};
/** The root element. */
get root() {
return {
[dataAttrs.root]: "",
// TODO: check if this is okay in a11y context
tabindex: 0,
onkeydown: this.#onKeydown,
[createAttachmentKey()]: (node) => {
this.#elMap.root = node;
const off = on(document.body, "mousemove", () => {
this.selectionMode = "mouse";
});
return () => {
delete this.#elMap.root;
off();
};
},
};
}
get input() {
return {
[dataAttrs.input]: "",
onkeydown: this.#onKeydown,
[createAttachmentKey()]: (node) => {
this.#elMap.input = node;
return () => {
delete this.#elMap.input;
};
},
};
}
getItem(value, options) {
const item = new SpatialMenuItem({
value,
onSelect: options?.onSelect,
disabled: options?.disabled,
parent: this,
lifecycle: {
onMount: () => {
this.#items.push(item);
},
onUnmount: () => {
this.#items = this.#items.filter((i) => i !== item);
if (item.highlighted)
this.highlighted = this.#items[0]?.value ?? null;
},
},
});
return item;
}
}
class SpatialMenuItem {
#props;
value = $derived(this.#props.value);
disabled = $derived(this.#props.disabled ?? false);
el = null;
parent = $derived(this.#props.parent);
highlighted = $derived(dequal(this.parent.highlighted, this.#props.value));
constructor(props) {
this.#props = props;
}
#attachment = {
[createAttachmentKey()]: (node) => {
this.el = node;
this.#props.lifecycle.onMount();
return () => {
this.el = null;
this.#props.lifecycle.onUnmount();
};
},
};
attrs = $derived.by(() => {
return {
[dataAttrs.item]: "",
"data-highlighted": dataAttr(this.highlighted),
"data-disabled": dataAttr(this.disabled),
onmousemove: () => {
if (this.parent.selectionMode !== "mouse" || this.disabled)
return;
this.parent.highlighted = this.#props.value;
},
onclick: () => {
if (this.disabled)
return;
this.onSelect();
},
...this.#attachment,
};
});
get rect() {
return this.el?.getBoundingClientRect();
}
// Same as rect, but with extra useful properties
get extendedRect() {
if (!this.rect)
return;
const rect = this.rect;
return Object.assign(rect, {
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
});
}
onSelect() {
if (this.disabled)
return;
this.#props.onSelect?.();
this.#props.parent.onSelect?.(this.#props.value);
}
}