Detect scrollHeight change with MutationObserver? - mutation-observers

How can I detect when scrollHeight changes on a DOM element using MutationObserver? It's not an attribute and it isn't data either.
Background: I need to detect when a scrollbar appears on my content element, the overflow-y of which is set to auto. I figured that the instant the scrollbar appears the value of scrollHeight jumps from 0 to, say, 500, so the idea was to set up a MutationObserver to detect a change in this property.
What I've got so far:
HTML
<div class="body" #body>
CSS
.body {
overflow-y: auto;
}
TypeScript
export class MyWatchedContent implements AfterViewInit, OnDestroy {
#ViewChild('body', { read: ElementRef })
private body: ElementRef;
private observer: MutationObserver;
public ngAfterViewInit() {
this.observer = new MutationObserver(this.observerChanges);
this.observer.observe(this.body.nativeElement, {
attributes: true,
});
}
public ngOnDestroy() {
this.observer.disconnect();
}
private observerChanges(records: MutationRecord[], observer: MutationObserver) {
console.log('##### MUTATION');
records.forEach((_record) => {
console.log(_record);
});
}
}
If I, for example, change the background color in the developer window I can see the observer firing
MUTATION
my-content-watcher.component.ts?d0f4:233 MutationRecord {type: "attributes", target: div.body, addedNodes: NodeList(0), removedNodes: NodeList(0), previousSibling: null…}
If, however, I change the window size to make the scrollbar appear there's no mutation detected. Is this doable with MutationObserver at all and if so, how?

Here's the answer, for anyone still looking for the solution:
As of today, it's not possible to directly monitor scrollHeight changes of an element.
The MutationObserver detects changes in the DOM tree, which could indicate a scrollHeight change, but that's a wild guess.
The ResizeObserver detects changes in the outer height of an element, but not the scrollHeight (i.e. "inner" height).
There is no ScrollHeight-Observer (yet).
BUT the solution is very close:
The Solution
The ResizeObserver detects changes in the outer height of an element...
There's no point in observing the scroll-container because its outer height does not change. The element that changes their out height is any CHILD node of the container!
Once the height of a child node changes, it means, that the scrollHeight of the parent container changed.
Vanilla JS version
const container = document.querySelector('.scrollable-container');
const observer = new ResizeObserver(function() {
console.log('New scrollHeight', container.scrollHeight);
});
// This is the critical part: We observe the size of all children!
for (var i = 0; i < container.children.length; i++) {
observer.observe(container.children[i]);
})
jQuery version
const container = $('.scrollable-container');
const observer = new ResizeObserver(function() {
console.log('New scrollHeight', container[0].scrollHeight);
});
container.children().each(function(index, child) {
observer.observe(child);
});
Further steps
When children are added dynamically, you could add a MutationObserver to add new children to the ResizeObserver once they were added.

You can emulate this behavior by adding an internal wrapping element on the contenteditable element (eg a span) and then add the ResizeObserver listener on the internal element. The internal span has to be display:block otherwise it wont trigger the ResizeObserver.
HTML
<div contenteditable id="input"><span id="content">Some content</span></div>
CSS
#input {
max-height: 100px;
overflow: scroll;
white-space: pre-wrap;
}
#content {
display: block;
white-space: pre-wrap;
}
JS
const content = document.getElementById("content");
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
console.warn(entry);
}
});
observer.observe(content);

Related

How to make fixed header availabel at the focus an focusable element?

I have a fixed header, when user scroll to an offset 100px, a class add to header to make it fixed:
fixed-header {
position: fixed:
top: 0,
left: 0,
right: 0,
width: 100%;
}
$( window ).on( 'scroll', function() {
if ( $( window ).scrollTop() >= 100 ) {
navigation.classList.add('.fixed-header');
document.body.style.paddingTop = "100px" // prevent page jump when fixed header
} else {
navigation.classList.remove('.fixed-header');
document.body.style.paddingTop = "0"
}
});
On top of page, when user focus a link via tab key, I use focus event listener to check the fixed class in DOM to scroll focusable element to re-position it below fixed header:
const elements = [...document.querySelectorAll(
"a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])"
)];
header = document.body.querySelector('.header-top'); // Fixed header height: 100px
const handleFocus = (e) => {
console.log(header.classList.contains('.fixed-header')); // This will false
if (header.classList.contains('.fixed-header')) {
var windowScrollTop = $( window ).scrollTop(),
focusableScrollTop = $(e.target).offset().top;
if (focusableScrollTop - windowScrollTop < 100) {
window.scrollTo({ top: focusableScrollTop - 100, behavior: 'smooth' });
}
}
};
for (const element of elements) {
element.addEventListener('focus', handleFocus, { once: true });
}
But focus callback handler fire before DOM repaint (when fixed class added to header), so it can not detect fixed class in DOM:
Wrap focus handler inside requestAnimationFrame()', don't work, if I wrap focus handler inside setTimeout()' function with 300ms, it will detect correct fixed class, but cause page scroll jump (focusable element scroll up and down).
for (const element of elements) {
element.addEventListener('focus', setTimeout(() => {
handleFocus()
}, 300), { once: true });
}
Any way to make header.classList.contains('.fixed-header') come true at the focusable link focused?
I hope someone know this issue and help to fix this problem. Thank you very much.

StencilJS component with shadow dom enabled does not generate the helper CSS classes for dynamically added elements on IE11/Edge

I've created a new project using the stencil component starter. Inside my component I'm using an external JS nouislider, which injects HTML elements into my div (this.slider ref):
...
componentDidLoad() {
noUiSlider.create(this.slider, {
start: [20, 80],
range: {
'min': 0,
'max': 100
}
})
}
...
I've copied the slider's CSS into my-component.css and rewrote everything with :host selectors for the shadow dom:
:host(.my-component) .noUi-target {
position: relative;
direction: ltr
}
Everything works fine on Chrome/Firefox but the slider styles are not working on IE11/Edge because Stencil appends a helper sc-my-component class to every element that I have inside the render method and generates CSS rules like so:
.my-component.sc-my-component-h .noUi-target.sc-my-component {
position: relative;
direction: ltr
}
but the injected nouislider child HTML elements don't have the helper classes on them. I have an ugly fix for this case atm:
...
componentDidLoad() {
noUiSlider.create(this.slider, {
start: [20, 80],
range: {
'min': 0,
'max': 100
}
})
this.slider.querySelectorAll('div').forEach((child)=>{
child.classList.add('sc-my-component')
})
}
...
I'm appending the helper classes after the slider is created (the slider generates child divs only). Is there a better way to tell Stencil that I'm injecting elements inside lifecycle methods and that it needs to recognize those elements when CSS rules are being generated?
This is not an answer to your question, nevertheless this could also be interesting for you:
We are currently working on the same topic (StencilJS, shadow: true, noUiSlider) and encountered the problem, that the slider's touch events are not working correctly in shadowDOM on mobile devices. We found a solution for this and already created a PR (https://github.com/leongersen/noUiSlider/pull/1060).
I too had problems using nouislider in StencilJS but just managed to make it work.
my-slider.scss
#import '~nouislider/distribute/nouislider.css';
:host {
padding: 50px 30px;
display: block;
}
my-slider.tsx
import { Component, h, Prop, Event, EventEmitter } from '#stencil/core';
import noUiSlider from "nouislider";
#Component({
tag: 'skim-slider',
styleUrl: 'skim-slider.scss',
shadow: true
})
export class SkimSlider {
#Prop() min!: number;
#Prop() max!: number;
private slider: HTMLElement;
#Event() update: EventEmitter;
componentDidLoad() {
const slider = noUiSlider.create(this.slider, {
start: [this.min, this.max],
tooltips: [true, true],
range: {
'min': this.min,
'max': this.max
}
});
slider.on('change', (value) => this.update.emit(value));
}
render() {
return (
<div ref={e => this.slider = e}></div>
);
}
}
The trick that did it for me was 'display: block'

How to add a horizontal scrollbar on top of the ag-grid

I have an ag-grid set up with a series of components in place for cell rendering. When my dataset loads the vertical scroll works well but the horizontal scroll isn't obvious unless using a trackpad or horizontal scroll enabled mouse.
I would like to be able to add a scroll bar to the top of the grid as well as the automatically generated one at the bottom?
Has anyone encountered this, come up with as solution?
Thanks in advance
This question is old but I struggled with the same issue and came up with something working.
💡 The Idea
The main idea behind my solution is to...
clone AgGrid scrollbar when grid is ready
insert the cloned scrollbar on top of the grid
add event listeners on both scrollbars to keep the scroll position synchronized
use MutationObserver to observe style attribute changes on original AgGrid scrollbar element (and child) to keep the size of the cloned scrollbar synchronized
⚡ The Code
The following code is for Angular but the concept is the same for Vanilla JS, React or Vue.
First, get a hook on gridReady event:
<ag-grid-angular
...
(gridReady)="onGridReady()">
</ag-grid-angular>
In the function associated to the event use the following code to clone the AgGrid scrollbar and keep the scrollbars synchronized:
// hold the `MutationObserver` to be disconnected when component is destroyed
private mutationObserver: MutationObserver;
onGridReady() {
// css class selectors
const headerSelector = '.ag-header';
const scrollSelector = '.ag-body-horizontal-scroll';
const scrollViewportSelector = '.ag-body-horizontal-scroll-viewport';
const scrollContainerSelector = '.ag-body-horizontal-scroll-container';
// get scrollbar elements
const scrollElement = document.querySelector(scrollSelector);
const scrollViewportElement = document.querySelector(scrollViewportSelector);
const scrollContainerElement = document.querySelector(scrollContainerSelector);
// create scrollbar clones
const cloneElement = scrollElement.cloneNode(true) as Element;
const cloneViewportElement = cloneElement.querySelector(scrollViewportSelector);
const cloneContainerElement = cloneElement.querySelector(scrollContainerSelector);
// insert scrollbar clone
const headerElement = document.querySelector(headerSelector);
headerElement.insertAdjacentElement('afterend', cloneElement);
// add event listeners to keep scroll position synchronized
scrollViewportElement.addEventListener('scroll', () => cloneViewportElement.scrollTo({ left: scrollViewportElement.scrollLeft }));
cloneViewportElement.addEventListener('scroll', () => scrollViewportElement.scrollTo({ left: cloneViewportElement.scrollLeft }));
// create a mutation observer to keep scroll size synchronized
this.mutationObserver = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
switch (mutation.target) {
case scrollElement:
cloneElement.setAttribute('style', scrollElement.getAttribute('style'));
break;
case scrollViewportElement:
cloneViewportElement.setAttribute('style', scrollViewportElement.getAttribute('style'));
break;
case scrollContainerElement:
cloneContainerElement.setAttribute('style', scrollContainerElement.getAttribute('style'));
break;
}
}
});
// start observing the scroll elements for `style` attribute changes
this.mutationObserver.observe(scrollElement, { attributeFilter: ['style'], subtree: true });
}
When destroying the component, disconnect the MutationObserver to avoid memory leaks.
ngOnDestroy() {
// stop observing
this.mutationObserver.disconnect();
}
It's tricky and all based on keeping the cloned scrollbar synchronized with the original scrollbar but so far it works great for my use cases.
Good luck 😎
Update 2022
::ng-deep{
.ag-root-wrapper{
.ag-root-wrapper-body{
.ag-root{
.ag-body-horizontal-scroll{
order: 1;
}
.ag-header{
order: 2;
}
.ag-floating-top{
order: 3;
}
.ag-body-viewport{
order: 4;
}
.ag-floating-bottom{
order: 5;
}
.ag-overlay{
order: 6;
}
}
}
}
}

Setting the class of a particular element permanently from the group of elements created using foreach binding in knockout js

I just started using knockout js. I am creating group of images using foreach binding of knockout js. Initially all the images have same css class "imageUnvisited" and I am trying to change and set the class(permanently) of the particular image that I click to show the visited state. I am successful in changing the class on click event but as soon as I click on another image, the new added class of the previously clicked image gets remove. I am new it the IT filed so pardon me if you find anything wrong, please help.
Below is the code that I am using:
var vm = {
item: jsonData.items,
clickedImageIndex: ko.observable('')
}
ko.applyBindings(vm);
function getVisitedClass(data, index) {
if (index() == vm.clickedImageIndex()) {
return "imageVisited"
}
}
function imageClicked(data, e) {
var itemTarget = e.target || e.srcElement;
index = ko.contextFor(itemTarget).$index();
vm.clickedImageIndex(index);
}
.imageUnvisited {
border: solid 1px green;
}
.imageVisited {
border: solid 1px black;
}
<div data-bind="foreach: item" id="image_gallery">
<div id="image_wrapper">
<image data-bind="attr: { id: 'image' + $index(), src: $data.Src, class: getVisitedClass($data, $index)},click: imageClicked" class="imageUnvisited" role="button"></image>
</div>
</div>
Why your current code doesn't work:
You're storing one index in clickedImageIndex which is updated on every click. Therefore, the expression index() == vm.clickedImageIndex() can only be true for one image at a time.
A "quick fix":
Instead of storing an index in clickedImageIndex, you can store multiple inside an object.
In your vm:
clickedImageIndex: ko.observable({})
In your handler:
var clicked = vm.clickedImageIndex();
clicked[index] = true;
vm.clickedImageIndex(clicked);
In your getVisitedClass:
if (vm.clickedImageIndex()[index()]) {
return "imageVisited"
}
A better fix:
It's probably a good idea to follow knockouts MVVM architecture a bit stricter... This means:
Map your images to ImageViewModel instances
Add a clicked observable property to ImageViewModel
Add a ImageViewModel.prototype.onClick method that sets this.clicked(true)
Use the data-bind="click: onClick, css: { 'imageVisited': clicked }" data-bind to update state.

Add button to fancytree node to delete/remove that node?

I cannot find an example to do this anywhere, although I could have sworn I've seen one in the past.
I want to add a button to a node in fancytree so that either on hovering over that node (or maybe on selecting it) the button displays (a white x on a red circle, for example) and clicking it will delete/remove that node. At all other times the delete button should be hidden for the node.
I've been unable to find any kind of example where a custom link or button is added to a fancytree node though - maybe it's not possible to do or I'm just using the wrong search terms?
Edit: I found a way to add a clickable button by appending html to the title string:
title: component.name() + "<span class='deleteButton'><a href='#' data-bind='click: myfunction'><img src='../../Content/images/deleteIcon.png' /></a></span>",
And by adding some custom css to my site file:
span.fancytree-node span.deleteButton {
display: none;
}
span.fancytree-active span.deleteButton {
margin-left: 10px;
display: inline-block;
}
But this adds the button to the title text and is therefore subject to the highlighting of the title when active. It would be better if there was a way to add this to the node OUTSIDE of the title text. Is that possible Martin?
$("#tree").fancytree({
source: [...],
renderNode: function (event, data) {
var node = data.node;
var $nodeSpan = $(node.span);
// check if span of node already rendered
if (!$nodeSpan.data('rendered')) {
var deleteButton = $('<button type="button" class="btn">delete node</button>');
$nodeSpan.append(deleteButton);
deleteButton.hide();
$nodeSpan.hover(function () {
// mouse over
deleteButton.show();
}, function () {
// mouse out
deleteButton.hide();
})
// span rendered
$nodeSpan.data('rendered', true);
}
}
});
I normally use css ':after' for such cases (https://developer.mozilla.org/de/docs/Web/CSS/::after).
If that is not enough, you can always tweak the markup in the 'renderNode' event.