Skip to content
Permalink
Browse files

feat(improvements): Bug fixes, support for scrolling via the scrollTo…

…p and scrollLeft setters, and more

This version brings several improvements. First, the setters on Element.prototype for 'scrollLeft' and 'scrollTop' now works with the polyfill.
If an element is within a scrolling box with a scroll-behavior of 'smooth', using these setters will now trigger smooth scrolling between the start and end positions.
Additionally, part of the CSSOM View Module enhancements to the Element interface is proper standardizations of Element.prototype.scroll, Element.prototype.scrollTo,
Element.prototype.scrollBy, and Element.protoype.scrollIntoView. These methods are all polyfilled with fallback within this polyfill for a unified scrolling API across browsers.
Finally, a few bugs have been fixed
  • Loading branch information...
wessberg committed Jan 11, 2019
1 parent 43b8a44 commit 4989d15ef53196e8619a161211214e412bbd09bc
@@ -7,7 +7,7 @@

# `scroll-behavior-polyfill`

> A polyfill for the [`scroll-behavior`](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior) CSS-property
> A polyfill for the [`scroll-behavior`](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior) CSS-property as well as the extensions to the Element interface in the [CSSOM View Module](https://drafts.csswg.org/cssom-view/#dom-element-scrollto-options-options)
## Description

@@ -16,6 +16,8 @@ This polyfill brings this new feature to all browsers.

It is very efficient, tiny, and works with the latest browser technologies such as Shadow DOM.

This polyfill also implements the extensions to the Element interface in the [CSSOM View Module](https://drafts.csswg.org/cssom-view/#dom-element-scrollto-options-options) such as `Element.prototype.scroll`, `Element.prototype.scrollTo`, `Element.protype.scrollBy`, and `Element.prototype.scrollIntoView`.

## Install

### NPM
@@ -67,11 +69,11 @@ This means that either of the following approaches will work:
<div style="scroll-behavior: smooth"></div>
<!-- Works just fine when given as an attribute of the name 'scroll-behavior' -->
<div scroll-behavior="smooth"></div>
```

```typescript
// Works jut fine when given as a style property
element.style.scrollBehavior = "smooth";
<script>
// Works jut fine when given as a style property
element.style.scrollBehavior = "smooth";
</script>
```

See [this section](#are-there-any-known-quirks) for information about why `scroll-behavior` values provided in stylesheets won't be discovered by the polyfill.
@@ -100,12 +102,18 @@ myElement.scrollBy({
});
```

You can also use the `scrollTop` and `scrollLeft` setters, both of which works with the polyfill too:

````typescript
element.scrollTop += 100;
element.scrollLeft += 50;
````

## Dependencies & Browser support

This polyfill is distributed in ES3-compatible syntax, but is using some modern APIs and language features which must be available:

- `requestAnimationFrame`
- `Element.prototype.scrollIntoView`

For by far the most browsers, these features will already be natively available.
Generally, I would highly recommend using something like [Polyfill.app](https://github.com/wessberg/Polyfiller) which takes care of this stuff automatically.
@@ -122,7 +130,6 @@ Do you want to contribute? Awesome! Please follow [these recommendations](./CONT

### Are there any known quirks?

- You cannot set `scrollLeft` or `scrollTop`. There is no way to overwrite the property descriptors for those operations. Instead, use `scroll()`, `scrollTo` or `scrollBy` which does the exact same thing.
- `scroll-behavior` properties declared only in stylesheets won't be discovered. This is because [polyfilling CSS is hard and really bad for performance](https://philipwalton.com/articles/the-dark-side-of-polyfilling-css/).

## Backers 🏅
@@ -0,0 +1,3 @@
export interface AdjustableElement extends Element {
__adjustingScrollPosition?: boolean;
}
@@ -1,6 +1,7 @@
import {SUPPORTS_SCROLL_BEHAVIOR} from "./support/supports-scroll-behavior";
import {patch} from "./patch/patch";
import {SUPPORTS_ELEMENT_PROTOTYPE_SCROLL_METHODS} from "./support/supports-element-prototype-scroll-methods";

if (!SUPPORTS_SCROLL_BEHAVIOR) {
if (!SUPPORTS_SCROLL_BEHAVIOR || !SUPPORTS_ELEMENT_PROTOTYPE_SCROLL_METHODS) {
patch();
}
@@ -1 +1,2 @@
export const ELEMENT_ORIGINAL_SCROLL_BY = Element.prototype.scrollBy;
export const ELEMENT_ORIGINAL_SCROLL_BY = Element.prototype.scrollBy;

@@ -0,0 +1 @@
export const ELEMENT_ORIGINAL_SCROLL_LEFT_SET_DESCRIPTOR = Object.getOwnPropertyDescriptor(Element.prototype, "scrollLeft")!.set!;
@@ -0,0 +1 @@
export const ELEMENT_ORIGINAL_SCROLL_TOP_SET_DESCRIPTOR = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!.set!;
@@ -21,17 +21,6 @@ export function catchNavigation (): void {
// Only work with HTMLAnchorElements that navigates to a specific ID
if (hrefAttributeValue == null || !hrefAttributeValue.startsWith("#")) return;

// Find the nearest ancestor that can be scrolled
const ancestorWithScrollBehaviorResult = findNearestAncestorsWithScrollBehavior(e.target);
// If there is none, don't proceed
if (ancestorWithScrollBehaviorResult == null) return;

// Take the scroll behavior for that ancestor
const [ancestorWithScrollBehavior, behavior] = ancestorWithScrollBehaviorResult;

// If the behavior isn't smooth, don't proceed
if (behavior !== "smooth") return;

// Find the nearest root, whether it be a ShadowRoot or the document itself
const root = findNearestRoot(e.target);

@@ -43,6 +32,12 @@ export function catchNavigation (): void {
// If no selector could be found, don't proceed
if (elementMatch == null) return;

// Find the nearest ancestor that can be scrolled
const [ancestorWithScrollBehavior, behavior] = findNearestAncestorsWithScrollBehavior(elementMatch);

// If the behavior isn't smooth, don't proceed
if (behavior !== "smooth") return;

// Otherwise, first prevent the default action.
e.preventDefault();

@@ -1,6 +1,7 @@
import {findNearestAncestorsWithScrollBehavior} from "../../util/find-nearest-ancestor-with-scroll-behavior";
import {ELEMENT_ORIGINAL_SCROLL_INTO_VIEW} from "../../original/element/scroll-into-view";
import {computeScrollIntoView} from "./compute-scroll-into-view";
import {getOriginalScrollMethodForKind} from "../../scroll-method/get-original-scroll-method-for-kind";

/**
* Patches the 'scrollIntoView' method on the Element prototype
@@ -21,20 +22,24 @@ export function patchElementScrollIntoView (): void {
: arg;

// Find the nearest ancestor that can be scrolled
const ancestorWithScrollBehaviorResult = findNearestAncestorsWithScrollBehavior(this);
const [ancestorWithScroll, ancestorWithScrollBehavior] = findNearestAncestorsWithScrollBehavior(this);

// If there is none, opt-out by calling the original implementation
if (ancestorWithScrollBehaviorResult == null) {
ELEMENT_ORIGINAL_SCROLL_INTO_VIEW.call(this, normalizedOptions);
return;
}

const [ancestorWithScroll, ancestorWithScrollBehavior] = ancestorWithScrollBehaviorResult;
const behavior = normalizedOptions.behavior != null ? normalizedOptions.behavior : ancestorWithScrollBehavior;
const behavior = normalizedOptions.behavior != null
? normalizedOptions.behavior
: ancestorWithScrollBehavior;

// If the behavior isn't smooth, simply invoke the original implementation and do no more
if (behavior !== "smooth") {
ELEMENT_ORIGINAL_SCROLL_INTO_VIEW.call(this, normalizedOptions);
// Assert that 'scrollIntoView' is actually defined
if (ELEMENT_ORIGINAL_SCROLL_INTO_VIEW != null) {
ELEMENT_ORIGINAL_SCROLL_INTO_VIEW.call(this, normalizedOptions);
}

// Otherwise, invoke 'scrollTo' instead and provide the scroll coordinates
else {
const {top, left} = computeScrollIntoView(this, ancestorWithScroll, normalizedOptions);
getOriginalScrollMethodForKind("scrollTo", this).call(this, left, top);
}
return;
}

@@ -0,0 +1,19 @@
import {handleScrollMethod} from "../shared";
import {ELEMENT_ORIGINAL_SCROLL_LEFT_SET_DESCRIPTOR} from "../../original/element/scroll-left";

/**
* Patches the 'scrollLeft' property descriptor on the Element prototype
*/
export function patchElementScrollLeft (): void {

Object.defineProperty(Element.prototype, "scrollLeft", {
set (scrollLeft: number) {
if (this.__adjustingScrollPosition) {
return ELEMENT_ORIGINAL_SCROLL_LEFT_SET_DESCRIPTOR.call(this, scrollLeft);
}

handleScrollMethod(this, "scrollTo", scrollLeft, this.scrollTop);
return scrollLeft;
}
});
}
@@ -0,0 +1,19 @@
import {handleScrollMethod} from "../shared";
import {ELEMENT_ORIGINAL_SCROLL_TOP_SET_DESCRIPTOR} from "../../original/element/scroll-top";

/**
* Patches the 'scrollTop' property descriptor on the Element prototype
*/
export function patchElementScrollTop(): void {

Object.defineProperty(Element.prototype, "scrollTop", {
set (scrollTop: number) {
if (this.__adjustingScrollPosition) {
return ELEMENT_ORIGINAL_SCROLL_TOP_SET_DESCRIPTOR.call(this, scrollTop);
}

handleScrollMethod(this, "scrollTo", this.scrollLeft, scrollTop);
return scrollTop;
}
});
}
@@ -6,19 +6,28 @@ import {patchWindowScrollBy} from "./window/scroll-by";
import {patchWindowScrollTo} from "./window/scroll-to";
import {catchNavigation} from "./anchor/catch-navigation";
import {patchElementScrollIntoView} from "./element/scroll-into-view";
import {patchElementScrollTop} from "./element/scroll-top";
import {patchElementScrollLeft} from "./element/scroll-left";

/**
* Applies the polyfill
*/
export function patch (): void {
// Element.prototype methods
patchElementScroll();
patchElementScrollBy();
patchElementScrollTo();
patchElementScrollIntoView();

// Element.prototype descriptors
patchElementScrollLeft();
patchElementScrollTop();

// window methods
patchWindowScroll();
patchWindowScrollBy();
patchWindowScrollTo();

// Navigation
catchNavigation();
}
@@ -1,16 +1,10 @@
import {ELEMENT_ORIGINAL_SCROLL} from "../original/element/scroll";
import {ELEMENT_ORIGINAL_SCROLL_BY} from "../original/element/scroll-by";
import {ELEMENT_ORIGINAL_SCROLL_TO} from "../original/element/scroll-to";
import {getScrollBehavior} from "../util/get-scroll-behavior";
import {smoothScroll} from "../smooth-scroll/smooth-scroll/smooth-scroll";
import {getSmoothScrollOptions, ScrollMethodName} from "../smooth-scroll/get-smooth-scroll-options/get-smooth-scroll-options";
import {getScrollLeft} from "../util/get-scroll-left";
import {getSmoothScrollOptions} from "../smooth-scroll/get-smooth-scroll-options/get-smooth-scroll-options";
import {ensureNumeric} from "../util/ensure-numeric";
import {getScrollTop} from "../util/get-scroll-top";
import {isScrollToOptions} from "../util/is-scroll-to-options";
import {WINDOW_ORIGINAL_SCROLL} from "../original/window/scroll";
import {WINDOW_ORIGINAL_SCROLL_BY} from "../original/window/scroll-by";
import {WINDOW_ORIGINAL_SCROLL_TO} from "../original/window/scroll-to";
import {ScrollMethodName} from "../scroll-method/scroll-method-name";
import {getOriginalScrollMethodForKind} from "../scroll-method/get-original-scroll-method-for-kind";

/**
* Handles a scroll method
@@ -20,41 +14,11 @@ import {WINDOW_ORIGINAL_SCROLL_TO} from "../original/window/scroll-to";
* @param {number} y
*/
export function handleScrollMethod (element: Element|Window, kind: ScrollMethodName, optionsOrX?: number|ScrollToOptions, y?: number): void {
// If only one argument is given, and it isn't an options object, throw a TypeError
if (y === undefined && !isScrollToOptions(optionsOrX)) {
throw new TypeError("Failed to execute 'scroll' on 'Element': parameter 1 ('options') is not an object.");
}

// Scroll based on the primitive values given as arguments
if (!isScrollToOptions(optionsOrX)) {
const {left, top} = normalizeScrollCoordinates(optionsOrX, y, element, kind);
onScrollPrimitive(left, top, element, kind);
}

// Scroll based on the received options object
else {
onScrollWithOptions({
...normalizeScrollCoordinates(optionsOrX.left, optionsOrX.top, element, kind),
behavior: optionsOrX.behavior == null ? "auto" : optionsOrX.behavior
}, element, kind);
}
}

/**
* Gets the original non-patched prototype method for the given kind
* @param {ScrollMethodName} kind
* @param {Element|Window} element
* @return {Function}
*/
function getOriginalPrototypeMethodForKind (kind: ScrollMethodName, element: Element|Window) {
switch (kind) {
case "scroll":
return element instanceof Element ? ELEMENT_ORIGINAL_SCROLL : WINDOW_ORIGINAL_SCROLL;
case "scrollBy":
return element instanceof Element ? ELEMENT_ORIGINAL_SCROLL_BY : WINDOW_ORIGINAL_SCROLL_BY;
case "scrollTo":
return element instanceof Element ? ELEMENT_ORIGINAL_SCROLL_TO : WINDOW_ORIGINAL_SCROLL_TO;
}
onScrollWithOptions(
getScrollToOptionsWithValidation(optionsOrX, y),
element,
kind
);
}

/**
@@ -68,7 +32,7 @@ function onScrollWithOptions (options: Required<ScrollToOptions>, element: Eleme

// If the behavior is 'auto' apply instantaneous scrolling
if (behavior == null || behavior === "auto") {
getOriginalPrototypeMethodForKind(kind, element).call(element, options.left, options.top);
getOriginalScrollMethodForKind(kind, element).call(element, options.left, options.top);
}

else {
@@ -81,41 +45,44 @@ function onScrollWithOptions (options: Required<ScrollToOptions>, element: Eleme
}
}

/**
* Invoked when 'scroll()' is invoked with primitive x or y values
* @param {number} x
* @param {number} y
* @param {ScrollMethodName} kind
* @param {Element|Window} element
*/
function onScrollPrimitive (x: number, y: number, element: Element|Window, kind: ScrollMethodName): void {
// noinspection SuspiciousTypeOfGuard
return onScrollWithOptions({
left: x,
top: y,
behavior: "auto"
}, element, kind);
}

/**
* Normalizes the given scroll coordinates
* @param {number?} x
* @param {number?} y
* @param {Element|Window} element
* @param {ScrollMethodName} kind
* @return {Required<Pick<ScrollToOptions, "top" | "left">>}
*/
function normalizeScrollCoordinates (x: number|undefined, y: number|undefined, element: Element|Window, kind: ScrollMethodName): Required<Pick<ScrollToOptions, "top"|"left">> {
switch (kind) {
case "scrollBy":
return {
left: getScrollLeft(element) + ensureNumeric(x),
top: getScrollTop(element) + ensureNumeric(y)
};
default:
return {
left: ensureNumeric(x),
top: ensureNumeric(y)
};
function normalizeScrollCoordinates (x: number|undefined, y: number|undefined): Required<Pick<ScrollToOptions, "top"|"left">> {
return {
left: ensureNumeric(x),
top: ensureNumeric(y)
};
}

/**
* Gets ScrollToOptions based on the given arguments. Will throw if validation fails
* @param {number | ScrollToOptions} optionsOrX
* @param {number} y
* @return {Required<ScrollToOptions>}
*/
function getScrollToOptionsWithValidation (optionsOrX?: number|ScrollToOptions, y?: number): Required<ScrollToOptions> {
// If only one argument is given, and it isn't an options object, throw a TypeError
if (y === undefined && !isScrollToOptions(optionsOrX)) {
throw new TypeError("Failed to execute 'scroll' on 'Element': parameter 1 ('options') is not an object.");
}

// Scroll based on the primitive values given as arguments
if (!isScrollToOptions(optionsOrX)) {
return {
...normalizeScrollCoordinates(optionsOrX, y),
behavior: "auto"
};
}

// Scroll based on the received options object
else {
return {
...normalizeScrollCoordinates(optionsOrX.left, optionsOrX.top),
behavior: optionsOrX.behavior == null ? "auto" : optionsOrX.behavior
};
}
}

0 comments on commit 4989d15

Please sign in to comment.
You can’t perform that action at this time.