Skip to content

Commit

Permalink
fix: generate correct RTL placements for popper
Browse files Browse the repository at this point in the history
Generates correct popper placements for `<html dir="rtl">`
  • Loading branch information
fbasso authored and maxokorokov committed Aug 11, 2022
1 parent 9d80e66 commit 5379cd0
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 31 deletions.
6 changes: 4 additions & 2 deletions src/datepicker/datepicker-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {NgbDatepickerConfig} from './datepicker-config';
import {isString} from '../util/util';
import {Subject} from 'rxjs';
import {addPopperOffset} from '../util/positioning-util';
import {NgbRTL} from '../util/rtl';

/**
* A directive that allows to stick a datepicker popup to an input field.
Expand Down Expand Up @@ -79,7 +80,7 @@ export class NgbInputDatepicker implements OnChanges,
private _model: NgbDate | null = null;
private _inputValue: string;
private _zoneSubscription: any;
private _positioning = ngbPositioning();
private _positioning: ReturnType<typeof ngbPositioning>;
private _destroyCloseHandlers$ = new Subject<void>();

/**
Expand Down Expand Up @@ -294,11 +295,12 @@ export class NgbInputDatepicker implements OnChanges,
constructor(
private _parserFormatter: NgbDateParserFormatter, private _elRef: ElementRef<HTMLInputElement>,
private _vcRef: ViewContainerRef, private _renderer: Renderer2, private _ngZone: NgZone,
private _calendar: NgbCalendar, private _dateAdapter: NgbDateAdapter<any>,
private _calendar: NgbCalendar, private _dateAdapter: NgbDateAdapter<any>, rtl: NgbRTL,
@Inject(DOCUMENT) private _document: any, private _changeDetector: ChangeDetectorRef,
config: NgbInputDatepickerConfig) {
['autoClose', 'container', 'positionTarget', 'placement'].forEach(input => this[input] = config[input]);
this.popperOptions = config.popperOptions;
this._positioning = ngbPositioning(rtl);
}

registerOnChange(fn: (value: any) => any): void { this._onChange = fn; }
Expand Down
10 changes: 6 additions & 4 deletions src/dropdown/dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {take} from 'rxjs/operators';

import {Placement, PlacementArray, ngbPositioning} from '../util/positioning';
import {Options} from '@popperjs/core';
import {NgbRTL} from '../util/rtl';
import {addPopperOffset} from '../util/positioning-util';
import {ngbAutoClose, SOURCE} from '../util/autoclose';
import {Key} from '../util/key';
Expand Down Expand Up @@ -150,7 +151,7 @@ export class NgbDropdown implements AfterContentInit, OnChanges, OnDestroy {
private _destroyCloseHandlers$ = new Subject<void>();
private _zoneSubscription: Subscription;
private _bodyContainer: HTMLElement | null = null;
private _positioning = ngbPositioning();
private _positioning: ReturnType<typeof ngbPositioning>;

@ContentChild(NgbDropdownMenu, {static: false}) private _menu: NgbDropdownMenu;
@ContentChild(NgbDropdownAnchor, {static: false}) private _anchor: NgbDropdownAnchor;
Expand Down Expand Up @@ -227,14 +228,15 @@ export class NgbDropdown implements AfterContentInit, OnChanges, OnDestroy {
@Output() openChange = new EventEmitter<boolean>();

constructor(
private _changeDetector: ChangeDetectorRef, config: NgbDropdownConfig, @Inject(DOCUMENT) private _document: any,
private _ngZone: NgZone, private _elementRef: ElementRef<HTMLElement>, private _renderer: Renderer2,
@Optional() ngbNavbar: NgbNavbar) {
private _changeDetector: ChangeDetectorRef, _rtl: NgbRTL, config: NgbDropdownConfig,
@Inject(DOCUMENT) private _document: any, private _ngZone: NgZone, private _elementRef: ElementRef<HTMLElement>,
private _renderer: Renderer2, @Optional() ngbNavbar: NgbNavbar) {
this.placement = config.placement;
this.popperOptions = config.popperOptions;
this.container = config.container;
this.autoClose = config.autoClose;

this._positioning = ngbPositioning(_rtl);
this.display = ngbNavbar ? 'static' : 'dynamic';
}

Expand Down
6 changes: 4 additions & 2 deletions src/popover/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {listenToTriggers} from '../util/triggers';
import {ngbAutoClose} from '../util/autoclose';
import {ngbPositioning, PlacementArray} from '../util/positioning';
import {PopupService} from '../util/popup';
import {NgbRTL} from '../util/rtl';

import {NgbPopoverConfig} from './popover-config';
import {Options} from '@popperjs/core';
Expand Down Expand Up @@ -181,7 +182,7 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges {
private _popupService: PopupService<NgbPopoverWindow>;
private _windowRef: ComponentRef<NgbPopoverWindow>| null = null;
private _unregisterListenersFn;
private _positioning = ngbPositioning();
private _positioning: ReturnType<typeof ngbPositioning>;
private _zoneSubscription: Subscription;
private _isDisabled(): boolean {
if (this.disablePopover) {
Expand All @@ -194,7 +195,7 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges {
}

constructor(
private _elementRef: ElementRef<HTMLElement>, private _renderer: Renderer2, injector: Injector,
private _elementRef: ElementRef<HTMLElement>, _rtl: NgbRTL, private _renderer: Renderer2, injector: Injector,
viewContainerRef: ViewContainerRef, config: NgbPopoverConfig, private _ngZone: NgZone,
@Inject(DOCUMENT) private _document: any, private _changeDetector: ChangeDetectorRef,
applicationRef: ApplicationRef) {
Expand All @@ -208,6 +209,7 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges {
this.popoverClass = config.popoverClass;
this.openDelay = config.openDelay;
this.closeDelay = config.closeDelay;
this._positioning = ngbPositioning(_rtl);
this._popupService = new PopupService<NgbPopoverWindow>(
NgbPopoverWindow, injector, viewContainerRef, _renderer, this._ngZone, applicationRef);
}
Expand Down
6 changes: 4 additions & 2 deletions src/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {ngbAutoClose} from '../util/autoclose';
import {ngbPositioning, PlacementArray} from '../util/positioning';
import {PopupService} from '../util/popup';
import {Options} from '@popperjs/core';
import {NgbRTL} from '../util/rtl';

import {NgbTooltipConfig} from './tooltip-config';
import {Subscription} from 'rxjs';
Expand Down Expand Up @@ -155,11 +156,11 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges {
private _popupService: PopupService<NgbTooltipWindow>;
private _windowRef: ComponentRef<NgbTooltipWindow>| null = null;
private _unregisterListenersFn;
private _positioning = ngbPositioning();
private _positioning: ReturnType<typeof ngbPositioning>;
private _zoneSubscription: Subscription;

constructor(
private _elementRef: ElementRef<HTMLElement>, private _renderer: Renderer2, injector: Injector,
private _elementRef: ElementRef<HTMLElement>, _rtl: NgbRTL, private _renderer: Renderer2, injector: Injector,
viewContainerRef: ViewContainerRef, config: NgbTooltipConfig, private _ngZone: NgZone,
@Inject(DOCUMENT) private _document: any, private _changeDetector: ChangeDetectorRef,
applicationRef: ApplicationRef) {
Expand All @@ -175,6 +176,7 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges {
this.closeDelay = config.closeDelay;
this._popupService = new PopupService<NgbTooltipWindow>(
NgbTooltipWindow, injector, viewContainerRef, _renderer, this._ngZone, applicationRef);
this._positioning = ngbPositioning(_rtl);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/typeahead/typeahead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {Live} from '../util/accessibility/live';
import {ngbAutoClose} from '../util/autoclose';
import {Key} from '../util/key';
import {PopupService} from '../util/popup';
import {NgbRTL} from '../util/rtl';
import {PlacementArray, ngbPositioning} from '../util/positioning';
import {Options} from '@popperjs/core';
import {isDefined, toString} from '../util/util';
Expand Down Expand Up @@ -85,7 +86,7 @@ export class NgbTypeahead implements ControlValueAccessor,
private _resubscribeTypeahead: BehaviorSubject<any>;
private _windowRef: ComponentRef<NgbTypeaheadWindow>| null = null;
private _zoneSubscription: Subscription;
private _positioning = ngbPositioning();
private _positioning: ReturnType<typeof ngbPositioning>;

/**
* The value for the `autocomplete` attribute for the `<input>` element.
Expand Down Expand Up @@ -199,7 +200,7 @@ export class NgbTypeahead implements ControlValueAccessor,
private _onChange = (_: any) => {};

constructor(
private _elementRef: ElementRef<HTMLInputElement>, viewContainerRef: ViewContainerRef,
private _elementRef: ElementRef<HTMLInputElement>, _rtl: NgbRTL, viewContainerRef: ViewContainerRef,
private _renderer: Renderer2, injector: Injector, config: NgbTypeaheadConfig, ngZone: NgZone, private _live: Live,
@Inject(DOCUMENT) private _document: any, private _ngZone: NgZone, private _changeDetector: ChangeDetectorRef,
applicationRef: ApplicationRef) {
Expand All @@ -217,6 +218,7 @@ export class NgbTypeahead implements ControlValueAccessor,

this._popupService = new PopupService<NgbTypeaheadWindow>(
NgbTypeaheadWindow, injector, viewContainerRef, _renderer, this._ngZone, applicationRef);
this._positioning = ngbPositioning(_rtl);
}

ngOnInit(): void { this._subscribeToUserInput(); }
Expand Down
43 changes: 39 additions & 4 deletions src/util/positioning.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {getBootstrapBaseClassPlacement, getPopperClassPlacement, ngbPositioning, Placement} from './positioning';
import {Placement as PopperPlacement} from '@popperjs/core';
import {NgbRTL} from './rtl';

describe('positioning', () => {

/**
Expand Down Expand Up @@ -30,6 +32,34 @@ describe('positioning', () => {
'right-bottom': 'right-end'
};

/**
* Object of bootstrap classes for the keys, and their corresponding popper ones in the values, for RTL
*/
const matchingBootstrapPopperPlacementsRTL = {
'top': 'top',
'bottom': 'bottom',
'start': 'right',
'left': 'left',
'end': 'left',
'right': 'right',
'top-start': 'top-end',
'top-left': 'top-start',
'top-end': 'top-start',
'top-right': 'top-end',
'bottom-start': 'bottom-end',
'bottom-left': 'bottom-start',
'bottom-end': 'bottom-start',
'bottom-right': 'bottom-end',
'start-top': 'right-start',
'left-top': 'left-start',
'start-bottom': 'right-end',
'left-bottom': 'left-end',
'end-top': 'left-start',
'right-top': 'right-start',
'end-bottom': 'left-end',
'right-bottom': 'right-end'
};

const matchingPopperBootstrapPlacements = {
'top': 'top',
'bottom': 'bottom',
Expand All @@ -46,14 +76,19 @@ describe('positioning', () => {
};

it('should convert bootstrap classes to popper classes', () => {

for (const[bsClass, popperClass] of Object.entries(matchingBootstrapPopperPlacements)) {
expect(getPopperClassPlacement(bsClass as Placement)).toBe(popperClass);
expect(getPopperClassPlacement(bsClass as Placement, false))
.toBe(popperClass, `failed conversion for ${bsClass}`);
}
});

it('should convert popper classes to bootstrap classes', () => {
it('should convert bootstrap classes to popper classes (RTL)', () => {
for (const[bsClass, popperClass] of Object.entries(matchingBootstrapPopperPlacementsRTL)) {
expect(getPopperClassPlacement(bsClass as Placement, true)).toBe(popperClass, `failed conversion for ${bsClass}`);
}
});

it('should convert popper classes to bootstrap classes', () => {
for (const[popperClass, bsClass] of Object.entries(matchingPopperBootstrapPlacements)) {
expect(getBootstrapBaseClassPlacement('', popperClass as PopperPlacement)).toBe(bsClass);
}
Expand Down Expand Up @@ -84,7 +119,7 @@ describe('positioning', () => {
['top', 'bs-base-top']
];

const positioning = ngbPositioning();
const positioning = ngbPositioning({ isRTL: () => false } as NgbRTL);
const options = {
targetElement: document.createElement('div'),
hostElement: document.createElement('div'),
Expand Down
58 changes: 43 additions & 15 deletions src/util/positioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,47 @@ import {
preventOverflow,
Options,
} from '@popperjs/core';
import {NgbRTL} from './rtl';

const placementSeparator = /\s+/;
const spacesRegExp = / +/gi;

const startPrimaryPlacement = /^start/;
const endPrimaryPlacement = /^end/;
const startSecondaryPlacement = /-(top|left)$/;
const endSecondaryPlacement = /-(bottom|right)$/;
export function getPopperClassPlacement(placement: Placement): PopperPlacement {
const newPlacement = placement.replace(startPrimaryPlacement, 'left')
.replace(endPrimaryPlacement, 'right')
.replace(startSecondaryPlacement, '-start')
.replace(endSecondaryPlacement, '-end') as PopperPlacement;
return newPlacement;
/**
* Matching classes from the Bootstrap ones to the poppers ones.
* The first index of each array is used for the left to right direction,
* the second one is used for the right to left, defaulting to the first index (when LTR and RTL lead to the same class)
*
* See [Bootstrap alignments](https://getbootstrap.com/docs/5.1/components/dropdowns/#alignment-options)
* and [Popper placements](https://popper.js.org/docs/v2/constructors/#options)
*/
const bootstrapPopperMatches = {
'top': ['top'],
'bottom': ['bottom'],
'start': ['left', 'right'],
'left': ['left'],
'end': ['right', 'left'],
'right': ['right'],
'top-start': ['top-start', 'top-end'],
'top-left': ['top-start'],
'top-end': ['top-end', 'top-start'],
'top-right': ['top-end'],
'bottom-start': ['bottom-start', 'bottom-end'],
'bottom-left': ['bottom-start'],
'bottom-end': ['bottom-end', 'bottom-start'],
'bottom-right': ['bottom-end'],
'start-top': ['left-start', 'right-start'],
'left-top': ['left-start'],
'start-bottom': ['left-end', 'right-end'],
'left-bottom': ['left-end'],
'end-top': ['right-start', 'left-start'],
'right-top': ['right-start'],
'end-bottom': ['right-end', 'left-end'],
'right-bottom': ['right-end'],
};

export function getPopperClassPlacement(placement: Placement, isRTL: boolean): PopperPlacement {
const[leftClass, rightClass] = bootstrapPopperMatches[placement];
return isRTL ? (rightClass || leftClass) : leftClass;
}

const popperStartPrimaryPlacement = /^left/;
Expand Down Expand Up @@ -56,7 +83,7 @@ export function getBootstrapBaseClassPlacement(baseClass: string, placement: Pop
* 'start-top', 'start-bottom',
* 'end-top', 'end-bottom'.
* */
export function getPopperOptions({placement, baseClass}: PositioningOptions): Partial<Options> {
export function getPopperOptions({placement, baseClass}: PositioningOptions, rtl: NgbRTL): Partial<Options> {
let placementVals: Array<Placement> =
Array.isArray(placement) ? placement : placement.split(placementSeparator) as Array<Placement>;

Expand All @@ -76,7 +103,8 @@ export function getPopperOptions({placement, baseClass}: PositioningOptions): Pa
});
}

const popperPlacements = placementVals.map((_placement) => { return getPopperClassPlacement(_placement); });
const popperPlacements =
placementVals.map((_placement) => { return getPopperClassPlacement(_placement, rtl.isRTL()); });

let mainPlacement = popperPlacements.shift();

Expand Down Expand Up @@ -148,14 +176,14 @@ interface PositioningOptions {
function noop(arg) {
return arg;
}
export function ngbPositioning() {
export function ngbPositioning(rtl: NgbRTL) {
let popperInstance: Instance | null = null;

return {
createPopper(positioningOption: PositioningOptions) {
if (!popperInstance) {
const updatePopperOptions = positioningOption.updatePopperOptions || noop;
let popperOptions = updatePopperOptions(getPopperOptions(positioningOption));
let popperOptions = updatePopperOptions(getPopperOptions(positioningOption, rtl));
popperInstance =
createPopperLite(positioningOption.hostElement, positioningOption.targetElement, popperOptions);
}
Expand All @@ -168,7 +196,7 @@ export function ngbPositioning() {
setOptions(positioningOption: PositioningOptions) {
if (popperInstance) {
const updatePopperOptions = positioningOption.updatePopperOptions || noop;
let popperOptions = updatePopperOptions(getPopperOptions(positioningOption));
let popperOptions = updatePopperOptions(getPopperOptions(positioningOption, rtl));
popperInstance.setOptions(popperOptions);
}
},
Expand Down
11 changes: 11 additions & 0 deletions src/util/rtl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {Injectable, Inject} from '@angular/core';
import {DOCUMENT} from '@angular/common';

@Injectable({providedIn: 'root'})
export class NgbRTL {
private _element: HTMLHtmlElement;

constructor(@Inject(DOCUMENT) document: any) { this._element = document.documentElement; }

isRTL() { return (this._element.getAttribute('dir') || '').toLowerCase() === 'rtl'; }
}

0 comments on commit 5379cd0

Please sign in to comment.