Skip to content

Instantly share code, notes, and snippets.

@Yarith
Last active April 28, 2022 23:50
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Yarith/bcd7b715cff302fdf4512f538b769521 to your computer and use it in GitHub Desktop.
Save Yarith/bcd7b715cff302fdf4512f538b769521 to your computer and use it in GitHub Desktop.
Elm: Animate the removal of DOM elements with CustomElement and MutationObserver

Elm: Animate the removal of DOM elements with CustomElement and MutationObserver

Desired result

You can see the end result in this ellie. It displays a smooth removal of the clicked items. You can also remove some items from the beginning or the end.

Version 1

https://ellie-app.com/cvCV93KgD56a1

Version 2

An updated ellie with improved performance. This gist was not yet updated with all changes made in this version. Only the article text is somewhat updated. https://ellie-app.com/fBtqCVCjh7qa1

Use cases

These are my use cases which comes to my mind:

  1. Fadeout animation of modal dialogs
  2. Fadeout animation of toasts
  3. Smooth removal of items in a list
  4. Page transitions

Description

If you are removing an entry from a list, then the item is just gone in the view. Design guidelines require a smooth removal of entries via an animation. Elm gives no simple way to configure how the DOM node should be removed. This decision keeps the virtual dom simple and fast, but we end up with jumping elements.

The common approach is to handle the animation by giving each item a "removing" state. After the animation completes, an event generates a message, which causes the removal of the element. Still after so much work we may end up with a small jump, because we forgot to animate the spacing of the element. Which is in case of elm-ui not so easy to achieve. Is this somehow possible with mdgriffith/elm-animator/Animator.Css?

This solution uses a WebComponent custom element. It is added as the first element in the item container. The custom element is called animate-sibling-removal-behavior. It registers a MutationObserver on its parent and reacts on the removal of child elements. From the viewpoint of the behavior element these elements are its siblings. Each item must be wrapped by another element, because a removed element must be added to the next. This keeps the removed element still visible. Now it is possible to apply an animation on this element. After the animation finishes we are removing it from the dom. Also because we are removing it from inside the container, our MutationObserver is not triggered again. For the removal of the last element we have added at the end an empty element.

In the provied elm file everything is already connected. You can also play around in the ellie, see the link at the beginning of this article.

Assumptions & Requirements

This project focuses only on the animation of removed elm-ui elements. It should be simple to apply them also on Html msg elements by changing the elm code accordingly. Keep in mind that the styles may break if you are not careful. For this reason i have used Element.layoutWith in every item.

To make our custom element work, we must define our assumptions about the virtual dom, so we implement it correctly.

Points marked with ⬜ are currently not used in the implementation. As you can see everything is implemented in the custom element. 😊

  • On each node we can append additional elements without confusing the virtual dom. The virtual dom will ignore these elements.
  • We are safe, if we are not removing and re-adding an element to a container, which we have already manipulated.
  • Moving the elements will not change their appearance. If you remove an elm-ui element from the dom, it may alter the styles. The api must use Element.layoutWith so that there is a new <style> block inside the elements.
  • We are not trying to animate wrapped elements. There is not even a Element.Keyed.wrappedRow which we can use. I think if an element moves in the row on top, it should slide out to the left and slide in from the right. A nice opacity mask could prevent sudden appearance in the other layer. But this feature is moved to another project, maybe this project can offer a good fundament.
  • A missing keyed element will always cause the removal of the DOM element. While a new keyed element will always cause a new DOM element. Only if the key is already present, it will just update the contents.
  • If the virtual dom is inserting multiple elements it keeps the order by using Node.appendChild() or ChildNode.after().
  • When the virtual dom is removing all elements and put some back, this must happen in the same frame, so it is in the same MutationObserver event. Otherwise we need to keep the ElementTransferMap inside our custom element, which can increase the likelyhood of issues.
  • The virtual dom is never removing our behavior custom element at index 0, the merged style element at index 1 and the first-removal-containers element at index 2, because all changes start at index 3.
  • It is okay that an element can be rendered twice. One which is animating away and the new one added. In case of modals/toasts this is even desired behavior.
  • The <style> blocks created by elm-ui uses a selectorText which reflects its cssText. I can disable all <style> blocks in every item and merge them in an own <style> element in my container <div>. I could even look if there is already some <style> somewhere else created by elm-ui. I need to put an Mutation Observer on every <style> block in case that it gets updated. The change of the textNode creates a new CSSSheet instance, which must be disabled again.
  • When the DOM element for the elm-ui item is generated the <style> block must be present. I mean the <style> block should not be added after we processed the initial elements and freshly added items. This assumption is met by the delayed call of the MutationObserver event. Didn't looked if the elm virtual dom implementation is adding the item as the last step, but this is not needed anyway.
  • The layout root and its style elements are only removed together with the custom element. We do need to observe the styles of the layout root, so we can reduce the amount of css rule overrides.
  • onanimationend is a bubbling event, which means if we have an animation inside our item, it may cause premature removal before the true removal animations ends. This will be protected against by checking the target of the event data.

DOM Structure

The structure is as followed:

  • Items container
    • animate-sibling-removal-behavior
    • div.merged-style-container The content of item <style> block will be merged into a style in this container.
    • first-removal-containers element, to allow the animation of the last element Contains all removed elements which were removed from the front. Moving it to the beginning simplified the new stylesheet animations.
    • One or more keyed item containers
      • The item content root element
        • On elm-ui here is a div
          • elm-ui creates here a <style> block This style block will be disabled and observed for changes
        • The user defined item content
      • Here may be some additional item content root elements, which are being animated

Merging item styles

The elm-ui package places a new <style> block inside each layoutWith. The containing rules overlapping between the various items. This causes a lot ouf overrides and slows down everything. Luckily we can just disable all these style blocks and copy the contents to a new <style> without duplicates. If this ability didn't exist, the only solution would be to fork elm-ui. The selectorText reflects the cssText, so we only need to check if we have already copied the selectorText. This improves the situation heavily.

When a <style> block gets updated the underlying sheet property gets a new instance. We attach a MutationObserver on the TextNode so we update on change the merged style block and redisabling the sheet in the <style>. In my test app i added controls to change the height of all items. This is done by using E.height <| E.px height which will update the <style> block of every item. Without merging all <style> blocks the performance was really poor (3 FPS @ 100 items), while merged it increased to (23 FPS @ 100 items).

The main reason to merge all styles is not the app performance. It is far more important to prevent the overrides because the DevTools will get slow and cluttered with so many unnecessary entries.

The removal animation

After my first working version which used Element.animate() , i came up with a new approach. The special thing about Element.animate() is, that it keeps the animation state even if removed from the dom. With the new approach the state is now assigned to the elements with css properties and classes.

This makes the animation now really flexible. It's definitely not straightforward to write removal/insert animations in lists. There were some trouble with jumping elements due to wrong margin animations. This use case should be the most complicated. The animation-delay css property was used to control the position of each animation. The custom element translated the current state to these delay values, so it can be used within the stylesheet. With the created building blocks it is now possible to create some new animation containers.

The browser compatibility improved by it. The minimum safari version is now at 11.3 instead of 13.1. Also i don't have to come up with a new animation api, because i can just use css.

Configuration of animations

With css properties it is possible to define all animation outside of the custom element. I apply following css properties at the child elements of the item-containers element. These are important because animations are restarting when the dom element is moved around. With the css properties you can calculate the new delay to continue the animation.

  • --removal-animation-delay: The time in milliseconds since the first removal.
  • --removal-after: The time in milliseconds between added and removed.
  • --added-animation-delay: The time in milliseconds since the first time the item got added to the dom.
  • --is-first-delay: The time in milliseconds since the element got entered/left the first element state. The css property is updated when the change happens or if the element got added again. This enables a smooth animation of a spacing, when added in front of a list.

Also there are some classes applied to write the selectors.

There are following elements which represent no item:

  • animate-sibling-removal-behavior: This is the custom element which applies the removal behavior.

On each item of the list element:

  • .merged-style-container: This div contains a <style> element which is used for merging all <style> elements inside every item. It prevents the massive amount of css overrides caused by elm-ui which creates a new <style> block with the layoutWith function. This is actually good, that it works like this. If an item differ slightly in the <style> the appearence would break if we try to animate the removal.
  • .first-removal-containers: The container element if items in the front are getting removed.
  • .item-containers: Initially every .item-containers element contains only one child. If the virtual dom is removing an element, we need a place to insert it back. So we pick the previous sibling where we are adding the item.

Possible classes on children of an .item-containers element:

  • .initial: Is applied when an element was already available when the custom element got connected and has not entered the removing state yet.
  • .removing: Is applied when an element was removed by the virtual dom. It was moved to another .item-containers element so we can animate it. If it was the first item, it will get moved to the element with the .first-removal-containers class.

Moving the removed elements back to the DOM

If the virtual dom decides to remove elements there are some ways how it could happen:

  • Just removes a single element
  • Just removes multiple elements
  • It removes many elements and adds back the elements which are left
  • It removes many elements then it adds multiple new elements and then adds back the removed elements.

The first case is quite simple. We take the element and but the content to the next sibling and apply the animation if not yet started. The other ones are more complicated, but are somewhat connected in the implementation.

Here were some details how the virtual dom is operating. Because i have the additional block from the back to the front the description was not up to date anymore.

Browser compatibility

One of my projects are delivered on tablets with the application preinstalled. The tablet has at least a Chromium WebView at version 80.

I list all used API in the custom element with the minimum required versions in some browsers.

API Chrome/WebView Firefox IE Edge Opera/Android Safari WebKit/iOS
ES6 Classes 49 45 - 12 36/59 9
MutationObserver 26 14 11 79 15 6
Element.animate() 36/37 48 - 79 23/24 13.1/13.4
StyleSheet.disabled 1 1 9 12 1 1
CSS properties and var() 49 55 - 15 36 11.1/11.3
CSS @keyframes animation 43 16 10 12 30 9
Element.onanimationend() 79 18 - 18 66/57 9
CustomElementRegistry.define() 54 63 - 79 41/47 10.3

Maybe for older browsers there are already polyfills. I think i have used nothing else which would not have been already covered by ES6. On IE11 when i only use elm-ui i get already render issues.

If issues due browser compatibility does not interfere with the virtual dom, then the worst what happens is, that the animation will not getting executed. The most common interfering would be thrown exception which enters the elm virtual dom. Maybe these are already catched by the MutationObserver and can not enter the elm virtual dom.

I think in a later version the Element.animate() will not be used, if i get all animation working with css keyframes. With css keyframes i do not need to think about a nice api with the custom element. So this is preferable anyway.

Source code

Besides the working ellie the relevant source code is also attached to this gist. I have written the behavior custom element in TypeScript or i would get completely exhausted if i try to refactor it in JavaScript directly. The resulting JavaScript is attached too and is compiled for ES6/ES2015. The only reason to use this version, that i get otherwise an exception in the super() function called inside the constructor:

Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function`.

Are there problems?

I am quite sure that i covered the behavior of the elm virtual dom. There should be no problems. If you overcome potentional browser incompatiblities this should work, at least until the virtual dom gets changed. Please tell me if you find an issue within my implementation. Currently i am planning to use this in predefined environments like Chrome 80+ or Edge 86+. If there are no problems in the common webbrowsers maybe this is also usable for the web.

Possible improvements

I only wanted the basics written down, so i can move on with the next project. There is still room for improvements.

  • The animation is hard coded. Changing to vertical scaling should be ease. Just replace scale(0.0, 1.0) with scale(1.0, 0.0) in the index.ts or index.js. The required changes in Main.elm are a little more. Also this could be used for fadein/fadeout modal dialogs. Just keep opacity animation in the index.ts or index.js and change the elm code so it fills out the space. Also we could introduce more elements to configure the animations directly from elm code, so we never need to touch the TypeScript/JavaScript code again.
  • Publishing this as an elm package for the elm api with the need to install the WebComponent code manually. This would increase the visibility with this approach. I don't think i will do this anytime soon, if i am not needing it in multiple projects. Still never have created a github repository.
  • Make an animatableWrappedRow version, without letting them fly over the whole page. They should slide out in the current row and slide in on the new row.

There may be some more, but my time begins to run out.

Sources

For this project i have used the provided information on the MDN Web Docs. How the elm virtual dom operates was obtained by the logs of the MutationObserver event handler. You can enable the log if you switch on the constant AnimateSiblingRemovalBehavior.activateMutationLogging. Of course i used the elm/typescript compiler and some elm packages, which you can find in the elm.json.

All other informations used are obtained by experience in the past years and are not backtracable.

License

I don't know if i need to attach a license to enable others to use this.

So this gist, which contains the article text and the attached code files are licensed in terms of the MIT license. Taken from opensource.org:

Copyright 2021 Yarith@GitHub

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"mdgriffith/elm-ui": "1.1.8"
},
"indirect": {
"elm/json": "1.1.3",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=0">
<meta name="theme-color" content="#000000">
<base href="/">
<title>Fade-Out after removal by VDOM</title>
</head>
<body>
<script>
var app;
function initialize() {
app = Elm.Main.init({ node: document.body });
}
</script>
<script type="text/javascript" src="./build/debug/index.js"></script>
<script type="text/javascript" src="./build/debug/elm.compiled.js" onload="initialize();"></script>
</body>
</html>
var _a;
class ElementTransferMap {
constructor() {
this.keyToElementMap = [];
this.items = [];
}
key(target) {
// TODO: Maybe here is a binary tree better? Elements are comparable.
const targetNullable = !target ? null : target;
// Important: indexOf(undefined) will just give the current count of the array, so map it to null before.
const index = this.keyToElementMap.indexOf(targetNullable);
// Important: push(undefined) will always result in push(null)
return index >= 0 ? index : this.keyToElementMap.push(targetNullable) - 1;
}
get(target) {
const key = this.key(target);
return this.items[key];
}
set(target, items) {
const key = this.key(target);
if (items) {
this.items[key] = items;
}
else {
delete this.items[key];
}
}
move(target, movable) {
// If siblingKey is undefined then those elements are appended
// to the first added item.
const targetGroup = this.get(target) || [];
this.set(target, targetGroup);
const movableGroup = this.get(movable);
if (movableGroup) {
// We have already moved elements into the movable.
// Now we need to move its contents to the target.
targetGroup.push(movable, ...movableGroup);
// Remove the existing group for our movable from the items.
this.set(movable, undefined);
}
else {
// This movable does not represent a group,
// so we add the single item directly to the target.
targetGroup.push(movable);
}
}
moveUndefinedTo(target) {
// If there are a lot of changes then the virtual dom will
// remove our target before the really removed ones.
// The items that need to be visible will be added right back.
// So we end up for a short time with an empty container.
// The first item that will be added back is our container.
const movableGroup = this.get(undefined);
if (movableGroup) {
const targetGroup = this.get(target) || [];
this.set(target, targetGroup);
targetGroup.push(...movableGroup);
this.set(undefined, undefined);
}
}
buildMoveItems() {
const moveItems = [];
const entries = this
.items.entries();
for (const [key, movables] of entries) {
if (!movables) {
continue;
}
const target = this.keyToElementMap[key];
if (!target) {
// Should not happen, because there is always an element in our list.
debugger;
continue;
}
const childNodes = [];
movables.forEach(function (movable) {
movable.childNodes.forEach(function (childNode) {
if (childNode instanceof Element) {
childNodes.push(childNode);
}
});
});
moveItems.push({
target: target,
childNodes: childNodes,
});
}
return moveItems;
}
}
customElements.define("animate-sibling-removal-behavior", (_a = class AnimateSiblingRemovalBehavior extends HTMLElement {
constructor() {
super();
}
executeMove(queueItem) {
const siblingChildContainer = queueItem.target;
const childNodes = queueItem.childNodes;
childNodes.forEach((childNode) => {
var _a;
if (AnimateSiblingRemovalBehavior.activateMutationLogging) {
console.log(`Attempt to move ${childNode.id} to ${(_a = siblingChildContainer
.childNodes[0]) === null || _a === void 0 ? void 0 : _a.id}`);
}
siblingChildContainer.appendChild(childNode);
});
childNodes.forEach(function (childNode) {
if (!(childNode instanceof HTMLElement)) {
return;
}
const childNodeContent = childNode.childNodes.item(1);
if (!(childNodeContent instanceof HTMLElement)) {
return;
}
if (childNodeContent["remove-animation-applied"]) {
return;
}
const duration = 700;
childNode.animate([{}, { marginLeft: "0px" }], {
duration: duration,
easing: "ease-in-out",
fill: "forwards",
});
childNodeContent["remove-animation-applied"] = true;
childNodeContent.animate([{}, {
transform: "scale(0.0, 1.0)",
transformOrigin: "right center",
opacity: "0%",
width: "0px",
padding: "0px",
}], { duration: duration, easing: "ease-in-out", fill: "forwards" }).onfinish = (function () {
childNode.remove();
});
});
}
static logMutations(mutations) {
console.log(`Mutations: ${mutations.length}`);
mutations.forEach((mutation, mutationIndex) => {
function n(node) {
var _a, _b;
if (!node || !(node instanceof Element)) {
return "undefined";
}
return (_b = (_a = node.childNodes.item(0)) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : (node.tagName === "DIV" ? "appendix" : "behavior");
}
if (mutation.addedNodes.length > 0) {
const addedNodesIds = mutation.addedNodes.forEach((addedNode, addedNodeIndex) => {
console.log("Added[", mutationIndex, addedNodeIndex, "]:", n(mutation.previousSibling), "<", n(addedNode), ">", n(mutation.nextSibling));
});
}
if (mutation.removedNodes.length > 0) {
const removedNodesIds = mutation.removedNodes.forEach((removedNode, removedNodeIndex) => {
console.log("Removed[", mutationIndex, removedNodeIndex, "]:", n(mutation.previousSibling), "<", n(removedNode), ">", n(mutation.nextSibling));
});
}
});
}
handleMutations(mutations) {
if (AnimateSiblingRemovalBehavior.activateMutationLogging) {
AnimateSiblingRemovalBehavior.logMutations(mutations);
}
const moveTargets = new ElementTransferMap();
mutations.forEach((mutation, mutationIndex) => {
if (mutation.type === "childList" &&
mutation.target === this.parent) {
const nextSibling = mutation.nextSibling;
mutation.removedNodes.forEach((removedNode) => {
if (!!removedNode.parentNode ||
!(removedNode instanceof Element)) {
return;
}
moveTargets.move(nextSibling, removedNode);
});
if (mutation.addedNodes.length > 0) {
// If the virtual dom clears the container first and then
// adds the targets back, we need to add them to the first entry.
const newTarget = mutation.addedNodes.item(0);
if (newTarget) {
if (newTarget instanceof Element) {
moveTargets.moveUndefinedTo(newTarget);
}
}
}
}
});
// Do all planned movements
const queueItems = moveTargets.buildMoveItems();
queueItems.forEach((queueItem) => this.executeMove(queueItem));
}
connectParent(parent) {
this.disconnectParent();
if (!parent || parent.tagName !== "DIV") {
return;
}
this.parent = parent;
// Register ObservationObserver
var observer = new MutationObserver(this.handleMutations.bind(this));
var config = { attributes: false, childList: true, characterData: false };
observer.observe(parent, config);
this.observer = observer;
}
disconnectParent() {
const parent = this.parent;
if (!parent || parent.tagName !== "VIDEO") {
return;
}
this.parent = null;
// Unregister ObservationObserver
const observer = this.observer;
if (!observer) {
return;
}
observer.disconnect();
this.observer = null;
}
connectedCallback() {
const parentElement = this.parentElement;
if (parentElement instanceof HTMLDivElement) {
this.connectParent(parentElement);
}
}
disconnectedCallback() {
this.disconnectParent();
}
},
_a.activateMutationLogging = false,
_a));
//# sourceMappingURL=index.js.map
type ElementMoveItem = {
target: Element;
childNodes: Element[];
};
class ElementTransferMap {
keyToElementMap: (Element | null)[] = [];
items: any = [];
private key(target: undefined | Element): number {
// TODO: Maybe here is a binary tree better? Elements are comparable.
const targetNullable = !target ? null : target;
// Important: indexOf(undefined) will just give the current count of the array, so map it to null before.
const index = this.keyToElementMap.indexOf(targetNullable);
// Important: push(undefined) will always result in push(null)
return index >= 0 ? index : this.keyToElementMap.push(targetNullable) - 1;
}
private get(target: undefined | Element): undefined | Element[] {
const key = this.key(target);
return this.items[key];
}
private set(target: undefined | Element, items: undefined | Element[]) {
const key = this.key(target);
if (items) {
this.items[key] = items;
} else {
delete this.items[key];
}
}
move(target: undefined | Element, movable: Element) {
// If siblingKey is undefined then those elements are appended
// to the first added item.
const targetGroup = this.get(target) || [];
this.set(target, targetGroup);
const movableGroup = this.get(movable);
if (movableGroup) {
// We have already moved elements into the movable.
// Now we need to move its contents to the target.
targetGroup.push(movable, ...movableGroup);
// Remove the existing group for our movable from the items.
this.set(movable, undefined);
} else {
// This movable does not represent a group,
// so we add the single item directly to the target.
targetGroup.push(movable);
}
}
moveUndefinedTo(target: Element) {
// If there are a lot of changes then the virtual dom will
// remove our target before the really removed ones.
// The items that need to be visible will be added right back.
// So we end up for a short time with an empty container.
// The first item that will be added back is our container.
const movableGroup = this.get(undefined);
if (movableGroup) {
const targetGroup = this.get(target) || [];
this.set(target, targetGroup);
targetGroup.push(...movableGroup);
this.set(undefined, undefined);
}
}
buildMoveItems(): ElementMoveItem[] {
const moveItems: ElementMoveItem[] = [];
const entries: IterableIterator<[number, Element[]]> = this
.items.entries();
for (const [key, movables] of entries) {
if (!movables) {
continue;
}
const target = this.keyToElementMap[key];
if (!target) {
// Should not happen, because there is always an element in our list.
debugger;
continue;
}
const childNodes: Element[] = [];
movables.forEach(function (movable) {
movable.childNodes.forEach(function (childNode) {
if (childNode instanceof Element) {
childNodes.push(childNode);
}
});
});
moveItems.push({
target: target,
childNodes: childNodes,
});
}
return moveItems;
}
}
customElements.define(
"animate-sibling-removal-behavior",
class AnimateSiblingRemovalBehavior extends HTMLElement {
static readonly activateMutationLogging = false;
parent: HTMLDivElement | null;
observer: MutationObserver | null;
constructor() {
super();
}
executeMove(queueItem: ElementMoveItem) {
const siblingChildContainer = queueItem.target;
const childNodes = queueItem.childNodes;
childNodes.forEach((childNode) => {
if (AnimateSiblingRemovalBehavior.activateMutationLogging) {
console.log(
`Attempt to move ${childNode.id} to ${(siblingChildContainer
.childNodes[0] as Element)?.id}`,
);
}
siblingChildContainer.appendChild(childNode);
});
childNodes.forEach(function (childNode) {
if (!(childNode instanceof HTMLElement)) {
return;
}
const childNodeContent = childNode.childNodes.item(1);
if (!(childNodeContent instanceof HTMLElement)) {
return;
}
if (childNodeContent["remove-animation-applied"]) {
return;
}
const duration = 700;
childNode.animate(
[{}, { marginLeft: "0px" }],
{
duration: duration,
easing: "ease-in-out",
fill: "forwards",
},
);
childNodeContent["remove-animation-applied"] = true;
childNodeContent.animate(
[{}, {
transform: "scale(0.0, 1.0)",
transformOrigin: "right center",
opacity: "0%",
width: "0px",
padding: "0px",
}],
{ duration: duration, easing: "ease-in-out", fill: "forwards" },
).onfinish = (function () {
childNode.remove();
});
});
}
private static logMutations(mutations: MutationRecord[]) {
console.log(`Mutations: ${mutations.length}`);
mutations.forEach((mutation, mutationIndex) => {
function n(node: Node | undefined | null) {
if (!node || !(node instanceof Element)) {
return "undefined";
}
return (node.childNodes.item(0) as Element)?.id ??
(node.tagName === "DIV" ? "appendix" : "behavior");
}
if (mutation.addedNodes.length > 0) {
const addedNodesIds = mutation.addedNodes.forEach(
(addedNode, addedNodeIndex) => {
console.log(
"Added[",
mutationIndex,
addedNodeIndex,
"]:",
n(mutation.previousSibling),
"<",
n(addedNode),
">",
n(mutation.nextSibling),
);
},
);
}
if (mutation.removedNodes.length > 0) {
const removedNodesIds = mutation.removedNodes.forEach(
(removedNode, removedNodeIndex) => {
console.log(
"Removed[",
mutationIndex,
removedNodeIndex,
"]:",
n(mutation.previousSibling),
"<",
n(removedNode),
">",
n(mutation.nextSibling),
);
},
);
}
});
}
handleMutations(mutations: MutationRecord[]) {
if (AnimateSiblingRemovalBehavior.activateMutationLogging) {
AnimateSiblingRemovalBehavior.logMutations(mutations);
}
const moveTargets = new ElementTransferMap();
mutations.forEach((mutation, mutationIndex) => {
if (
mutation.type === "childList" &&
mutation.target === this.parent
) {
const nextSibling = mutation.nextSibling as Element;
mutation.removedNodes.forEach((removedNode) => {
if (
!!removedNode.parentNode ||
!(removedNode instanceof Element)
) {
return;
}
moveTargets.move(nextSibling, removedNode);
});
if (mutation.addedNodes.length > 0) {
// If the virtual dom clears the container first and then
// adds the targets back, we need to add them to the first entry.
const newTarget = mutation.addedNodes.item(0);
if (newTarget) {
if (newTarget instanceof Element) {
moveTargets.moveUndefinedTo(newTarget);
}
}
}
}
});
// Do all planned movements
const queueItems = moveTargets.buildMoveItems();
queueItems.forEach((queueItem) => this.executeMove(queueItem));
}
connectParent(parent: HTMLDivElement) {
this.disconnectParent();
if (!parent || parent.tagName !== "DIV") {
return;
}
this.parent = parent;
// Register ObservationObserver
var observer = new MutationObserver(this.handleMutations.bind(this));
var config = { attributes: false, childList: true, characterData: false };
observer.observe(parent, config);
this.observer = observer;
}
disconnectParent() {
const parent = this.parent;
if (!parent || parent.tagName !== "VIDEO") {
return;
}
this.parent = null;
// Unregister ObservationObserver
const observer = this.observer;
if (!observer) {
return;
}
observer.disconnect();
this.observer = null;
}
connectedCallback() {
const parentElement = this.parentElement;
if (parentElement instanceof HTMLDivElement) {
this.connectParent(parentElement);
}
}
disconnectedCallback() {
this.disconnectParent();
}
},
);
module Main exposing (main)
import Browser
import Element as E exposing (Attribute, Element)
import Element.Background as EB
import Element.Font as EF
import Element.Input as EI
import Element.Keyed as EK
import Html exposing (Html)
import Html.Attributes as HA
--
-- This code contains the contents of a helper module
--
style : String -> String -> Attribute msg
style name value =
HA.style name value |> E.htmlAttribute
--
-- This part contains the true user code which belongs to Main.elm
--
type alias Model =
{ items : List Int }
initialModel : Model
initialModel =
{ items = List.range 1 99 |> List.reverse }
type Msg
= Reset
| Increment
| Decrement
| RemoveFirst
| RemoveEven
| RemoveLast
| Remove Int
update : Msg -> Model -> Model
update msg model =
case msg of
Reset ->
initialModel
Increment ->
{ model | items = (List.head model.items |> Maybe.withDefault 0 |> (+) 1) :: model.items }
Decrement ->
{ model
| items =
case model.items of
_ :: tail ->
tail
_ ->
model.items
}
RemoveFirst ->
{ model | items = List.reverse model.items |> List.drop 20 |> List.reverse }
RemoveEven ->
{ model
| items =
model.items
|> List.indexedMap Tuple.pair
|> List.filter (Tuple.first >> modBy 2 >> (==) 0)
|> List.map Tuple.second
}
RemoveLast ->
{ model | items = model.items |> List.drop 20 }
Remove value ->
{ model | items = List.filter ((/=) value) model.items }
buttonSize : { width : number, height : number }
buttonSize =
{ width = 100, height = 100 }
button : List (Attribute msg) -> { onPress : Maybe msg, label : Element msg } -> Element msg
button attrs =
EI.button
(List.concat
[ [ EB.color <| E.rgb255 0x0F 0x7F 0x7F
, E.mouseDown [ EB.color <| E.rgb255 0x0F 0x9F 0x9F ]
, E.padding 20
, EF.color <| E.rgb255 0xFF 0xFF 0xFF
, EF.bold
, EF.size 44
, E.width <| E.px buttonSize.width
, E.height <| E.px buttonSize.height
]
, attrs
]
)
viewItem : Int -> ( String, E.Element Msg )
viewItem number =
item
{ key = "item-" ++ String.fromInt number
, width = buttonSize.width
}
(Just <|
-- Its currently bad that we need to set the scale in user code
-- Maybe we can get rid of it, if we put the stuff to animate in the
-- previous sibling instead of in the next one.
button []
{ label = E.text <| String.fromInt number
, onPress = Just <| Remove number
}
)
titleAttr : String -> Attribute msg
titleAttr =
HA.title >> E.htmlAttribute
buttonTemplate : Msg -> String -> String -> Element Msg
buttonTemplate msg title content =
button [] { onPress = Just msg, label = E.el [ E.centerX, E.centerY, titleAttr title ] (E.text content) }
view : Model -> Html Msg
view model =
E.layout [ E.width E.fill, E.padding 20, EB.color <| E.rgb255 0x00 0x00 0x00 ]
(E.column [ E.width E.fill, E.spacing 10, E.height E.fill ]
[ E.row [ E.width E.fill, E.spacing 20 ]
[ buttonTemplate Decrement "Remove first" "-"
, E.el [ EF.size 44, E.width <| E.px 50, EF.center, EF.color <| E.rgb255 0x4F 0xDF 0x4F ]
(List.length model.items |> String.fromInt |> E.text)
, buttonTemplate Increment "Append element" "+"
, buttonTemplate Reset "Reset" "R"
, buttonTemplate RemoveFirst "Remove first 20" "-F"
, buttonTemplate RemoveEven "Remove even" "-E"
, buttonTemplate RemoveLast "Remove last 20" "-L"
]
, E.el [ E.width E.fill, E.scrollbarX, E.height E.fill ] <|
animatedRow (List.map viewItem model.items)
]
)
main : Program () Model Msg
main =
Browser.sandbox
{ init = initialModel
, view = view
, update = update
}
--
-- Following code is meant for a separate module, but ellie does not support multiple modules.
--
layoutRow : List (Attribute msg) -> Maybe (E.Element msg) -> E.Element msg
layoutRow attrs element =
E.row
[ E.width E.fill
, E.height E.fill
, E.spacing 10
, style "transform" "scale(-1.0,1.0)"
]
[ case element of
Just el ->
E.html (E.layoutWith { options = [ E.noStaticStyleSheet ] } attrs el)
Nothing ->
E.none
]
itemContainer : String -> Maybe (Element msg) -> ( String, E.Element msg )
itemContainer key element =
( key
, layoutRow
[ EF.center
-- Id is only for debugging purpose
-- Keep in mind that fast insert and remove can cause duplicate content.
-- This may result in multiple element with same id/name while it is animating.
-- , E.htmlAttribute <| HA.id key
, E.spacing 10
]
element
)
type alias Item =
{ key : String
, width : Int
}
item : Item -> Maybe (Element msg) -> ( String, E.Element msg )
item { key, width } element =
itemContainer key
(element
|> Maybe.map
(E.el
[ style "transform" "scale(-1.0,1.0)"
, -- For performance reason we must set this element to a fixed width.
-- You can not really animate things when letting everything on auto.
E.width <| E.px width
]
)
)
appendix : ( String, E.Element Msg )
appendix =
itemContainer "appendix" Nothing
animateSiblingRemovalBehavior : ( String, E.Element Msg )
animateSiblingRemovalBehavior =
( "behaviour"
, E.html <|
Html.node "animate-sibling-removal-behavior" [] []
)
animatedRow : List ( String, Element Msg ) -> Element Msg
animatedRow items =
EK.row
[ E.width E.shrink
, E.spacing 10
]
(items
|> (::) appendix
|> List.reverse
|> (::) animateSiblingRemovalBehavior
)
{
"compilerOptions": {
"target": "es2015",
"module": "None",
"outDir": "./build/debug/",
"sourceMap": true,
"allowJs": true,
"strictNullChecks": true
},
"files": [
"./src/index.ts"
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment