Skip to content

Commit

Permalink
feat(improvements): Bug fixes, support for scrolling via the scrollTo…
Browse files Browse the repository at this point in the history
…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 4989d15
Show file tree
Hide file tree
Showing 19 changed files with 302 additions and 146 deletions.
21 changes: 14 additions & 7 deletions README.md
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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 🏅
Expand Down
3 changes: 3 additions & 0 deletions src/adjustable-element/adjustable-element.ts
@@ -0,0 +1,3 @@
export interface AdjustableElement extends Element {
__adjustingScrollPosition?: boolean;
}
3 changes: 2 additions & 1 deletion src/index.ts
@@ -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();
}
3 changes: 2 additions & 1 deletion src/original/element/scroll-by.ts
@@ -1 +1,2 @@
export const ELEMENT_ORIGINAL_SCROLL_BY = Element.prototype.scrollBy;
export const ELEMENT_ORIGINAL_SCROLL_BY = Element.prototype.scrollBy;

1 change: 1 addition & 0 deletions src/original/element/scroll-left.ts
@@ -0,0 +1 @@
export const ELEMENT_ORIGINAL_SCROLL_LEFT_SET_DESCRIPTOR = Object.getOwnPropertyDescriptor(Element.prototype, "scrollLeft")!.set!;
1 change: 1 addition & 0 deletions src/original/element/scroll-top.ts
@@ -0,0 +1 @@
export const ELEMENT_ORIGINAL_SCROLL_TOP_SET_DESCRIPTOR = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!.set!;
17 changes: 6 additions & 11 deletions src/patch/anchor/catch-navigation.ts
Expand Up @@ -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);

Expand All @@ -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();

Expand Down
25 changes: 15 additions & 10 deletions src/patch/element/scroll-into-view.ts
@@ -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
Expand All @@ -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;
}

Expand Down
19 changes: 19 additions & 0 deletions src/patch/element/scroll-left.ts
@@ -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;
}
});
}
19 changes: 19 additions & 0 deletions src/patch/element/scroll-top.ts
@@ -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;
}
});
}
9 changes: 9 additions & 0 deletions src/patch/patch.ts
Expand Up @@ -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();
}
117 changes: 42 additions & 75 deletions src/patch/shared.ts
@@ -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
Expand All @@ -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
);
}

/**
Expand All @@ -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 {
Expand All @@ -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.