Incorrect behavior of content editable div in custom element shadowRoots in Firefox? - firefox

I have a program that includes some nested custom elements. The leaves of one of these component's shadowRoot contains instances of an element like <div contentEditable>. In Chrome79 and Chromium-Edge-Beta the contentEditable feature works as one would expect it to - that is, the elements focus when you click or tab to them, show a focus outline, and are editable. In FireFox72 they behave erratically, mainly in that clicking on one will focus on it only some of the time, and that while they can be tabbed to, they do not focus such that they can be typed into.
After some whittling, I think I've arrived at a minimal reproduction. It is two custom elements: A root element ce-main and the leaf element ce-leaf that is instantiated arbitrarily many times from within ce-main and attached to ce-main's shadowRoot.
class Main extends HTMLElement {
constructor() { super(); }
connectedCallback() {
this.attachShadow({mode: "open"});
this.shadowRoot.innerHTML = `
<style>
[contentEditable] {
min-height:2em;
padding:.5em;
border:1px dashed rgba(0,0,0,.0625);
}
[contentEditable]:empty::before {
color: rgba(0,0,0,.15);
content: "You should be able to focus and type here.";
cursor:text;
}
</style>
<div id="container" style=""></div>`;
customElements.whenDefined("ce-leaf").then(
() => this.constructFromSomeDataSource()
);
}
constructFromSomeDataSource() {
let rows = [];
for (let i = 0; i < 4; i++) {
let leaf = document.createElement("ce-leaf");
this.shadowRoot.querySelector("#container").appendChild(leaf);
};
}
}
class Leaf extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `
<div contentEditable></div>
`;
}
}
customElements.define("ce-main", Main);
customElements.define("ce-leaf", Leaf);
<ce-main></ce-main>
If we do without the shadowRoot, everything is nicely focusable in Chrome/EdgeBeta/Firefox:
class Main extends HTMLElement {
constructor() { super(); }
connectedCallback() {
customElements.whenDefined("ce-leaf").then(
() => this.constructFromSomeDataSource()
);
}
constructFromSomeDataSource() {
let rows = [];
for (let i = 0; i < 4; i++) {
let leaf = document.createElement("ce-leaf");
this.appendChild(leaf);
};
}
}
class Leaf extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `
<div contentEditable></div>
`;
}
}
customElements.define("ce-main", Main);
customElements.define("ce-leaf", Leaf);
[contentEditable] {
min-height:2em;
padding:.5em;
border:1px dashed rgba(0,0,0,.0625);
}
[contentEditable]:empty::before {
color: rgba(0,0,0,.15);
content: "You should be able to focus and type here.";
cursor:text;
}
<ce-main></ce-main>
Can anyone verify if this is a bug in FF, or if I am simply doing something that is not in line with how it should be done in FF?

Had to dig through many Firefox/Focus posts.
Similar behaviour in a FireFox bug going back some 6 years: https://bugzilla.mozilla.org/show_bug.cgi?id=904846
Workaround
Found the best approach here: Clicking outside a contenteditable div stills give focus to it?
Handle the contenteditable attribute and setting focus() yourself with click & blur events:
(note: leafCounter is valid CSS, just does not work in StackOverflow inline code, works in JSFiddle)
class Main extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.attachShadow({ mode: "open" })
.innerHTML = `<style>
ce-leaf div {
padding: .5em;
cursor: text;
counter-increment: leafCounter;
}
ce-leaf div:empty::before {
color: lightgrey;
content: "placeholder text #" counter(leafCounter);
}
[contenteditable]:focus{
background: lightgreen;
}
</style>` + "<ce-leaf></ce-leaf>".repeat(5);
}
}
class Leaf extends HTMLElement {
constructor() {
super();
let div = this.appendChild(document.createElement("div"));
div.addEventListener("click", evt => {
evt.target.contentEditable = true;
evt.target.focus();
});
div.addEventListener("blur", evt => {
evt.target.contentEditable = false;
});
}
}
customElements.define("ce-main", Main);
customElements.define("ce-leaf", Leaf);
<ce-main></ce-main>
<ce-leaf> IS an element!
You don't need that DIV inside a <ce-leaf> Custom Element...
JSFiddle version does the contenteditable on <ce-leaf>
https://jsfiddle.net/dannye/udmgL03p/
constructor() {
super();
this.addEventListener("click", evt => {
this.contentEditable = true;
this.focus();
});
this.addEventListener("blur", evt => {
this.contentEditable = false;
});
}
Update: Alas another Firefox with contenteditable bug: You can't select part of a text and replace it in the JSfiddle..
stick with the DIV inside an element solution.

Related

How to Inject CSS or JavaScript to SharePoint Modern page using SPFX on Sharepoint Server 2019

We are using Sharepoint Modern communication site on Sharepoint Server 2019. I am tasked with customizing the out-of-the-box template for header and adding a footer. I was toying with using https://github.com/pnp/sp-dev-fx-extensions/tree/main/samples/react-application-injectcss but I am unable to get it to work. The SPPKG file throws the following error when I deploy it to Sharepoint Server 2019 apps.
There were errors when validating the App manifest.:Xml Validation Exception: 'The 'IsDomainIsolated' attribute is not declared.' on line '1', position '322'.
Please help.
Thanks
You have to create new project because that sample is using spfx 1.8.0. For SharePoint 2019 you have to use spfx ~1.4.0.
I have my own ApplicationCustomizer with custom css inside for branding ShP 2019.
import { override } from '#microsoft/decorators';
import { Log } from '#microsoft/sp-core-library';
import { BaseApplicationCustomizer } from '#microsoft/sp-application-base';
import * as $ from 'jquery';
import styles from './css/styles.module.scss';
require('./css/styles.module.scss');
export default class BrandingApplicationCustomizer extends BaseApplicationCustomizer<{}> {
#override
public async onInit(): Promise<any> {
try {
await this.wait('.o365cs-nav-centerAlign');
if (window.screen.availWidth < 639) {
$('.o365cs-nav-centerAlign').css({"height":"50px", "cursor":"pointer", "background":"url(https://.../_layouts/15/Intranet.Design/Img/_intranet_logo.png) no-repeat left center"});
$('.o365cs-nav-centerAlign').click(function() {
window.location.href = 'https://intranet';
});
} else {
$('.o365cs-nav-centerAlign').html(`
<div class=` + styles.brandingMainDiv + `>
<a href='https://intranet' style='line-height: 46px;'>
<img src='https://intranet./_layouts/15/Intranet.Design/Img/_intranet_logo.png' style='margin: auto;vertical-align: middle;display: inline-block;'/>
</a>
<div style='margin-left:15px;border-left:1px solid white;'></div>
<a href='` + this.context.pageContext.web.absoluteUrl + `'>
<div style='font-family: Segoe UI Regular WestEuropean,Segoe UI,Tahoma,Arial,sans-serif;line-height: 40px;display: inline-block;font-size: 20px;-webkit-font-smoothing: antialiased;margin: 0px 0 0 25px;vertical-align: top;font-weight: bold;color:white;'>
` + this.context.pageContext.web.title + `</div></a></div>
`);
}
} catch (error) {
};
return Promise.resolve();
}
private wait = (selector) => {
return new Promise((resolve, reject) => {
const waitForEl = (selector, count = 0) => {
const el = $(selector);
if (el.length > 0) {
resolve(el);
} else {
setTimeout(() => {
count++;
if (count < 1000) {
waitForEl(selector, count);
} else {
reject('Error: More than 1000 attempts to wait for element');
}
}, 100);
}
};
waitForEl(selector);
});
}
}
Css looks like this (in scss you need to use global atribute):
:global {
.CanvasZone,
.SPCanvas-canvas {
max-width: none !important;
}
button[data-automation-id="pageCommandBarNewButton"] {
display: none !important;
}
body, /* entire body*/
.ms-Nav, /*left navigation pane background*/
#spPageChromeAppDiv /* whole page again*/
{
background-color: #eeece1 !important;
}
.o365cs-base.o365cs-topnavBGColor-2, /* top bar*/
.o365cs-base .o365cs-nav-rightMenus /* top bar menu right*/
{
background-color: #17375e !important;
}
#O365_MainLink_Help,
#O365_NavHeader button#O365_MainLink_NavMenu,
#O365_NavHeader button#O365_MainLink_NavMenu_Responsive,
.o365button.o365cs-nav-appTitle.o365cs-topnavText,
.o365cs-nav-topItem.o365cs-rsp-m-hide.o365cs-rsp-tn-hideIfAffordanceOn,
.o365button .o365cs-nav-brandingText,
.o365cs-nav-brandingText /* Top bar Sharepoint text*/
{
display: none !important;
}

UIKit 3 accordion external button

How to close\ open accorion on click to .shewron element?
Example: https://codepen.io/npofopr/pen/vYKKXbJ?editors=1010
let util = UIkit.util;
let lkOrderIconExpand = document.querySelectorAll(".shewron");
util.on(lkOrderIconExpand, "click", function () {
let accordionEl = util.$("[uk-accordion]");
// find closed li array
let allItems = util.$$("[uk-accordion] > li");
// for each element
util.each(allItems, function (el) {
// get index
let openIndex = util.index(el);
// Check if some element has some class
if (util.hasClass(allItems, "uk-open")) {
console.log("Class was found!");
// toggle it
UIkit.accordion(accordionEl).toggle(openIndex);
} else {
console.log("Class was NOT found!");
// toggle it
UIkit.accordion(accordionEl).toggle(openIndex);
}
});
});
You can easily do it with CSS:
.uk-accordion-title::before{
background-image: url(**icon-url-here);
}
.uk-open > .uk-accordion-title::before{
background-image: url(**icon-url-here);
}
So that your chevron icons will show instead of default accordion icons.

Instant return to initial state after Angular2 Animate based animation to be able to run the animation several times

I'm trying to accomplish a simple Angular2 animation using the Animate modules.
Here is what I managed to do so far: https://plnkr.co/edit/o0ukvWEB4THB0EQmv0Zu
Everything is as I'm expecting it, the red cross progressively disappears in a motion to the top.
My problem is that I want to be able to do the same animation as many times as I want.
I used the onComplete method with a callback that resets the styles to the initial state. The only problem I have is that instead of returning instantly to it's initial state the object "annimates back". So I have two animations instead of only one.
I think I'm misunderstanding some of the concepts of the Angular2 Animate modules or maybe simply lacking the required CSS skills.
Any help appreciated.
Here is my code so far:
//our root app component
import {Component, Directive, ElementRef} from 'angular2/core';
import {bootstrap} from 'angular2/platform/browser';
import {AnimationBuilder} from 'angular2/src/animate/animation_builder';
import Subject from '#reactivex/rxjs/dist/cjs/Subject';
#Directive({
selector : '[animate-box]',
host : {
'[style.background-color]' : "'transparrent'"
},
exportAs : 'ab'
})
class AnimateBox {
animation: Animation;
a:Animation;
cb: Function = () => {
console.log("animation finished");
console.dir(this.a);
this.a.applyStyles({top: '-20px', opacity: '100' });
};
constructor(private _ab: AnimationBuilder, private _e: ElementRef) {
}
toggle(isVisible: boolean = false) {
this.animation = this._ab.css();
this.animation
.setDuration(1000);
this.animation.addAnimationClass("test-animation-class");
this.animation.setFromStyles(
{
top: '-20px',
opacity: '100',
})
.setToStyles(
{
opacity: '0',
top: '-120px'
});
this.a = this.animation.start(this._e.nativeElement);
this.a.onComplete(this.cb);
}
}
#Component({
selector: 'my-app',
template: `
<span class="vote"><span animate-box #box="ab" class="test-vote"><i class="fa fa-close"></i></span>1</span>
<button data-tooltip="I’m the tooltip text." (click)="box.toggle(visible = !visible)">Animate</button>
`,
directives : [AnimateBox]
})
export class App {
visible = true;
}
bootstrap(App).catch(err => console.error(err));
update
I created just two animations for forward and backwards and start one run on the end of the onComplete callback of the previous animation.
Plunker
class AnimateBox {
//animation: Animation;
//a:Animation;
constructor(private _ab: AnimationBuilder, private _e: ElementRef) {}
createAnimation:Function = (forward:boolean, count:number):Animation => {
animation = this._ab.css();
animation.setDuration(1000);
animation.addAnimationClass("test-animation-class");
if(forward) {
animation.setFromStyles({
top: '-20px',
opacity: '100',
})
.setToStyles({
top: '-120px'
opacity: '0',
});
} else {
animation.setFromStyles({
top: '-120px',
opacity: '0',
})
.setToStyles({
opacity: '100',
top: '-20px'
});
}
a = animation.start(this._e.nativeElement);
console.log(a);
a.onComplete(() => { this.onComplete(forward, count);});
};
onComplete:Function = (forward:boolean, count:number) => {
console.log("animation finished");
//console.dir(this.a);
//animation.applyStyles({top: '-20px', opacity: '100' });
if(count) {
a = this.createAnimation(!forward, --count);
console.log('a ' + a);
}
};
toggle:Function =(isVisible: boolean = false) => {
this.createAnimation(true,10);
};
}
original
When you set
cb: Function = () => {
console.log("animation finished");
console.dir(this.a);
// this.a.applyStyles({top: '-20px', opacity: '100' });
};
top is animated back. Just comment the line out and it stops after moving up.
setFromStyles() is also redundant because the element has that style already.

Double click and click on ReactJS Component

I have a ReactJS component that I want to have different behavior on a single click and on a double click.
I read this question.
<Component
onClick={this.onSingleClick}
onDoubleClick={this.onDoubleClick} />
And I tried it myself and it appears as though you cannot register both single click and double click on a ReactJS component.
I'm not sure of a good solution to this problem. I don't want to use a timer because I'm going to have 8 of these single components on my page.
Would it be a good solution to have another inner component inside this one to deal with the double click situation?
Edit:
I tried this approach but it doesn't work in the render function.
render (
let props = {};
if (doubleClick) {
props.onDoubleClick = function
} else {
props.onClick = function
}
<Component
{...props} />
);
Here is the fastest and shortest answer:
CLASS-BASED COMPONENT
class DoubleClick extends React.Component {
timer = null
onClickHandler = event => {
clearTimeout(this.timer);
if (event.detail === 1) {
this.timer = setTimeout(this.props.onClick, 200)
} else if (event.detail === 2) {
this.props.onDoubleClick()
}
}
render() {
return (
<div onClick={this.onClickHandler}>
{this.props.children}
</div>
)
}
}
FUNCTIONAL COMPONENT
const DoubleClick = ({ onClick = () => { }, onDoubleClick = () => { }, children }) => {
const timer = useRef()
const onClickHandler = event => {
clearTimeout(timer.current);
if (event.detail === 1) {
timer.current = setTimeout(onClick, 200)
} else if (event.detail === 2) {
onDoubleClick()
}
}
return (
<div onClick={onClickHandler}>
{children}
</div>
)
}
DEMO
var timer;
function onClick(event) {
clearTimeout(timer);
if (event.detail === 1) {
timer = setTimeout(() => {
console.log("SINGLE CLICK");
}, 200)
} else if (event.detail === 2) {
console.log("DOUBLE CLICK");
}
}
document.querySelector(".demo").onclick = onClick;
.demo {
padding: 20px 40px;
background-color: #eee;
user-select: none;
}
<div class="demo">
Click OR Double Click Here
</div>
I know this is an old question and i only shoot into the dark (did not test the code but i am sure enough it should work) but maybe this is of help to someone.
render() {
let clicks = [];
let timeout;
function singleClick(event) {
alert("single click");
}
function doubleClick(event) {
alert("doubleClick");
}
function clickHandler(event) {
event.preventDefault();
clicks.push(new Date().getTime());
window.clearTimeout(timeout);
timeout = window.setTimeout(() => {
if (clicks.length > 1 && clicks[clicks.length - 1] - clicks[clicks.length - 2] < 250) {
doubleClick(event.target);
} else {
singleClick(event.target);
}
}, 250);
}
return (
<a onClick={clickHandler}>
click me
</a>
);
}
I am going to test this soon and in case update or delete this answer.
The downside is without a doubt, that we have a defined "double-click speed" of 250ms, which the user needs to accomplish, so it is not a pretty solution and may prevent some persons from being able to use the double click.
Of course the single click does only work with a delay of 250ms but its not possible to do it otherwise, you have to wait for the doubleClick somehow...
All of the answers here are overcomplicated, you just need to use e.detail:
<button onClick={e => {
if (e.detail === 1) handleClick();
if (e.detail === 2) handleDoubleClick();
}}>
Click me
</button>
A simple example that I have been doing.
File: withSupportDoubleClick.js
let timer
let latestTouchTap = { time: 0, target: null }
export default function withSupportDoubleClick({ onDoubleClick = () => {}, onSingleClick = () => {} }, maxDelay = 300) {
return (event) => {
clearTimeout(timer)
const touchTap = { time: new Date().getTime(), target: event.currentTarget }
const isDoubleClick =
touchTap.target === latestTouchTap.target && touchTap.time - latestTouchTap.time < maxDelay
latestTouchTap = touchTap
timer = setTimeout(() => {
if (isDoubleClick) onDoubleClick(event)
else onSingleClick(event)
}, maxDelay)
}
}
File: YourComponent.js
import React from 'react'
import withSupportDoubleClick from './withSupportDoubleClick'
export default const YourComponent = () => {
const handleClick = withSupportDoubleClick({
onDoubleClick: (e) => {
console.log('double click', e)
},
onSingleClick: (e) => {
console.log('single click', e)
},
})
return (
<div
className="cursor-pointer"
onClick={handleClick}
onTouchStart={handleClick}
tabIndex="0"
role="button"
aria-pressed="false"
>
Your content/button...
</div>
)
}
onTouchStart start is a touch event that fires when the user touches the element.
Why do you describe these events handler inside a render function? Try this approach:
const Component = extends React.Component {
constructor(props) {
super(props);
}
handleSingleClick = () => {
console.log('single click');
}
handleDoubleClick = () => {
console.log('double click');
}
render() {
return (
<div onClick={this.handleSingleClick} onDoubleClick={this.handleDoubleClick}>
</div>
);
}
};

Angular 2 Scroll to bottom (Chat style)

I have a set of single cell components within an ng-for loop.
I have everything in place but I cannot seem to figure out the proper
Currently I have
setTimeout(() => {
scrollToBottom();
});
But this doesn't work all the time as images asynchronously push the viewport down.
Whats the appropriate way to scroll to the bottom of a chat window in Angular 2?
I had the same problem, I'm using a AfterViewChecked and #ViewChild combination (Angular2 beta.3).
The Component:
import {..., AfterViewChecked, ElementRef, ViewChild, OnInit} from 'angular2/core'
#Component({
...
})
export class ChannelComponent implements OnInit, AfterViewChecked {
#ViewChild('scrollMe') private myScrollContainer: ElementRef;
ngOnInit() {
this.scrollToBottom();
}
ngAfterViewChecked() {
this.scrollToBottom();
}
scrollToBottom(): void {
try {
this.myScrollContainer.nativeElement.scrollTop = this.myScrollContainer.nativeElement.scrollHeight;
} catch(err) { }
}
}
The Template:
<div #scrollMe style="overflow: scroll; height: xyz;">
<div class="..."
*ngFor="..."
...>
</div>
</div>
Of course this is pretty basic. The AfterViewChecked triggers every time the view was checked:
Implement this interface to get notified after every check of your component's view.
If you have an input-field for sending messages for instance this event is fired after each keyup (just to give an example). But if you save whether the user scrolled manually and then skip the scrollToBottom() you should be fine.
Simplest and the best solution for this is :
Add this #scrollMe [scrollTop]="scrollMe.scrollHeight" simple thing on Template side
<div style="overflow: scroll; height: xyz;" #scrollMe [scrollTop]="scrollMe.scrollHeight">
<div class="..."
*ngFor="..."
...>
</div>
</div>
Here is the link for WORKING DEMO (With dummy chat app) AND FULL CODE
Will work with Angular2 and also upto 5, As above demo is done in
Angular5.
Note :
For error : ExpressionChangedAfterItHasBeenCheckedError
Please check your css,it's a issue of css side,not the Angular side
, One of the user #KHAN has solved that by removing overflow:auto; height: 100%; from div. (please check conversations for detail)
The accepted answer fires while scrolling through the messages, this avoids that.
You want a template like this.
<div #content>
<div #messages *ngFor="let message of messages">
{{message}}
</div>
</div>
Then you want to use a ViewChildren annotation to subscribe to new message elements being added to the page.
#ViewChildren('messages') messages: QueryList<any>;
#ViewChild('content') content: ElementRef;
ngAfterViewInit() {
this.scrollToBottom();
this.messages.changes.subscribe(this.scrollToBottom);
}
scrollToBottom = () => {
try {
this.content.nativeElement.scrollTop = this.content.nativeElement.scrollHeight;
} catch (err) {}
}
I added a check to see if the user tried to scroll up.
I'm just going to leave this here if anyone wants it :)
<div class="jumbotron">
<div class="messages-box" #scrollMe (scroll)="onScroll()">
<app-message [message]="message" [userId]="profile.userId" *ngFor="let message of messages.slice().reverse()"></app-message>
</div>
<textarea [(ngModel)]="newMessage" (keyup.enter)="submitMessage()"></textarea>
</div>
and the code:
import { AfterViewChecked, ElementRef, ViewChild, Component, OnInit } from '#angular/core';
import {AuthService} from "../auth.service";
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/concatAll';
import {Observable} from 'rxjs/Rx';
import { Router, ActivatedRoute } from '#angular/router';
#Component({
selector: 'app-messages',
templateUrl: './messages.component.html',
styleUrls: ['./messages.component.scss']
})
export class MessagesComponent implements OnInit {
#ViewChild('scrollMe') private myScrollContainer: ElementRef;
messages:Array<MessageModel>
newMessage = ''
id = ''
conversations: Array<ConversationModel>
profile: ViewMyProfileModel
disableScrollDown = false
constructor(private authService:AuthService,
private route:ActivatedRoute,
private router:Router,
private conversationsApi:ConversationsApi) {
}
ngOnInit() {
}
public submitMessage() {
}
ngAfterViewChecked() {
this.scrollToBottom();
}
private onScroll() {
let element = this.myScrollContainer.nativeElement
let atBottom = element.scrollHeight - element.scrollTop === element.clientHeight
if (this.disableScrollDown && atBottom) {
this.disableScrollDown = false
} else {
this.disableScrollDown = true
}
}
private scrollToBottom(): void {
if (this.disableScrollDown) {
return
}
try {
this.myScrollContainer.nativeElement.scrollTop = this.myScrollContainer.nativeElement.scrollHeight;
} catch(err) { }
}
}
Consider using
.scrollIntoView()
See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
If you want to be sure, that you are scrolling to the end after *ngFor is done, you can use this.
<div #myList>
<div *ngFor="let item of items; let last = last">
{{item.title}}
{{last ? scrollToBottom() : ''}}
</div>
</div>
scrollToBottom() {
this.myList.nativeElement.scrollTop = this.myList.nativeElement.scrollHeight;
}
Important here, the "last" variable defines if you are currently at the last item, so you can trigger the "scrollToBottom" method
this.contentList.nativeElement.scrollTo({left: 0 , top: this.contentList.nativeElement.scrollHeight, behavior: 'smooth'});
If you are in recent version of Angular, following is enough:
<div #scrollMe style="overflow: scroll; height: xyz;" [scrollTop]="scrollMe.scrollHeight>
<div class="..."
*ngFor="..."
...>
</div>
</div>
const element = document.getElementById('box');
element.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
Vivek's answer has worked for me, but resulted in an expression has changed after it was checked error. None of the comments worked for me, but what I did was change the change detection strategy.
import { Component, ChangeDetectionStrategy } from '#angular/core';
#Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'page1',
templateUrl: 'page1.html',
})
The title of the question mentions "Chat Style" scroll to bottom, which I also needed. None of these answers really satisfied me, because what I really wanted to do was scroll to the bottom of my div whenever child elements were added or destroyed. I ended up doing that with this very simple Directive that leverages the MutationObserver API
#Directive({
selector: '[pinScroll]',
})
export class PinScrollDirective implements OnInit, OnDestroy {
private observer = new MutationObserver(() => {
this.scrollToPin();
});
constructor(private el: ElementRef) {}
ngOnInit() {
this.observer.observe(this.el.nativeElement, {
childList: true,
});
}
ngOnDestroy() {
this.observer.disconnect();
}
private scrollToPin() {
this.el.nativeElement.scrollTop = this.el.nativeElement.scrollHeight;
}
}
You just attach this directive to your list element, and it will scroll to the bottom whenever a list item changes in the DOM. It's the behavior I was personally looking for. This directive assumes that you are already handling height and overflow rules on the list element.
Sharing my solution, because I was not completely satisfied with the rest. My problem with AfterViewChecked is that sometimes I'm scrolling up, and for some reason, this life hook gets called and it scrolls me down even if there were no new messages. I tried using OnChanges but this was an issue, which lead me to this solution. Unfortunately, using only DoCheck, it was scrolling down before the messages were rendered, which was not useful either, so I combined them so that DoCheck is basically indicating AfterViewChecked if it should call scrollToBottom.
Happy to receive feedback.
export class ChatComponent implements DoCheck, AfterViewChecked {
#Input() public messages: Message[] = [];
#ViewChild('scrollable') private scrollable: ElementRef;
private shouldScrollDown: boolean;
private iterableDiffer;
constructor(private iterableDiffers: IterableDiffers) {
this.iterableDiffer = this.iterableDiffers.find([]).create(null);
}
ngDoCheck(): void {
if (this.iterableDiffer.diff(this.messages)) {
this.numberOfMessagesChanged = true;
}
}
ngAfterViewChecked(): void {
const isScrolledDown = Math.abs(this.scrollable.nativeElement.scrollHeight - this.scrollable.nativeElement.scrollTop - this.scrollable.nativeElement.clientHeight) <= 3.0;
if (this.numberOfMessagesChanged && !isScrolledDown) {
this.scrollToBottom();
this.numberOfMessagesChanged = false;
}
}
scrollToBottom() {
try {
this.scrollable.nativeElement.scrollTop = this.scrollable.nativeElement.scrollHeight;
} catch (e) {
console.error(e);
}
}
}
chat.component.html
<div class="chat-wrapper">
<div class="chat-messages-holder" #scrollable>
<app-chat-message *ngFor="let message of messages" [message]="message">
</app-chat-message>
</div>
<div class="chat-input-holder">
<app-chat-input (send)="onSend($event)"></app-chat-input>
</div>
</div>
chat.component.sass
.chat-wrapper
display: flex
justify-content: center
align-items: center
flex-direction: column
height: 100%
.chat-messages-holder
overflow-y: scroll !important
overflow-x: hidden
width: 100%
height: 100%
Here's another good solution on stackblitz.
Alternatively:
The accepted answer is a good solution, but it can be improved since your content/chat may often scroll to the bottom involuntarily given how the ngAfterViewChecked() lifecycle hook works.
Here's an improved version...
COMPONENT
import {..., AfterViewChecked, ElementRef, ViewChild, OnInit} from 'angular2/core'
#Component({
...
})
export class ChannelComponent implements OnInit, AfterViewChecked {
#ViewChild('scrollMe') private myScrollContainer: ElementRef;
/**Add the variable**/
scrolledToBottom = false;
ngAfterViewChecked() {
this.scrollToBottom();
}
scrollToBottom(): void {
try {
/**Add the condition**/
if(!this.scrolledToBottom){
this.myScrollContainer.nativeElement.scrollTop = this.myScrollContainer.nativeElement.scrollHeight;
}
} catch(err) { }
}
/**Add the method**/
onScroll(){
this.scrolledToBottom = true;
}
}
TEMPLATE
<!--Add a scroll event listener-->
<div #scrollMe
style="overflow: scroll; height: xyz;"
(scroll)="onScroll()">
<div class="..."
*ngFor="..."
...>
</div>
</div>
In angular using material design sidenav I had to use the following:
let ele = document.getElementsByClassName('md-sidenav-content');
let eleArray = <Element[]>Array.prototype.slice.call(ele);
eleArray.map( val => {
val.scrollTop = val.scrollHeight;
});
In case anyone has this problem with Angular 9, this is how I manage to fix it.
I started with the solution with #scrollMe [scrollTop]="scrollMe.scrollHeight" and I got the ExpressionChangedAfterItHasBeenCheckedError error as people mentioned.
In order to fix this one I just add in my ts component:
#Component({
changeDetection: ChangeDetectionStrategy.OnPush,
...})
constructor(private cdref: ChangeDetectorRef) {}
ngAfterContentChecked() {
this.cdref.detectChanges();
}
ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'undefined'
After reading other solutions, the best solution I can think of, so you run only what you need is the following:
You use ngOnChanges to detect the proper change
ngOnChanges() {
if (changes.messages) {
let chng = changes.messages;
let cur = chng.currentValue;
let prev = chng.previousValue;
if(cur && prev) {
// lazy load case
if (cur[0].id != prev[0].id) {
this.lazyLoadHappened = true;
}
// new message
if (cur[cur.length -1].id != prev[prev.length -1].id) {
this.newMessageHappened = true;
}
}
}
}
And you use ngAfterViewChecked to actually enforce the change before it renders but after the full height is calculated
ngAfterViewChecked(): void {
if(this.newMessageHappened) {
this.scrollToBottom();
this.newMessageHappened = false;
}
else if(this.lazyLoadHappened) {
// keep the same scroll
this.lazyLoadHappened = false
}
}
If you are wondering how to implement scrollToBottom
#ViewChild('scrollWrapper') private scrollWrapper: ElementRef;
scrollToBottom(){
try {
this.scrollWrapper.nativeElement.scrollTop = this.scrollWrapper.nativeElement.scrollHeight;
} catch(err) { }
}
Just in case someone is using Ionic and Angular,
here is a link that uses a very simple code to do that king of scroll to bottom (or top) :
https://forum.ionicframework.com/t/scroll-content-to-top-bottom-using-ionic-4-solution/163048
To add smooth scroll do this
#scrollMe [scrollTop]="scrollMe.scrollHeight" style="scroll-behavior: smooth;"
and
this.cdref.detectChanges();

Resources