This is my current attempt at a MutationObserver
based solution.
UserApp
is a component placed toward the top of the application hierarchy.
The Popover
class is (over) used in various places in my application for a bunch of stuff.
The possibility of infinite recursion caused by firing popover("update")
from a MutationObserver
event makes me wary of using this solution long term.
It seems to do the job for now, but this is one of the things uni-directional binding is meant to avoid.
On the plus side, this works even when you have non-react components in your application (like for example, the Bootstrap navbar
).
export class UserApp extends React.Component<any, AppState> {
public domChangeObservers = $.Callbacks();
public mutationObserver = new MutationObserver(
(mutations: MutationRecord[])=>{
// premature optimisation?
// I figure I don't care about each individual change, if the browser
// batched em up, just fire on the last one.
// But is this a good idea given we have to inspect the mutation in order
// to avoid recursive loops?
this.domChangeObservers.fire(mutations[mutations.length-1]);
}
);
constructor(props: any) {
super(props);
this.mutationObserver.observe(document.documentElement, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true
});
}
componentWillUnmount(){
this.mutationObserver.disconnect();
}
...
}
const DefaultTrigger = "click";
export interface PopoverProps{
popoverTitle: string | Element | Function;
popoverContent: string | Element | Function;
/** Set to "focus" to get "dismiss on next click anywhere" behaviour */
popoverTrigger?: string;
/** Leaving it empty means that the popover gets created
* as a child of the anchor (whatever you use as the child of the popover).
* Setting this to "body" means the popover gets created out on the body
* of the document.
* "body" can help with stuff like when the popover ends up
* being clipped or "under" other components (because of stuff like
* `overflow:hidden`).
*/
container?: string;
allowDefaultClickHandling?: boolean;
ignoreDomChanges?: boolean;
id?: string;
}
export class Popover
extends PureComponent<PopoverProps, object> {
// ! to hack around TS 2.6 "strictPropertyInitialization"
// figure out the right way... one day
selfRef!: HTMLSpanElement;
onDomChange = (mutation:MutationRecord)=>{
/*
- popover("update") causes DOM changes which fire this handler again,
so we need to guard against infinite recursion of DOM change events.
- popover("update") is async, so we can't just use an "if not currently
handling a mutation" flag, because the order of events ends up being:
onDomChange() -> flag=true -> popover("update") -> flag=false ->
popper.js changes DOM -> onDomChange() called again -> repeat forever
- Can't just detect *this* popover. If DOM event occurs because popovers
overlay each other they will recurse alternately - i.e. pop1 update
call makes DOM changes for pop2, pop2 update makes changes for pop1,
repeat forever.
*/
if( Popover.isPopoverNode(mutation) ){
return;
}
/*
- tell popper.js to reposition the popover
- probably not necessary if popover is not showing, but I duuno how to tell
*/
$(this.selfRef).popover("update");
};
private static isPopoverNode(mutation: MutationRecord){
/*
Had a good attempt that used the structure of the mutation target to
see if it's parent element was defined as `data-toggle="popover"`; but
that fails when you set the `container` prop to some other element -
especially, "body", see the comments on the Props .
*/
if( mutation.target.nodeType != 1 ){
return false;
}
// Is Element
let element = mutation.target as Element;
/*
Is the mutation target a popover element?
As defined by its use of the Bootstrap "popover" class.
This is dodgy, it relies on Bootstrap always creating a container
element that has the "popover" class assigned.
BS could change their classname, or they could
change how they structure their popover, or some other
random widget could use the name.
Actually, this can be controlled by overriding the popover template,
which I will do... later.
*/
let isPopoverNode = element.classList.contains("popover");
// very helpful when debugging - easy to tell if recursion is happening
// by looking at the log
// console.log("target", isPopoverNode, mutation, mutation.target );
return isPopoverNode;
}
componentDidMount(): void{
// the popover() method is a "JQuery plugin" thing,
// that's how Bootstrap does its stuff
$(this.selfRef).popover({
container: this.props.container || this.selfRef,
placement: "auto",
title: this.props.popoverTitle,
content: this.props.popoverContent,
trigger: this.props.popoverTrigger || DefaultTrigger,
});
if( !this.props.ignoreDomChanges ){
UserApp.instance.domChangeObservers.add(this.onDomChange);
}
}
componentWillUnmount(): void {
if( !this.props.ignoreDomChanges ){
UserApp.instance.domChangeObservers.remove(this.onDomChange);
}
// - without this, if this component or any parent is unmounted,
// popper.js doesn't know that and the popover content just becomes
// orphaned
$(this.selfRef).popover("dispose");
}
stopClick = (e: SyntheticEvent<any>) =>{
if( !this.props.allowDefaultClickHandling ){
// without this, if the child element is an <a> or similar, clicking it
// to show/dismiss the popup will scroll the content
e.preventDefault();
e.stopPropagation();
}
};
render(){
let popoverTrigger = this.props.popoverTrigger || DefaultTrigger;
// tabIndex is necessary when using "trigger=focus" to get
// "dismiss on next click" behaviour.
let tabIndex = popoverTrigger.indexOf("focus")>=0?0:undefined;
return <span id={this.props.id}
tabIndex={tabIndex}
ref={(ref)=>{if(ref) this.selfRef = ref}}
data-toggle="popover"
onClick={this.stopClick}
>{this.props.children}</span>;
}
}