Current section

Files

Jump to
phoenix_live_view assets js phoenix_live_view js.js
Raw

assets/js/phoenix_live_view/js.js

import DOM from "./dom";
import ARIA from "./aria";
const focusStack = [];
const default_transition_time = 200;
const JS = {
// private
exec(e, eventType, phxEvent, view, sourceEl, defaults) {
const [defaultKind, defaultArgs] = defaults || [
null,
{ callback: defaults && defaults.callback },
];
const commands =
phxEvent.charAt(0) === "["
? JSON.parse(phxEvent)
: [[defaultKind, defaultArgs]];
commands.forEach(([kind, args]) => {
if (kind === defaultKind) {
// always prefer the args, but keep existing keys from the defaultArgs
args = { ...defaultArgs, ...args };
args.callback = args.callback || defaultArgs.callback;
}
this.filterToEls(view.liveSocket, sourceEl, args).forEach((el) => {
this[`exec_${kind}`](e, eventType, phxEvent, view, sourceEl, el, args);
});
});
},
isVisible(el) {
return !!(
el.offsetWidth ||
el.offsetHeight ||
el.getClientRects().length > 0
);
},
// returns true if any part of the element is inside the viewport
isInViewport(el) {
const rect = el.getBoundingClientRect();
const windowHeight =
window.innerHeight || document.documentElement.clientHeight;
const windowWidth =
window.innerWidth || document.documentElement.clientWidth;
return (
rect.right > 0 &&
rect.bottom > 0 &&
rect.left < windowWidth &&
rect.top < windowHeight
);
},
// private
// commands
exec_exec(e, eventType, phxEvent, view, sourceEl, el, { attr, to }) {
const encodedJS = el.getAttribute(attr);
if (!encodedJS) {
throw new Error(`expected ${attr} to contain JS command on "${to}"`);
}
view.liveSocket.execJS(el, encodedJS, eventType);
},
exec_dispatch(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ event, detail, bubbles, blocking },
) {
detail = detail || {};
detail.dispatcher = sourceEl;
if (blocking) {
const promise = new Promise((resolve, _reject) => {
detail.done = resolve;
});
view.liveSocket.asyncTransition(promise);
}
DOM.dispatchEvent(el, event, { detail, bubbles });
},
exec_push(e, eventType, phxEvent, view, sourceEl, el, args) {
const {
event,
data,
target,
page_loading,
loading,
value,
dispatcher,
callback,
} = args;
const pushOpts = {
loading,
value,
target,
page_loading: !!page_loading,
originalEvent: e,
};
const targetSrc =
eventType === "change" && dispatcher ? dispatcher : sourceEl;
const phxTarget =
target || targetSrc.getAttribute(view.binding("target")) || targetSrc;
const handler = (targetView, targetCtx) => {
if (!targetView.isConnected()) {
return;
}
if (eventType === "change") {
let { newCid, _target } = args;
_target =
_target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined);
if (_target) {
pushOpts._target = _target;
}
targetView.pushInput(
sourceEl,
targetCtx,
newCid,
event || phxEvent,
pushOpts,
callback,
);
} else if (eventType === "submit") {
const { submitter } = args;
targetView.submitForm(
sourceEl,
targetCtx,
event || phxEvent,
submitter,
pushOpts,
callback,
);
} else {
targetView.pushEvent(
eventType,
sourceEl,
targetCtx,
event || phxEvent,
data,
pushOpts,
callback,
);
}
};
// in case of formRecovery, targetView and targetCtx are passed as argument
// as they are looked up in a template element, not the real DOM
if (args.targetView && args.targetCtx) {
handler(args.targetView, args.targetCtx);
} else {
view.withinTargets(phxTarget, handler);
}
},
exec_navigate(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) {
view.liveSocket.historyRedirect(
e,
href,
replace ? "replace" : "push",
null,
sourceEl,
);
},
exec_patch(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) {
view.liveSocket.pushHistoryPatch(
e,
href,
replace ? "replace" : "push",
sourceEl,
);
},
exec_focus(e, eventType, phxEvent, view, sourceEl, el) {
ARIA.attemptFocus(el);
// in case the JS.focus command is in a JS.show/hide/toggle chain, for show we need
// to wait for JS.show to have updated the element's display property (see exec_toggle)
// but that run in nested animation frames, therefore we need to use them here as well
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => ARIA.attemptFocus(el));
});
},
exec_focus_first(e, eventType, phxEvent, view, sourceEl, el) {
ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el);
// if you wonder about the nested animation frames, see exec_focus
window.requestAnimationFrame(() => {
window.requestAnimationFrame(
() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el),
);
});
},
exec_push_focus(e, eventType, phxEvent, view, sourceEl, el) {
focusStack.push(el || sourceEl);
},
exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el) {
const el = focusStack.pop();
if (el) {
el.focus();
// if you wonder about the nested animation frames, see exec_focus
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => el.focus());
});
}
},
exec_add_class(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ names, transition, time, blocking },
) {
this.addOrRemoveClasses(el, names, [], transition, time, view, blocking);
},
exec_remove_class(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ names, transition, time, blocking },
) {
this.addOrRemoveClasses(el, [], names, transition, time, view, blocking);
},
exec_toggle_class(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ names, transition, time, blocking },
) {
this.toggleClasses(el, names, transition, time, view, blocking);
},
exec_toggle_attr(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ attr: [attr, val1, val2] },
) {
this.toggleAttr(el, attr, val1, val2);
},
exec_ignore_attrs(e, eventType, phxEvent, view, sourceEl, el, { attrs }) {
this.ignoreAttrs(el, attrs);
},
exec_transition(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ time, transition, blocking },
) {
this.addOrRemoveClasses(el, [], [], transition, time, view, blocking);
},
exec_toggle(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ display, ins, outs, time, blocking },
) {
this.toggle(eventType, view, el, display, ins, outs, time, blocking);
},
exec_show(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ display, transition, time, blocking },
) {
this.show(eventType, view, el, display, transition, time, blocking);
},
exec_hide(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ display, transition, time, blocking },
) {
this.hide(eventType, view, el, display, transition, time, blocking);
},
exec_set_attr(
e,
eventType,
phxEvent,
view,
sourceEl,
el,
{ attr: [attr, val] },
) {
this.setOrRemoveAttrs(el, [[attr, val]], []);
},
exec_remove_attr(e, eventType, phxEvent, view, sourceEl, el, { attr }) {
this.setOrRemoveAttrs(el, [], [attr]);
},
ignoreAttrs(el, attrs) {
DOM.putPrivate(el, "JS:ignore_attrs", {
apply: (fromEl, toEl) => {
Array.from(fromEl.attributes).forEach((attr) => {
if (
attrs.some(
(toIgnore) =>
attr.name == toIgnore ||
toIgnore === "*" ||
(toIgnore.includes("*") && attr.name.match(toIgnore) != null),
)
) {
toEl.setAttribute(attr.name, attr.value);
}
});
},
});
},
onBeforeElUpdated(fromEl, toEl) {
const ignoreAttrs = DOM.private(fromEl, "JS:ignore_attrs");
if (ignoreAttrs) {
ignoreAttrs.apply(fromEl, toEl);
}
},
// utils for commands
show(eventType, view, el, display, transition, time, blocking) {
if (!this.isVisible(el)) {
this.toggle(
eventType,
view,
el,
display,
transition,
null,
time,
blocking,
);
}
},
hide(eventType, view, el, display, transition, time, blocking) {
if (this.isVisible(el)) {
this.toggle(
eventType,
view,
el,
display,
null,
transition,
time,
blocking,
);
}
},
toggle(eventType, view, el, display, ins, outs, time, blocking) {
time = time || default_transition_time;
const [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []];
const [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []];
if (inClasses.length > 0 || outClasses.length > 0) {
if (this.isVisible(el)) {
const onStart = () => {
this.addOrRemoveClasses(
el,
outStartClasses,
inClasses.concat(inStartClasses).concat(inEndClasses),
);
window.requestAnimationFrame(() => {
this.addOrRemoveClasses(el, outClasses, []);
window.requestAnimationFrame(() =>
this.addOrRemoveClasses(el, outEndClasses, outStartClasses),
);
});
};
const onEnd = () => {
this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses));
DOM.putSticky(
el,
"toggle",
(currentEl) => (currentEl.style.display = "none"),
);
el.dispatchEvent(new Event("phx:hide-end"));
};
el.dispatchEvent(new Event("phx:hide-start"));
if (blocking === false) {
onStart();
setTimeout(onEnd, time);
} else {
view.transition(time, onStart, onEnd);
}
} else {
if (eventType === "remove") {
return;
}
const onStart = () => {
this.addOrRemoveClasses(
el,
inStartClasses,
outClasses.concat(outStartClasses).concat(outEndClasses),
);
const stickyDisplay = display || this.defaultDisplay(el);
window.requestAnimationFrame(() => {
// first add the starting + active class, THEN make the element visible
// otherwise if we toggled the visibility earlier css animations
// would flicker, as the element becomes visible before the active animation
// class is set (see https://github.com/phoenixframework/phoenix_live_view/issues/3456)
this.addOrRemoveClasses(el, inClasses, []);
// addOrRemoveClasses uses a requestAnimationFrame itself, therefore we need to move the putSticky
// into the next requestAnimationFrame...
window.requestAnimationFrame(() => {
DOM.putSticky(
el,
"toggle",
(currentEl) => (currentEl.style.display = stickyDisplay),
);
this.addOrRemoveClasses(el, inEndClasses, inStartClasses);
});
});
};
const onEnd = () => {
this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses));
el.dispatchEvent(new Event("phx:show-end"));
};
el.dispatchEvent(new Event("phx:show-start"));
if (blocking === false) {
onStart();
setTimeout(onEnd, time);
} else {
view.transition(time, onStart, onEnd);
}
}
} else {
if (this.isVisible(el)) {
window.requestAnimationFrame(() => {
el.dispatchEvent(new Event("phx:hide-start"));
DOM.putSticky(
el,
"toggle",
(currentEl) => (currentEl.style.display = "none"),
);
el.dispatchEvent(new Event("phx:hide-end"));
});
} else {
window.requestAnimationFrame(() => {
el.dispatchEvent(new Event("phx:show-start"));
const stickyDisplay = display || this.defaultDisplay(el);
DOM.putSticky(
el,
"toggle",
(currentEl) => (currentEl.style.display = stickyDisplay),
);
el.dispatchEvent(new Event("phx:show-end"));
});
}
}
},
toggleClasses(el, classes, transition, time, view, blocking) {
window.requestAnimationFrame(() => {
const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]);
const newAdds = classes.filter(
(name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name),
);
const newRemoves = classes.filter(
(name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name),
);
this.addOrRemoveClasses(
el,
newAdds,
newRemoves,
transition,
time,
view,
blocking,
);
});
},
toggleAttr(el, attr, val1, val2) {
if (el.hasAttribute(attr)) {
if (val2 !== undefined) {
// toggle between val1 and val2
if (el.getAttribute(attr) === val1) {
this.setOrRemoveAttrs(el, [[attr, val2]], []);
} else {
this.setOrRemoveAttrs(el, [[attr, val1]], []);
}
} else {
// remove attr
this.setOrRemoveAttrs(el, [], [attr]);
}
} else {
this.setOrRemoveAttrs(el, [[attr, val1]], []);
}
},
addOrRemoveClasses(el, adds, removes, transition, time, view, blocking) {
time = time || default_transition_time;
const [transitionRun, transitionStart, transitionEnd] = transition || [
[],
[],
[],
];
if (transitionRun.length > 0) {
const onStart = () => {
this.addOrRemoveClasses(
el,
transitionStart,
[].concat(transitionRun).concat(transitionEnd),
);
window.requestAnimationFrame(() => {
this.addOrRemoveClasses(el, transitionRun, []);
window.requestAnimationFrame(() =>
this.addOrRemoveClasses(el, transitionEnd, transitionStart),
);
});
};
const onDone = () =>
this.addOrRemoveClasses(
el,
adds.concat(transitionEnd),
removes.concat(transitionRun).concat(transitionStart),
);
if (blocking === false) {
onStart();
setTimeout(onDone, time);
} else {
view.transition(time, onStart, onDone);
}
return;
}
window.requestAnimationFrame(() => {
const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]);
const keepAdds = adds.filter(
(name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name),
);
const keepRemoves = removes.filter(
(name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name),
);
const newAdds = prevAdds
.filter((name) => removes.indexOf(name) < 0)
.concat(keepAdds);
const newRemoves = prevRemoves
.filter((name) => adds.indexOf(name) < 0)
.concat(keepRemoves);
DOM.putSticky(el, "classes", (currentEl) => {
currentEl.classList.remove(...newRemoves);
currentEl.classList.add(...newAdds);
return [newAdds, newRemoves];
});
});
},
setOrRemoveAttrs(el, sets, removes) {
const [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []]);
const alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes);
const newSets = prevSets
.filter(([attr, _val]) => !alteredAttrs.includes(attr))
.concat(sets);
const newRemoves = prevRemoves
.filter((attr) => !alteredAttrs.includes(attr))
.concat(removes);
DOM.putSticky(el, "attrs", (currentEl) => {
newRemoves.forEach((attr) => currentEl.removeAttribute(attr));
newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val));
return [newSets, newRemoves];
});
},
hasAllClasses(el, classes) {
return classes.every((name) => el.classList.contains(name));
},
isToggledOut(el, outClasses) {
return !this.isVisible(el) || this.hasAllClasses(el, outClasses);
},
filterToEls(liveSocket, sourceEl, { to }) {
const defaultQuery = () => {
if (typeof to === "string") {
return document.querySelectorAll(to);
} else if (to.closest) {
const toEl = sourceEl.closest(to.closest);
return toEl ? [toEl] : [];
} else if (to.inner) {
return sourceEl.querySelectorAll(to.inner);
}
};
return to
? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery)
: [sourceEl];
},
defaultDisplay(el) {
return (
{ tr: "table-row", td: "table-cell" }[el.tagName.toLowerCase()] || "block"
);
},
transitionClasses(val) {
if (!val) {
return null;
}
let [trans, tStart, tEnd] = Array.isArray(val)
? val
: [val.split(" "), [], []];
trans = Array.isArray(trans) ? trans : trans.split(" ");
tStart = Array.isArray(tStart) ? tStart : tStart.split(" ");
tEnd = Array.isArray(tEnd) ? tEnd : tEnd.split(" ");
return [trans, tStart, tEnd];
},
};
export default JS;