I try to implement a resortable list of collapsible div elements in vanilla JS. The general sorting is static but any of the elements is moved to the top. The top item is to be exchangeable. Transitions between different top items and the folding of the elements itself are supposed to be animated to clearly show the change.
My approach is like this:
- There is a styling class
.top
for the top item and.before-top
for all blocking items (i.e. items which in general sorting order placed before the top item). They contain the distance--offset
these elements have to be moved. - If the top item changes, assignment of these classes to the elements is updated.
- If the top item changes or some folding occurs the
--offset
s are recalculated and the CSS rules updated.
Unfortunately this only works without animations, that means instant placement. Else if some middle element is on top then folding any .top
or .before-top
element messes up the layout (e.g. if C is on top, folding A, B or C causes problems).
Obviously the calculations of upDelta
and downDelta
is wrong. They consider the current heights of elements not the one when folding has finished.
The code (excuse the wall of text but it's already boiled down):
function assignClasses() {
const input = document.getElementById('input').value;
const items = [...document.getElementsByClassName('item')];
const topIdx = items.findIndex(elem => elem.children[0].textContent == input);
if (topIdx < 0) return;
items.forEach((elem, idx) => {
elem.classList.toggle('top', idx == topIdx);
elem.classList.toggle('before-top', idx < topIdx);
});
}
function adjustDeltas() {
const input = document.getElementById('input').value;
const topItem = Array.prototype.find.call(
document.getElementsByClassName('item'),
elem => elem.children[0].textContent == input
);
if (!topItem) return;
const firstItem = document.querySelector('.item');
const upDelta = firstItem.offsetTop - topItem.offsetTop;
const downDelta = topItem.nextElementSibling != null
? topItem.nextElementSibling.offsetTop - topItem.offsetTop
: topItem.previousElementSibling != null
? topItem.offsetTop
+ topItem.offsetHeight
- topItem.previousElementSibling.offsetTop
- topItem.previousElementSibling.offsetHeight
: 0;
function updateCssRule(selector, delta) {
const sheet = document.styleSheets[0];
const topIdx = Array.prototype.findIndex.call(sheet.cssRules, r => r.selectorText == selector);
if (topIdx) {
sheet.deleteRule(topIdx);
}
sheet.insertRule(`${selector} { --offset: ${delta}px; }`, sheet.cssRules.length);
}
updateCssRule('.top', upDelta);
updateCssRule('.before-top', downDelta);
}
function toggleFolding(target) {
let foldable = target.nextElementSibling;
foldable.style.maxHeight = foldable.style.maxHeight
? null
: foldable.scrollHeight + 'px';
adjustDeltas();
}
Array.prototype.forEach.call(
document.getElementsByClassName('item'),
elem => elem.addEventListener('click', event => toggleFolding(event.target))
);
document.getElementById('input').addEventListener('change', event => {
assignClasses();
adjustDeltas();
});
.container {
position: relative;
}
.item {
top: 0;
width: 10rem;
margin: .5rem;
text-align: center;
line-height: 3rem;
opacity: 0.7;
}
.head {
height: 3rem;
}
.body {
height: 2rem;
max-height: 0;
overflow: hidden;
transition: max-height 1s ease;
}
.top,
.before-top {
translate: 0 var(--offset, 0);
transition: translate 1s ease;
}
.top { --offset: 0; }
.before-top { --offset: 0; }
#A { background: green; }
#B { background: yellow; }
#C { background: purple; }
#D { background: blue; }
#E { background: teal; }
<input id="input" type="text" placeholder="Enter top item!" autofocus />
<div class="container">
<div id="A" class="item"> <div class="head">A</div> <div class="body">aa</div> </div>
<div id="B" class="item"> <div class="head">B</div> <div class="body">bb</div> </div>
<div id="C" class="item"> <div class="head">C</div> <div class="body">cc</div> </div>
<div id="D" class="item"> <div class="head">D</div> <div class="body">dd</div> </div>
<div id="E" class="item"> <div class="head">E</div> <div class="body">ee</div> </div>
</div>
I also tried to manually consider the expected change in height:
function adjustDeltas(additionalOffsetPx=0, elemClass=null) {
// ...
const upDelta = firstItem.offsetTop - topItem.offsetTop
- (elemClass == 'before-top') * additionalOffsetPx;
const downDelta = (topItem.nextElementSibling != null
// ...
: 0) + (elemClass == 'top') * additionalOffsetPx;
// ...
}
function toggleFoldingV1(target) {
// assuming the difference will be exactly the hidden content
// ...
const heightChangePx = (foldable.style.maxHeight ? -1 : 1) * foldable.scrollHeight;
// ...
adjustDeltas(heightChangePx, parentClass /* .top or .before-top or null*/);
}
function toggleFoldingV2(target) {
// ...
const parentClone = foldable.parentElement.cloneNode(true);
parentClone.style.transition = "none";
parentClone.style.display = "none";
foldable.parentElement.parentElement.appendChild(parentClone);
const oldHeight = window.getComputedStyle(parentClone).height;
parentClone.lastElementChild.style.maxHeight = foldable.style.maxHeight
? null
: foldable.scrollHeight + 'px';
const heightChangePx = window.getComputedStyle(parentClone).height - oldHeight;
parentClone.remove();
// ...
adjustDeltas(heightChangePx, parentClass /* .top or .before-top or null*/);
}
Version 2 is adopting this SO thred.
How can I correctly anticipate the offsets? Is there any member of Element
which allows a correct calculation? I am also open to other vanilla-JS approaches.
Thanks in advance.