bits-ui
Version:
The headless components for Svelte.
237 lines (236 loc) • 8.01 kB
JavaScript
import { afterSleep, onDestroyEffect, attachRef, DOMContext, box, } from "svelte-toolbelt";
import { Context, watch } from "runed";
import { on } from "svelte/events";
import { createBitsAttrs, getAriaExpanded, getDataOpenClosed } from "../../internal/attrs.js";
import { isElement, isFocusVisible, isTouch } from "../../internal/is.js";
import { getTabbableCandidates } from "../../internal/focus.js";
import { GraceArea } from "../../internal/grace-area.svelte.js";
import { OpenChangeComplete } from "../../internal/open-change-complete.js";
const linkPreviewAttrs = createBitsAttrs({
component: "link-preview",
parts: ["content", "trigger"],
});
const LinkPreviewRootContext = new Context("LinkPreview.Root");
export class LinkPreviewRootState {
static create(opts) {
return LinkPreviewRootContext.set(new LinkPreviewRootState(opts));
}
opts;
hasSelection = $state(false);
isPointerDownOnContent = $state(false);
containsSelection = $state(false);
timeout = null;
contentNode = $state(null);
contentMounted = $state(false);
triggerNode = $state(null);
isOpening = false;
domContext = new DOMContext(() => null);
constructor(opts) {
this.opts = opts;
new OpenChangeComplete({
ref: box.with(() => this.contentNode),
open: this.opts.open,
onComplete: () => {
this.opts.onOpenChangeComplete.current(this.opts.open.current);
},
});
watch(() => this.opts.open.current, (isOpen) => {
if (!isOpen) {
this.hasSelection = false;
return;
}
if (!this.domContext)
return;
const handlePointerUp = () => {
this.containsSelection = false;
this.isPointerDownOnContent = false;
afterSleep(1, () => {
const isSelection = this.domContext.getDocument().getSelection()?.toString() !== "";
if (isSelection) {
this.hasSelection = true;
}
else {
this.hasSelection = false;
}
});
};
const unsubListener = on(this.domContext.getDocument(), "pointerup", handlePointerUp);
if (!this.contentNode)
return;
const tabCandidates = getTabbableCandidates(this.contentNode);
for (const candidate of tabCandidates) {
candidate.setAttribute("tabindex", "-1");
}
return () => {
unsubListener();
this.hasSelection = false;
this.isPointerDownOnContent = false;
};
});
}
clearTimeout() {
if (this.timeout) {
this.domContext.clearTimeout(this.timeout);
this.timeout = null;
}
}
handleOpen() {
this.clearTimeout();
if (this.opts.open.current)
return;
this.isOpening = true;
this.timeout = this.domContext.setTimeout(() => {
if (this.isOpening) {
this.opts.open.current = true;
this.isOpening = false;
}
}, this.opts.openDelay.current);
}
immediateClose() {
this.clearTimeout();
this.isOpening = false;
this.opts.open.current = false;
}
handleClose() {
this.isOpening = false;
this.clearTimeout();
if (!this.isPointerDownOnContent && !this.hasSelection) {
this.timeout = this.domContext.setTimeout(() => {
this.opts.open.current = false;
}, this.opts.closeDelay.current);
}
}
}
export class LinkPreviewTriggerState {
static create(opts) {
return new LinkPreviewTriggerState(opts, LinkPreviewRootContext.get());
}
opts;
root;
attachment;
constructor(opts, root) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(this.opts.ref, (v) => (this.root.triggerNode = v));
this.root.domContext = new DOMContext(opts.ref);
this.onpointerenter = this.onpointerenter.bind(this);
this.onpointerleave = this.onpointerleave.bind(this);
this.onfocus = this.onfocus.bind(this);
this.onblur = this.onblur.bind(this);
}
onpointerenter(e) {
if (isTouch(e))
return;
this.root.handleOpen();
}
onpointerleave(e) {
if (isTouch(e))
return;
if (!this.root.contentMounted || !this.root.opts.open.current) {
this.root.immediateClose();
}
}
onfocus(e) {
if (!isFocusVisible(e.currentTarget))
return;
this.root.handleOpen();
}
onblur(_) {
this.root.handleClose();
}
props = $derived.by(() => ({
id: this.opts.id.current,
"aria-haspopup": "dialog",
"aria-expanded": getAriaExpanded(this.root.opts.open.current),
"data-state": getDataOpenClosed(this.root.opts.open.current),
"aria-controls": this.root.contentNode?.id,
role: "button",
[linkPreviewAttrs.trigger]: "",
onpointerenter: this.onpointerenter,
onfocus: this.onfocus,
onblur: this.onblur,
onpointerleave: this.onpointerleave,
...this.attachment,
}));
}
export class LinkPreviewContentState {
static create(opts) {
return new LinkPreviewContentState(opts, LinkPreviewRootContext.get());
}
opts;
root;
attachment;
constructor(opts, root) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v));
this.root.domContext = new DOMContext(opts.ref);
this.onpointerdown = this.onpointerdown.bind(this);
this.onpointerenter = this.onpointerenter.bind(this);
this.onfocusout = this.onfocusout.bind(this);
new GraceArea({
triggerNode: () => this.root.triggerNode,
contentNode: () => this.opts.ref.current,
enabled: () => this.root.opts.open.current,
onPointerExit: () => {
this.root.handleClose();
},
});
onDestroyEffect(() => {
this.root.clearTimeout();
});
}
onpointerdown(e) {
const target = e.target;
if (!isElement(target))
return;
if (e.currentTarget.contains(target)) {
this.root.containsSelection = true;
}
this.root.hasSelection = true;
this.root.isPointerDownOnContent = true;
}
onpointerenter(e) {
if (isTouch(e))
return;
this.root.handleOpen();
}
onfocusout(e) {
e.preventDefault();
}
onInteractOutside = (e) => {
this.opts.onInteractOutside.current(e);
if (e.defaultPrevented)
return;
this.root.handleClose();
};
onEscapeKeydown = (e) => {
this.opts.onEscapeKeydown.current?.(e);
if (e.defaultPrevented)
return;
this.root.handleClose();
};
onOpenAutoFocus = (e) => {
e.preventDefault();
};
onCloseAutoFocus = (e) => {
e.preventDefault();
};
snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
props = $derived.by(() => ({
id: this.opts.id.current,
tabindex: -1,
"data-state": getDataOpenClosed(this.root.opts.open.current),
[linkPreviewAttrs.content]: "",
onpointerdown: this.onpointerdown,
onpointerenter: this.onpointerenter,
onfocusout: this.onfocusout,
...this.attachment,
}));
popperProps = {
onInteractOutside: this.onInteractOutside,
onEscapeKeydown: this.onEscapeKeydown,
onOpenAutoFocus: this.onOpenAutoFocus,
onCloseAutoFocus: this.onCloseAutoFocus,
};
}