vue-sticky-directive-ts
Version:
A powerful vue directive make element sticky.
277 lines (276 loc) • 8.7 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
import { nextTick } from "vue";
const StickyNamespace = "@@vue-sticky-directive";
const events = [
"resize",
"scroll",
"touchstart",
"touchmove",
"touchend",
"pageshow",
"load"
];
const stylePropWithPixel = [
"width",
"top",
"left",
"right",
"bottom",
"paddingTop"
];
const DefaultOptions = {
topOffset: 0,
bottomOffset: 0,
shouldTopSticky: false,
shouldBottomSticky: false,
zIndex: 10,
on: () => {
}
};
function convertProp(name, prop) {
if (typeof prop === "string") {
return prop;
}
prop = prop.toString(10);
return stylePropWithPixel.includes(name) ? `${prop}px` : prop;
}
function batchStyle(el, style = {}, className = {}) {
if (!el) {
return;
}
for (const k in style) {
el.style.setProperty(k, convertProp(k, style[k]));
}
for (const k in className) {
if (className[k] && !el.classList.contains(k)) {
el.classList.add(k);
} else if (!className[k] && el.classList.contains(k)) {
el.classList.remove(k);
}
}
}
class Sticky {
constructor(el, options = {}) {
__publicField(this, "el");
__publicField(this, "containerEl");
__publicField(this, "placeholderEl");
__publicField(this, "listeners", []);
__publicField(this, "isPending", false);
__publicField(this, "state");
__publicField(this, "callbackState");
__publicField(this, "options", DefaultOptions);
this.el = el;
this.state = {
isTopSticky: false,
isBottomSticky: false,
height: 0,
width: 0,
xOffset: 0,
placeholderElTop: 0,
containerElTop: 0,
containerElBottom: 0
};
this.callbackState = {
top: false,
bottom: false,
sticked: false
};
this.setOptions(options);
this.containerEl = this.getContainerEl();
const parent = this.el.parentNode || this.containerEl;
this.placeholderEl = document.createElement("div");
parent.insertBefore(this.placeholderEl, this.el);
this.setListeners().then(() => {
}, () => {
});
}
setOptions(options = {}) {
const side = options.side && ["top", "bottom", "both"].includes(options.side) ? options.side : "top";
this.options = {
topOffset: Number(options.topOffset) || DefaultOptions.topOffset,
bottomOffset: Number(options.bottomOffset) || DefaultOptions.bottomOffset,
shouldTopSticky: side === "top" || side === "both",
shouldBottomSticky: side === "bottom" || side === "both",
zIndex: Number(options.zIndex) || DefaultOptions.zIndex,
on: options.on || DefaultOptions.on
};
}
async setListeners() {
if (this.listeners.length > 0) {
return;
}
const fn = this.update.bind(this);
await nextTick(() => {
const containers = [window, this.containerEl];
events.forEach((event) => {
containers.forEach((container) => {
this.listeners.push(() => container == null ? void 0 : container.removeEventListener(event, fn));
container == null ? void 0 : container.addEventListener(event, fn, { passive: true });
});
});
});
}
doUnbind() {
this.listeners.forEach((fn) => fn());
this.listeners = [];
this.resetElement();
}
updateOptions(options = {}) {
this.setOptions(options);
this.recomputeState();
this.updateElements();
}
update() {
if (!this.isPending) {
requestAnimationFrame(() => {
this.isPending = false;
this.recomputeState();
this.updateElements();
});
this.isPending = true;
}
}
isTopSticky() {
if (!this.options.shouldTopSticky) {
return false;
}
const fromTop = this.state.placeholderElTop;
const fromBottom = this.state.containerElBottom;
const topBreakpoint = this.options.topOffset;
const bottomBreakpoint = this.options.bottomOffset;
return fromTop <= topBreakpoint && fromBottom >= bottomBreakpoint;
}
isBottomSticky() {
if (!this.options.shouldBottomSticky) {
return false;
}
const fromBottom = window.innerHeight - this.state.placeholderElTop - this.state.height;
const fromTop = window.innerHeight - this.state.containerElTop;
const topBreakpoint = this.options.topOffset;
const bottomBreakpoint = this.options.bottomOffset;
return fromBottom <= bottomBreakpoint && fromTop >= topBreakpoint;
}
recomputeState() {
const elRect = this.el.getBoundingClientRect();
const placeholderElRect = this.placeholderEl.getBoundingClientRect();
const containerElRect = this.containerEl.getBoundingClientRect();
this.state = Object.assign({}, this.state, {
height: elRect.height,
width: placeholderElRect.width,
xOffset: placeholderElRect.left,
placeholderElTop: placeholderElRect.top,
containerElTop: containerElRect.top,
containerElBottom: containerElRect.bottom
});
this.state.isTopSticky = this.isTopSticky();
this.state.isBottomSticky = this.isBottomSticky();
}
fireEvents() {
if (typeof this.options.on === "function" && (this.callbackState.top !== this.state.isTopSticky || this.callbackState.bottom !== this.state.isBottomSticky || this.callbackState.sticked !== (this.state.isTopSticky || this.state.isBottomSticky))) {
this.callbackState = {
top: this.state.isTopSticky,
bottom: this.state.isBottomSticky,
sticked: this.state.isBottomSticky || this.state.isTopSticky
};
this.options.on();
}
}
updateElements() {
const placeholderStyle = { paddingTop: 0 };
const elStyle = {
position: "static",
top: "auto",
bottom: "auto",
left: "auto",
width: "auto",
zIndex: this.options.zIndex
};
const placeholderClassName = { "vue-sticky-placeholder": true };
const elClassName = {
"vue-sticky-el": true,
"top-sticky": false,
"bottom-sticky": false
};
const limit = this.state.height + this.options.bottomOffset + this.options.topOffset;
if (this.state.isTopSticky || this.state.isBottomSticky) {
elStyle.position = "fixed";
elStyle.left = this.state.xOffset;
elStyle.width = this.state.width;
placeholderStyle.paddingTop = this.state.height;
} else {
placeholderStyle.paddingTop = 0;
}
if (this.state.isTopSticky) {
elClassName["top-sticky"] = true;
elStyle.top = this.options.topOffset;
const bottomLimit = this.state.containerElBottom - limit;
if (bottomLimit < 0) {
elStyle.top += bottomLimit;
}
}
if (this.state.isBottomSticky) {
elClassName["bottom-sticky"] = true;
elStyle.bottom = this.options.bottomOffset;
const topLimit = window.innerHeight - this.state.containerElTop - limit;
if (topLimit < 0) {
elStyle.bottom += topLimit;
}
}
batchStyle(this.el, elStyle, elClassName);
batchStyle(this.placeholderEl, placeholderStyle, placeholderClassName);
this.fireEvents();
}
resetElement() {
var _a, _b;
["position", "top", "bottom", "left", "width", "zIndex"].forEach((attr) => {
this.el.style.removeProperty(attr);
});
this.el.classList.remove("bottom-sticky", "top-sticky");
(_b = (_a = this.placeholderEl) == null ? void 0 : _a.parentNode) == null ? void 0 : _b.removeChild(this.placeholderEl);
}
getContainerEl() {
var _a;
let node = this.el.parentElement;
while (node && node.tagName !== "HTML" && node.tagName !== "BODY" && node.nodeType === 1) {
if (node.hasAttribute("sticky-container")) {
return node;
}
node = node.parentElement;
}
return (_a = this.el.parentElement) != null ? _a : document.body;
}
}
const directiveHooks = {
mounted: "mounted",
updated: "updated",
beforeUnmount: "beforeUnmount"
};
const directive = {
[directiveHooks.mounted](el, bind) {
if (bind.value === void 0 || bind.value) {
el[StickyNamespace] = new Sticky(el, bind.value);
}
},
[directiveHooks.updated](el, bind) {
if (el[StickyNamespace]) {
el[StickyNamespace].updateOptions(bind.value);
}
},
[directiveHooks.beforeUnmount](el) {
if (el[StickyNamespace]) {
el[StickyNamespace].doUnbind();
el[StickyNamespace] = void 0;
}
}
};
const plugin = {
install: (app) => {
app.directive("Sticky", directive);
}
};
export { Sticky, plugin as default };