diff --git a/packages/ckeditor5-editor-classic/tests/manual/stickypanel.html b/packages/ckeditor5-editor-classic/tests/manual/stickypanel.html new file mode 100644 index 00000000000..384c0b613e6 --- /dev/null +++ b/packages/ckeditor5-editor-classic/tests/manual/stickypanel.html @@ -0,0 +1,97 @@ + + + + +
Viewport offset
+ +
+
+
+

Heading 1

+

Paragraph

+

Bold Italic Link

+
    +
  • UL List item 1
  • +
  • UL List item 2
  • +
+
    +
  1. OL List item 1
  2. +
  3. OL List item 2
  4. +
+
+ bar +
Caption
+
+
+

Quote

+
    +
  • Quoted UL List item 1
  • +
  • Quoted UL List item 2
  • +
+

Quote

+
+
+
+
+ +

Unrelated scrollable container for testing.

+
+ +

Heading 1

+

Paragraph

+

Bold Italic Link

+ +
    +
  1. OL List item 1
  2. +
  3. OL List item 2
  4. +
+
+ bar +
Caption
+
+
+

Quote

+ +

Quote

+
+
diff --git a/packages/ckeditor5-editor-classic/tests/manual/stickypanel.js b/packages/ckeditor5-editor-classic/tests/manual/stickypanel.js new file mode 100644 index 00000000000..8077ed0bec7 --- /dev/null +++ b/packages/ckeditor5-editor-classic/tests/manual/stickypanel.js @@ -0,0 +1,54 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import ClassicEditor from '../../src/classiceditor'; + +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ ArticlePluginSet ], + toolbar: [ + 'heading', + '|', + 'bold', + 'italic', + 'link', + 'bulletedList', + 'numberedList', + '|', + 'outdent', + 'indent', + '|', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + 'undo', + 'redo' + ], + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + ui: { + viewportOffset: { + top: 50 + } + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-editor-classic/tests/manual/stickypanel.md b/packages/ckeditor5-editor-classic/tests/manual/stickypanel.md new file mode 100644 index 00000000000..0981b035832 --- /dev/null +++ b/packages/ckeditor5-editor-classic/tests/manual/stickypanel.md @@ -0,0 +1,12 @@ +# Sticky panel + +Verify the behavior of the sticky panel inside the overflowing (scrollable) containers. +Best to run with `--debug=stickypanel` flag. + +Tests: +* Panel should stick only when it's active. +* Panel should not change its state when scrolling the element that isn't its ancestor (drawn rects will disappear though). +* Sticky toolbar inside the inner container. +* Sticking the toolbar when scrolling the outer container. +* Top viewport offset should be taken into account when scrolling the window. +* Panel should stick to the bottom of the container when it's near end. diff --git a/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts b/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts index 5e131e69fdc..0d4803d5c37 100644 --- a/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts +++ b/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts @@ -16,9 +16,13 @@ import { global, toUnit, type Locale, - type ObservableChangeEvent + type ObservableChangeEvent, + findClosestScrollableAncestor, + Rect } from '@ckeditor/ckeditor5-utils'; +// @if CK_DEBUG_STICKYPANEL // const RectDrawer = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ).default + import '../../../theme/components/panel/stickypanel.css'; const toPx = toUnit( 'px' ); @@ -107,29 +111,31 @@ export default class StickyPanelView extends View { * @readonly * @observable */ - declare public _isStickyToTheLimiter: boolean; + declare public _isStickyToTheBottomOfLimiter: boolean; + + /** + * The DOM bounding client rect of the {@link module:ui/view~View#element} of the panel. + */ + private _panelRect?: Rect; /** - * Set `true` if the sticky panel uses the {@link #viewportTopOffset}, - * i.e. not {@link #_isStickyToTheLimiter} and the {@link #viewportTopOffset} - * is not `0`. + * The `top` CSS position of the panel when it is sticky to the top of the viewport or scrollable + * ancestors of the {@link #limiterElement}. * * @private * @readonly * @observable */ - declare public _hasViewportTopOffset: boolean; + declare public _stickyTopOffset: number | null; /** - * The DOM bounding client rect of the {@link module:ui/view~View#element} of the panel. - */ - private _panelRect?: DOMRect; - - /** - * The DOM bounding client rect of the {@link #limiterElement} - * of the panel. + * The `bottom` CSS position of the panel when it is sticky to the bottom of the {@link #limiterElement}. + * + * @private + * @readonly + * @observable */ - private _limiterRect?: DOMRect; + declare public _stickyBottomOffset: number | null; /** * A dummy element which visually fills the space as long as the @@ -158,8 +164,10 @@ export default class StickyPanelView extends View { this.set( 'viewportTopOffset', 0 ); this.set( '_marginLeft', null ); - this.set( '_isStickyToTheLimiter', false ); - this.set( '_hasViewportTopOffset', false ); + this.set( '_isStickyToTheBottomOfLimiter', false ); + + this.set( '_stickyTopOffset', null ); + this.set( '_stickyBottomOffset', null ); this.content = this.createCollection(); @@ -188,20 +196,15 @@ export default class StickyPanelView extends View { 'ck-sticky-panel__content', // Toggle class of the panel when "sticky" state changes in the view. bind.if( 'isSticky', 'ck-sticky-panel__content_sticky' ), - bind.if( '_isStickyToTheLimiter', 'ck-sticky-panel__content_sticky_bottom-limit' ) + bind.if( '_isStickyToTheBottomOfLimiter', 'ck-sticky-panel__content_sticky_bottom-limit' ) ], style: { width: bind.to( 'isSticky', isSticky => { return isSticky ? toPx( this._contentPanelPlaceholder.getBoundingClientRect().width ) : null; } ), - top: bind.to( '_hasViewportTopOffset', _hasViewportTopOffset => { - return _hasViewportTopOffset ? toPx( this.viewportTopOffset ) : null; - } ), - - bottom: bind.to( '_isStickyToTheLimiter', _isStickyToTheLimiter => { - return _isStickyToTheLimiter ? toPx( this.limiterBottomOffset ) : null; - } ), + top: bind.to( '_stickyTopOffset', value => value ? toPx( value ) : value ), + bottom: bind.to( '_stickyBottomOffset', value => value ? toPx( value ) : value ), marginLeft: bind.to( '_marginLeft' ) } @@ -232,56 +235,209 @@ export default class StickyPanelView extends View { super.render(); // Check if the panel should go into the sticky state immediately. - this._checkIfShouldBeSticky(); + this.checkIfShouldBeSticky(); - // Update sticky state of the panel as the window is being scrolled. - this.listenTo( global.window, 'scroll', () => { - this._checkIfShouldBeSticky(); - } ); + // Update sticky state of the panel as the window and ancestors are being scrolled. + this.listenTo( global.document, 'scroll', ( evt, data ) => { + this.checkIfShouldBeSticky( data.target as HTMLElement | Document ); + }, { useCapture: true } ); // Synchronize with `model.isActive` because sticking an inactive panel is pointless. this.listenTo( this, 'change:isActive', () => { - this._checkIfShouldBeSticky(); + this.checkIfShouldBeSticky(); } ); } /** - * Analyzes the environment to decide whether the panel should - * be sticky or not. + * Analyzes the environment to decide whether the panel should be sticky or not. + * Then handles the positioning of the panel. + * + * @param [scrollTarget] The element which is being scrolled. */ - private _checkIfShouldBeSticky(): void { - const panelRect = this._panelRect = this._contentPanel.getBoundingClientRect(); - let limiterRect: DOMRect; + public checkIfShouldBeSticky( scrollTarget?: HTMLElement | Document ): void { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.clear(); + this._panelRect = new Rect( this._contentPanel ); + + if ( !this.limiterElement || !this.isActive ) { + this._unstick(); + + return; + } - if ( !this.limiterElement ) { - this.isSticky = false; + const scrollableAncestors = _getScrollableAncestors( this.limiterElement ); + + if ( scrollTarget && !scrollableAncestors.includes( scrollTarget ) ) { + return; + } + + const visibleAncestorsRect = _getVisibleAncestorsRect( scrollableAncestors, this.viewportTopOffset ); + const limiterRect = new Rect( this.limiterElement ); + + // @if CK_DEBUG_STICKYPANEL // if ( visibleAncestorsRect ) { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleAncestorsRect, + // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '3px', opacity: '.8', outlineColor: 'red', outlineOffset: '-3px' }, + // @if CK_DEBUG_STICKYPANEL // 'Visible anc' + // @if CK_DEBUG_STICKYPANEL // ); + // @if CK_DEBUG_STICKYPANEL // } + // @if CK_DEBUG_STICKYPANEL // + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( limiterRect, + // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '3px', opacity: '.8', outlineColor: 'green', outlineOffset: '-3px' }, + // @if CK_DEBUG_STICKYPANEL // 'Limiter' + // @if CK_DEBUG_STICKYPANEL // ); + + // Stick the panel only if + // * the limiter's ancestors are intersecting with each other so that some of their rects are visible, + // * and the limiter's top edge is above the visible ancestors' top edge. + if ( visibleAncestorsRect && limiterRect.top < visibleAncestorsRect.top ) { + const visibleLimiterRect = limiterRect.getIntersection( visibleAncestorsRect ); + + // Sticky the panel only if the limiter's visible rect is at least partially visible in the + // visible ancestors' rects intersection. + if ( visibleLimiterRect ) { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleLimiterRect, + // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '3px', opacity: '.8', outlineColor: 'fuchsia', outlineOffset: '-3px', + // @if CK_DEBUG_STICKYPANEL // backgroundColor: 'rgba(255, 0, 255, .3)' }, + // @if CK_DEBUG_STICKYPANEL // 'Visible limiter' + // @if CK_DEBUG_STICKYPANEL // ); + + const visibleAncestorsTop = visibleAncestorsRect.top; + + // Check if there's a change the panel can be sticky to the bottom of the limiter. + if ( visibleAncestorsTop + this._panelRect.height + this.limiterBottomOffset > visibleLimiterRect.bottom ) { + const stickyBottomOffset = Math.max( limiterRect.bottom - visibleAncestorsRect.bottom, 0 ) + this.limiterBottomOffset; + // @if CK_DEBUG_STICKYPANEL // const stickyBottomOffsetRect = new Rect( { + // @if CK_DEBUG_STICKYPANEL // top: limiterRect.bottom - stickyBottomOffset, left: 0, right: 0, + // @if CK_DEBUG_STICKYPANEL // bottom: limiterRect.bottom - stickyBottomOffset, width: 1000, height: 1 + // @if CK_DEBUG_STICKYPANEL // } ); + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( stickyBottomOffsetRect, + // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '1px', opacity: '.8', outlineColor: 'black' }, + // @if CK_DEBUG_STICKYPANEL // 'Sticky bottom offset' + // @if CK_DEBUG_STICKYPANEL // ); + + // Check if sticking the panel to the bottom of the limiter does not cause it to suddenly + // move upwards if there's not enough space for it. + if ( limiterRect.bottom - stickyBottomOffset > limiterRect.top + this._panelRect.height ) { + this._stickToBottomOfLimiter( stickyBottomOffset ); + } else { + this._unstick(); + } + } else { + if ( this._panelRect.height + this.limiterBottomOffset < limiterRect.height ) { + this._stickToTopOfAncestors( visibleAncestorsTop ); + } else { + this._unstick(); + } + } + } else { + this._unstick(); + } } else { - limiterRect = this._limiterRect = this.limiterElement.getBoundingClientRect(); - - // The panel must be active to become sticky. - this.isSticky = this.isActive && - // The limiter's top edge must be beyond the upper edge of the visible viewport (+the viewportTopOffset). - limiterRect.top < this.viewportTopOffset && - // The model#limiterElement's height mustn't be smaller than the panel's height and model#limiterBottomOffset. - // There's no point in entering the sticky mode if the model#limiterElement is very, very small, because - // it would immediately set model#_isStickyToTheLimiter true and, given model#limiterBottomOffset, the panel - // would be positioned before the model#limiterElement. - this._panelRect.height + this.limiterBottomOffset < limiterRect.height; + this._unstick(); } - // Stick the panel to the top edge of the viewport simulating CSS position:sticky. - // TODO: Possibly replaced by CSS in the future http://caniuse.com/#feat=css-sticky - if ( this.isSticky ) { - this._isStickyToTheLimiter = - limiterRect!.bottom < panelRect.height + this.limiterBottomOffset + this.viewportTopOffset; - this._hasViewportTopOffset = !this._isStickyToTheLimiter && !!this.viewportTopOffset; - this._marginLeft = this._isStickyToTheLimiter ? null : toPx( -global.window.scrollX ); + // @if CK_DEBUG_STICKYPANEL // console.clear(); + // @if CK_DEBUG_STICKYPANEL // console.log( 'isSticky', this.isSticky ); + // @if CK_DEBUG_STICKYPANEL // console.log( '_isStickyToTheBottomOfLimiter', this._isStickyToTheBottomOfLimiter ); + // @if CK_DEBUG_STICKYPANEL // console.log( '_stickyTopOffset', this._stickyTopOffset ); + // @if CK_DEBUG_STICKYPANEL // console.log( '_stickyBottomOffset', this._stickyBottomOffset ); + } + + /** + * Sticks the panel at the given CSS `top` offset. + * + * @private + * @param topOffset + */ + private _stickToTopOfAncestors( topOffset: number ) { + this.isSticky = true; + this._isStickyToTheBottomOfLimiter = false; + this._stickyTopOffset = topOffset; + this._stickyBottomOffset = null; + this._marginLeft = toPx( -global.window.scrollX ); + } + + /** + * Sticks the panel at the bottom of the limiter with a given CSS `bottom` offset. + * + * @private + * @param stickyBottomOffset + */ + private _stickToBottomOfLimiter( stickyBottomOffset: number ) { + this.isSticky = true; + this._isStickyToTheBottomOfLimiter = true; + this._stickyTopOffset = null; + this._stickyBottomOffset = stickyBottomOffset; + this._marginLeft = toPx( -global.window.scrollX ); + } + + /** + * Unsticks the panel putting it back to its original position. + * + * @private + */ + private _unstick() { + this.isSticky = false; + this._isStickyToTheBottomOfLimiter = false; + this._stickyTopOffset = null; + this._stickyBottomOffset = null; + this._marginLeft = null; + } +} + +// Loops over the given element's ancestors to find all the scrollable elements. +// +// @private +// @param element +// @returns Array An array of scrollable element's ancestors. +function _getScrollableAncestors( element: HTMLElement ) { + const scrollableAncestors = []; + let scrollableAncestor = findClosestScrollableAncestor( element ); + + while ( scrollableAncestor && scrollableAncestor !== global.document.body ) { + scrollableAncestors.push( scrollableAncestor ); + scrollableAncestor = findClosestScrollableAncestor( scrollableAncestor! ); + } + + scrollableAncestors.push( global.document ); + + return scrollableAncestors; +} + +// Calculates the intersection rectangle of the given element and its scrollable ancestors (including window). +// Also, takes into account the passed viewport top offset. +// +// @private +// @param scrollableAncestors +// @param viewportTopOffset +// @returns Rect +function _getVisibleAncestorsRect( scrollableAncestors: Array, viewportTopOffset: number ) { + const scrollableAncestorsRects = scrollableAncestors.map( ancestor => { + // The document (window) is yet another scrollable ancestor, but cropped by the top offset. + if ( ancestor instanceof Document ) { + const windowRect = new Rect( global.window ); + + windowRect.top += viewportTopOffset; + windowRect.height -= viewportTopOffset; + + return windowRect; + } else { + return new Rect( ancestor ); } - // Detach the panel from the top edge of the viewport. - else { - this._isStickyToTheLimiter = false; - this._hasViewportTopOffset = false; - this._marginLeft = null; + } ); + + let scrollableAncestorsIntersectionRect: Rect | null = scrollableAncestorsRects[ 0 ]; + + // @if CK_DEBUG_STICKYPANEL // for ( const scrollableAncestorRect of scrollableAncestorsRects ) { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( scrollableAncestorRect, { + // @if CK_DEBUG_STICKYPANEL // outlineWidth: '1px', opacity: '.7', outlineStyle: 'dashed' + // @if CK_DEBUG_STICKYPANEL // }, 'Scrollable ancestor' ); + // @if CK_DEBUG_STICKYPANEL // } + + for ( const scrollableAncestorRect of scrollableAncestorsRects.slice( 1 ) ) { + if ( scrollableAncestorsIntersectionRect ) { + scrollableAncestorsIntersectionRect = scrollableAncestorsIntersectionRect.getIntersection( scrollableAncestorRect ); } } + + return scrollableAncestorsIntersectionRect; } diff --git a/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.html b/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.html index a8cf3d6c35e..9c15684b9f6 100644 --- a/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.html +++ b/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.html @@ -4,15 +4,15 @@

Sticky to the top of the viewport

-

- An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. +

+ Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter.

@@ -22,20 +22,41 @@

Sticky to the green box

-

- An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. - An editable content mock–up. +

+ Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter.

-
The panel should stick to me instead of the viewport.
+
The panel should stick to me instead of the viewport.
+ + +
+

In scrollable ancestors

+ +
+
+
+
+

+ Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. + Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. Panel limiter. +

+
+
+
+ + +
The panel should stick to me instead of the viewport.
@@ -46,13 +67,22 @@

Sticky to the green box

padding-top: 150px; } + .ck-reset { + font-size: 10px; + } + + h2 { + font-size: 18px; + } + #columns { overflow: hidden; + margin-top: 50px; } .column { float: left; - width: 350px; + width: 300px; padding: 1em; } @@ -68,7 +98,10 @@

Sticky to the green box

} .ck-sticky-panel__content:after { - content: "A sticky panel mock–up."; + content: "Sticky panel."; + text-align: center; + width: 100%; + display: block; } .offset-visualizer { @@ -78,15 +111,16 @@

Sticky to the green box

bottom: 0; background: red; color: #fff; + opacity: .8; } .offset-visualizer:after { content: "An offset mock–up. Toolbar should *never* cover the red area."; } - #fixed { + .fixed { height: 100px; - width: 350px; + width: 300px; position: fixed; top: 0; z-index: 9999; @@ -96,4 +130,25 @@

Sticky to the green box

box-sizing: border-box; padding: 30px; } + + .scrollable { + border: 1px solid #aaa; + padding: 30px; + padding-bottom: 200px; + box-sizing: border-box; + height: 150px; + overflow: auto; + } + + .scrollable > .scrollable { + margin-bottom: 200px; + } + + .scrollable > .ck-reset { + margin-bottom: 100px; + } + + .limiter { + overflow: hidden; + } diff --git a/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.js b/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.js index 6d206bb807a..b0bf8dffdd6 100644 --- a/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.js +++ b/packages/ckeditor5-ui/tests/manual/panel/sticky/stickypanelview.js @@ -8,13 +8,13 @@ import StickyPanelView from '../../../../src/panel/sticky/stickypanelview'; const ui = testUtils.createTestUIView( { stickyToTheTop: '.ck-sticky_to-the-top .ck-editor__top', - stickyToTheBox: '.ck-sticky_to-the-box .ck-editor__top' + stickyToTheBox: '.ck-sticky_to-the-box .ck-editor__top', + stickyWithScrollableAncestors: '.ck-sticky_with-scrollable-ancestors .ck-editor__top' } ); createStickyPanel( ui.stickyToTheTop ); -const stickyToTheBoxTPanel = createStickyPanel( ui.stickyToTheBox ); - -stickyToTheBoxTPanel.viewportTopOffset = 100; +createStickyPanel( ui.stickyToTheBox ).viewportTopOffset = 100; +createStickyPanel( ui.stickyWithScrollableAncestors ).viewportTopOffset = 100; function createStickyPanel( collection ) { const panel = new StickyPanelView(); diff --git a/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js b/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js index c600dc4f348..3ead2c3bd56 100644 --- a/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js +++ b/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals document */ +/* globals document, Event */ import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import StickyPanelView from '../../../src/panel/sticky/stickypanelview'; @@ -11,10 +11,9 @@ import View from '../../../src/view'; import LabelView from '../../../src/label/labelview'; import ViewCollection from '../../../src/viewcollection'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; describe( 'StickyPanelView', () => { - let view, element, contentElement, placeholderElement, limiterElement, locale, windowStub; + let view, element, contentElement, placeholderElement, limiterElement, locale; testUtils.createSinonSandbox(); @@ -29,18 +28,8 @@ describe( 'StickyPanelView', () => { contentElement = view.element.lastChild; placeholderElement = view.element.firstChild; - // Dummy values just to let non–geometrical tests pass without reference errors. - view._toolbarRect = { top: 10, right: 20, bottom: 30, left: 40, width: 50, height: 60 }; - view._limiterRect = { top: 5, right: 10, bottom: 15, left: 20, width: 25, height: 30 }; - - windowStub = Object.create( DomEmitterMixin ); - - Object.assign( windowStub, { - scrollX: 0, - scrollY: 0 - } ); - - testUtils.sinon.stub( global, 'window' ).value( windowStub ); + sinon.stub( global.window, 'innerWidth' ).value( 1000 ); + sinon.stub( global.window, 'innerHeight' ).value( 500 ); document.body.appendChild( element ); } ); @@ -69,14 +58,16 @@ describe( 'StickyPanelView', () => { } ); it( 'sets view attributes', () => { + expect( view.isActive ).to.be.false; expect( view.isSticky ).to.be.false; expect( view.limiterElement ).to.be.null; expect( view.limiterBottomOffset ).to.equal( 50 ); expect( view.viewportTopOffset ).to.equal( 0 ); - expect( view._isStickyToTheLimiter ).to.be.false; - expect( view._hasViewportTopOffset ).to.be.false; expect( view._marginLeft ).to.be.null; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; + expect( view._stickyTopOffset ).to.be.null; + expect( view._stickyBottomOffset ).to.be.null; } ); it( 'accepts the locale', () => { @@ -101,25 +92,25 @@ describe( 'StickyPanelView', () => { expect( contentElement.classList.contains( 'ck-sticky-panel__content_sticky' ) ).to.be.true; } ); - it( 'update the class on view#_isStickyToTheLimiter change', () => { - view._isStickyToTheLimiter = false; + it( 'update the class on view#_isStickyToTheBottomOfLimiter change', () => { + view._isStickyToTheBottomOfLimiter = false; expect( contentElement.classList.contains( 'ck-sticky-panel__content_sticky_bottom-limit' ) ).to.be.false; - view._isStickyToTheLimiter = true; + view._isStickyToTheBottomOfLimiter = true; expect( contentElement.classList.contains( 'ck-sticky-panel__content_sticky_bottom-limit' ) ).to.be.true; } ); - it( 'update the styles.top on view#_hasViewportTopOffset change', () => { + it( 'update the style.top on view#_stickyTopOffset change', () => { view.viewportTopOffset = 100; - view._hasViewportTopOffset = false; - expect( contentElement.style.top ).to.equal( '' ); + view._stickyTopOffset = 0; + expect( contentElement.style.top ).to.equal( '0px' ); - view._hasViewportTopOffset = true; + view._stickyTopOffset = 100; expect( contentElement.style.top ).to.equal( '100px' ); } ); - it( 'update the styles.width on view#isSticky change', () => { + it( 'update the style.width on view#isSticky change', () => { testUtils.sinon.stub( view._contentPanelPlaceholder, 'getBoundingClientRect' ).returns( { width: 100 } ); view.isSticky = false; @@ -129,15 +120,15 @@ describe( 'StickyPanelView', () => { expect( contentElement.style.width ).to.equal( '100px' ); } ); - it( 'update the styles.bottom on view#_isStickyToTheLimiter change', () => { - view._isStickyToTheLimiter = false; - expect( contentElement.style.bottom ).to.equal( '' ); + it( 'update the style.bottom on view#_stickyBottomOffset change', () => { + view._stickyBottomOffset = 0; + expect( contentElement.style.bottom ).to.equal( '0px' ); - view._isStickyToTheLimiter = true; + view._stickyBottomOffset = 50; expect( contentElement.style.bottom ).to.equal( '50px' ); } ); - it( 'update the styles.marginLeft on view#marginLeft change', () => { + it( 'update the style.marginLeft on view#marginLeft change', () => { view._marginLeft = '30px'; expect( contentElement.style.marginLeft ).to.equal( '30px' ); @@ -151,7 +142,7 @@ describe( 'StickyPanelView', () => { view.limiterElement = limiterElement; } ); - it( 'update the styles.display on view#isSticky change', () => { + it( 'update the style.display on view#isSticky change', () => { view.isSticky = false; expect( placeholderElement.style.display ).to.equal( 'none' ); @@ -159,7 +150,7 @@ describe( 'StickyPanelView', () => { expect( placeholderElement.style.display ).to.equal( 'block' ); } ); - it( 'update the styles.height on view#isSticky change', () => { + it( 'update the style.height on view#isSticky change', () => { view._panelRect = { height: 50 }; view.isSticky = false; @@ -201,28 +192,31 @@ describe( 'StickyPanelView', () => { } ); it( 'checks if the panel should be sticky', () => { - const spy = testUtils.sinon.spy( view, '_checkIfShouldBeSticky' ); + const spy = testUtils.sinon.spy( view, 'checkIfShouldBeSticky' ); expect( spy.notCalled ).to.be.true; view.render(); expect( spy.calledOnce ).to.be.true; } ); - it( 'listens to window#scroll event and calls view._checkIfShouldBeSticky', () => { - const spy = testUtils.sinon.spy( view, '_checkIfShouldBeSticky' ); + it( 'listens to document#scroll event and calls view.checkIfShouldBeSticky()', () => { + const spy = testUtils.sinon.spy( view, 'checkIfShouldBeSticky' ); expect( spy.notCalled ).to.be.true; view.render(); + expect( spy.calledOnce ).to.be.true; - global.window.fire( 'scroll' ); + global.document.dispatchEvent( new Event( 'scroll' ) ); expect( spy.calledTwice ).to.be.true; } ); - it( 'listens to view.isActive and calls view._checkIfShouldBeSticky', () => { - const spy = testUtils.sinon.spy( view, '_checkIfShouldBeSticky' ); + it( 'listens to view.isActive and calls view.checkIfShouldBeSticky()', () => { + const spy = testUtils.sinon.spy( view, 'checkIfShouldBeSticky' ); expect( spy.notCalled ).to.be.true; view.render(); + expect( spy.calledOnce ).to.be.true; + view.isActive = true; expect( spy.calledTwice ).to.be.true; @@ -247,11 +241,40 @@ describe( 'StickyPanelView', () => { } ); } ); - describe( '_checkIfShouldBeSticky', () => { + describe( 'checkIfShouldBeSticky()', () => { beforeEach( () => { view.limiterElement = limiterElement; } ); + it( 'should unstick the panel if limiter element is not set', () => { + view.limiterElement = null; + + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); + + it( 'should unstick the panel if it is not active', () => { + view.isActive = true; + + const unstickSpy = testUtils.sinon.spy( view, '_unstick' ); + + view.isActive = false; + + sinon.assert.calledOnce( unstickSpy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); + describe( 'view.isSticky', () => { beforeEach( () => { testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -302,12 +325,12 @@ describe( 'StickyPanelView', () => { } ); } ); - describe( 'view._isStickyToTheLimiter', () => { + describe( 'view._isStickyToTheBottomOfLimiter', () => { it( 'is true if view.isSticky is true and reached the bottom edge of view.limiterElement', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { - top: -10, - bottom: 10, - height: 100 + top: -80, + bottom: 60, + height: 140 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -315,12 +338,12 @@ describe( 'StickyPanelView', () => { } ); expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; view.isActive = true; expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.true; + expect( view._isStickyToTheBottomOfLimiter ).to.be.true; } ); it( 'is false if view.isSticky is true and not reached the bottom edge of view.limiterElement', () => { @@ -335,12 +358,12 @@ describe( 'StickyPanelView', () => { } ); expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; view.isActive = true; expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; } ); it( 'is false if view.isSticky is false', () => { @@ -349,82 +372,387 @@ describe( 'StickyPanelView', () => { } ); expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; view.isActive = true; expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; } ); } ); - describe( 'view._hasViewportTopOffset', () => { - it( 'is true if view._isStickyToTheLimiter is false and view.viewportTopOffset has been specified', () => { - view.viewportTopOffset = 100; - - testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { - top: 90, - bottom: 190, - height: 100 - } ); + describe( 'after scrolling', () => { + it( 'should do nothing if scrolled element does not contain the panel', () => { + view.isActive = true; - testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { - height: 20 - } ); + const separateElement = document.createElement( 'div' ); - view.isActive = true; + view.checkIfShouldBeSticky( separateElement ); - expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.false; - expect( view._hasViewportTopOffset ).to.be.true; + expect( view.isSticky ).to.be.false; } ); - it( 'is false if view._isStickyToTheLimiter is true and view.viewportTopOffset has been specified', () => { - view.viewportTopOffset = 100; + describe( 'if there is one scrollable non-window parent', () => { + let scrollableContainer; - testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { - top: 10, - bottom: 110, - height: 100 - } ); + beforeEach( () => { + scrollableContainer = document.createElement( 'div' ); + scrollableContainer.className = 'scrollable'; + scrollableContainer.style.overflow = 'scroll'; + scrollableContainer.appendChild( limiterElement ); + global.document.body.appendChild( scrollableContainer ); - testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { - height: 20 + view.isActive = true; } ); - view.isActive = true; + afterEach( () => { + scrollableContainer.remove(); + } ); - expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.true; - expect( view._hasViewportTopOffset ).to.be.false; + describe( 'scrolled the container', () => { + it( 'should make panel sticky to the top if the limiter top is not visible', () => { + const stickToTopSpy = testUtils.sinon.spy( view, '_stickToTopOfAncestors' ); + + testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { + top: 40, + bottom: 140, + height: 100 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: 20, + bottom: 200, + height: 180 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableContainer ); + + sinon.assert.calledOnce( stickToTopSpy ); + assureStickiness( { + isSticky: true, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: 40, + _stickyBottomOffset: null, + _marginLeft: '0px' + } ); + } ); + + it( 'should make panel sticky to the bottom if there is enough space left', () => { + const stickToBottomSpy = testUtils.sinon.spy( view, '_stickToBottomOfLimiter' ); + + testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { + top: 40, + bottom: 140, + height: 100 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: -80, + bottom: 60, + height: 140 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableContainer ); + + expect( view.isSticky ).to.be.true; + expect( view._isStickyToTheBottomOfLimiter ).to.be.true; + + sinon.assert.calledOnce( stickToBottomSpy ); + assureStickiness( { + isSticky: true, + _isStickyToTheBottomOfLimiter: true, + _stickyTopOffset: null, + _stickyBottomOffset: 50, + _marginLeft: '0px' + } ); + } ); + + it( 'should unstick the panel if the limiter top is still visible', () => { + const stickToBottomSpy = testUtils.sinon.spy( view, '_stickToBottomOfLimiter' ); + const stickToTopSpy = testUtils.sinon.spy( view, '_stickToTopOfAncestors' ); + const unstickSpy = testUtils.sinon.spy( view, '_unstick' ); + + testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { + top: 20, + bottom: 140, + height: 120 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: 20, + bottom: 200, + height: 180 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableContainer ); + + sinon.assert.notCalled( stickToBottomSpy ); + sinon.assert.notCalled( stickToTopSpy ); + sinon.assert.calledOnce( unstickSpy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); + + it( 'should unstick the panel if there is not enough space left in the limiter', () => { + const spy = sinon.spy( view, '_unstick' ); + + testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { + top: 40, + bottom: 140, + height: 100 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: -80, + bottom: 60, + height: 140 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 100 + } ); + + view.checkIfShouldBeSticky( scrollableContainer ); + + sinon.assert.calledOnce( spy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); + + it( 'should unstick the panel if panel limiter is not visible in the viewport', () => { + const spy = sinon.spy( view, '_unstick' ); + + testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { + top: 120, + bottom: 140, + height: 100 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: -80, + bottom: 60, + height: 140 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableContainer ); + + sinon.assert.calledOnce( spy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); + } ); } ); - it( 'is false if view._isStickyToTheLimiter is false and view.viewportTopOffset is 0', () => { - view.viewportTopOffset = 100; + describe( 'if there are multiple scrollable non-window parents', () => { + let scrollableOuterParent, scrollableInnerParent; - testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { - top: 90, - bottom: 190, - height: 100 + beforeEach( () => { + scrollableOuterParent = document.createElement( 'div' ); + scrollableOuterParent.className = 'scrollable-outer'; + scrollableOuterParent.style.overflow = 'scroll'; + + scrollableInnerParent = document.createElement( 'div' ); + scrollableInnerParent.className = 'scrollable-inner'; + scrollableInnerParent.style.overflow = 'scroll'; + + scrollableInnerParent.appendChild( limiterElement ); + scrollableOuterParent.appendChild( scrollableInnerParent ); + global.document.body.appendChild( scrollableOuterParent ); + + view.isActive = true; } ); - testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { - height: 20 + afterEach( () => { + scrollableInnerParent.remove(); + scrollableOuterParent.remove(); } ); - view.isActive = true; + it( 'should unstick the panel if the limiter is still visible', () => { + const unstickSpy = testUtils.sinon.spy( view, '_unstick' ); + + testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { + top: 10, + bottom: 160, + height: 150 + } ); + + testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { + top: 20, + bottom: 140, + height: 120 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: 40, + bottom: 100, + height: 60 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableOuterParent ); + + sinon.assert.calledOnce( unstickSpy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); - expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.false; - expect( view._hasViewportTopOffset ).to.be.true; + it( 'should stick the panel to the top if the outer container was scrolled over the limiter top', () => { + const stickToTopSpy = testUtils.sinon.spy( view, '_stickToTopOfAncestors' ); + + testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { + top: 50, + bottom: 160, + height: 150 + } ); + + testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { + top: 20, + bottom: 140, + height: 120 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: 40, + bottom: 140, + height: 100 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableOuterParent ); + + sinon.assert.calledOnce( stickToTopSpy ); + assureStickiness( { + isSticky: true, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: 50, + _stickyBottomOffset: null, + _marginLeft: '0px' + } ); + } ); + + it( 'should unstick the panel if the outer container was scrolled but there is no space below', () => { + const unstickSpy = testUtils.sinon.spy( view, '_unstick' ); + + testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { + top: 50, + bottom: 160, + height: 150 + } ); + + testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { + top: 20, + bottom: 140, + height: 120 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: 40, + bottom: 110, + height: 60 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableOuterParent ); + + sinon.assert.calledOnce( unstickSpy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); + + it( 'should unstick the panel if the outer container was scrolled over the inner container top', () => { + const unstickSpy = testUtils.sinon.spy( view, '_unstick' ); + + testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { + top: 50, + bottom: 160, + height: 150 + } ); + + testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { + top: -20, + bottom: 50, + height: 70 + } ); + + testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { + top: 0, + bottom: 40, + height: 40 + } ); + + testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { + height: 20 + } ); + + view.checkIfShouldBeSticky( scrollableOuterParent ); + + sinon.assert.calledOnce( unstickSpy ); + assureStickiness( { + isSticky: false, + _isStickyToTheBottomOfLimiter: false, + _stickyTopOffset: null, + _stickyBottomOffset: null, + _marginLeft: null + } ); + } ); } ); } ); describe( 'view._marginLeft', () => { - it( 'is set if view.isSticky is true view._isStickyToTheLimiter is false', () => { + it( 'is set if view.isSticky is true view._stickyTopOffset is set', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -10, - bottom: 80, + bottom: 70, height: 100 } ); @@ -432,28 +760,26 @@ describe( 'StickyPanelView', () => { height: 20 } ); - Object.assign( windowStub, { - scrollX: 10, - scrollY: 0 - } ); + sinon.stub( global.window, 'scrollX' ).value( 10 ); + sinon.stub( global.window, 'scrollY' ).value( 0 ); expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; expect( view._marginLeft ).to.equal( null ); view.isActive = true; expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._stickyTopOffset ).to.not.equal( null ); expect( view._marginLeft ).to.equal( '-10px' ); } ); - it( 'is not set if view._isStickyToTheLimiter is true', () => { + it( 'is set if view._isStickyToTheBottomOfLimiter is true', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { - top: -10, - bottom: 10, + top: -30, + bottom: 50, left: 60, - height: 100 + height: 80 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -464,32 +790,43 @@ describe( 'StickyPanelView', () => { left: 40 } ); + sinon.stub( global.window, 'innerHeight' ).value( 100 ); + expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; expect( view._marginLeft ).to.equal( null ); view.isActive = true; expect( view.isSticky ).to.be.true; - expect( view._isStickyToTheLimiter ).to.be.true; - expect( view._marginLeft ).to.equal( null ); + expect( view._isStickyToTheBottomOfLimiter ).to.be.true; + expect( view._marginLeft ).to.equal( '0px' ); } ); - it( 'is not set if view.isSticky is false', () => { + it( 'is null if view.isSticky is false', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 10 } ); expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; expect( view._marginLeft ).to.equal( null ); view.isActive = true; expect( view.isSticky ).to.be.false; - expect( view._isStickyToTheLimiter ).to.be.false; + expect( view._isStickyToTheBottomOfLimiter ).to.be.false; expect( view._marginLeft ).to.equal( null ); } ); } ); } ); + + function assureStickiness( options ) { + expect( view.isSticky, 'isSticky is incorrect' ).to.equal( options.isSticky ); + expect( view._isStickyToTheBottomOfLimiter, '_isStickyToTheBottomOfLimiter is incorrect' ) + .to.equal( options._isStickyToTheBottomOfLimiter ); + expect( view._stickyTopOffset, '_stickyTopOffset is incorrect' ).to.equal( options._stickyTopOffset ); + expect( view._stickyBottomOffset, '_stickyBottomOffset is incorrect' ).to.equal( options._stickyBottomOffset ); + expect( view._marginLeft, '_marginLeft is incorrect' ).to.equal( options._marginLeft ); + } } );