UNPKG

bits-ui

Version:

The headless components for Svelte.

237 lines (236 loc) 8.01 kB
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, }; }