Skip to content

Commit

Permalink
feat: add autoscroll boolean prop (#1160)
Browse files Browse the repository at this point in the history
* feat: add autoscroll boolean prop

Fixes #449

* refactor: update autoscroll implementation

Closes #1028
Closes #910

* refactor: only call maybeAdjustScroll in the watcher

* docs: upgrade vuepress
  • Loading branch information
sagalbot authored Apr 12, 2020
1 parent e65258d commit 2eb3908
Show file tree
Hide file tree
Showing 7 changed files with 2,415 additions and 2,022 deletions.
13 changes: 13 additions & 0 deletions docs/api/props.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## autoscroll <Badge text="v3.10.0+" />

When true, the dropdown will automatically scroll to ensure
that the option highlighted is fully within the dropdown viewport
when navigating with keyboard arrows.

```js
autoscroll: {
type: Boolean,
default: true
}
```

## appendToBody <Badge text="v3.7.0+" />

Append the dropdown element to the end of the body
Expand Down
22 changes: 11 additions & 11 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@
"devDependencies": {
"@octokit/graphql": "^4.3.1",
"@popperjs/core": "^2.1.0",
"@vuepress/plugin-active-header-links": "^1.0.0-alpha.47",
"@vuepress/plugin-google-analytics": "^1.0.0-alpha.47",
"@vuepress/plugin-nprogress": "^1.0.0-alpha.47",
"@vuepress/plugin-pwa": "^1.0.0-alpha.47",
"@vuepress/plugin-register-components": "^1.0.0-alpha.47",
"@vuepress/plugin-search": "^1.0.0-alpha.47",
"@vuepress/plugin-active-header-links": "^1.4.0",
"@vuepress/plugin-google-analytics": "^1.4.0",
"@vuepress/plugin-nprogress": "^1.4.0",
"@vuepress/plugin-pwa": "^1.4.0",
"@vuepress/plugin-register-components": "^1.4.0",
"@vuepress/plugin-search": "^1.4.0",
"axios": "^0.19.2",
"cross-env": "^5.2.0",
"cross-env": "^7.0.2",
"date-fns": "^2.11.0",
"dotenv": "^8.2.0",
"fuse.js": "^3.4.4",
"gh-pages": "^0.11.0",
"fuse.js": "^5.1.0",
"gh-pages": "^2.2.0",
"node-sass": "^4.12.0",
"octonode": "^0.9.5",
"sass-loader": "^7.1.0",
"sass-loader": "^8.0.2",
"vue": "^2.6.10",
"vuepress": "^1.0.0-alpha.47",
"vuepress": "^1.4.0",
"vuex": "^3.1.0"
}
}
4,120 changes: 2,295 additions & 1,825 deletions docs/yarn.lock

Large diffs are not rendered by default.

88 changes: 28 additions & 60 deletions src/mixins/pointerScroll.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
export default {
props: {
autoscroll: {
type: Boolean,
default: true
}
},

watch: {
typeAheadPointer() {
this.maybeAdjustScroll();
}
if (this.autoscroll) {
this.maybeAdjustScroll();
}
},
},

methods: {
Expand All @@ -13,75 +22,34 @@ export default {
* @returns {*}
*/
maybeAdjustScroll() {
let pixelsToPointerTop = this.pixelsToPointerTop();
let pixelsToPointerBottom = this.pixelsToPointerBottom();
const optionEl =
this.$refs.dropdownMenu?.children[this.typeAheadPointer] || false;

if (pixelsToPointerTop <= this.viewport().top) {
return this.scrollTo(pixelsToPointerTop);
} else if (pixelsToPointerBottom >= this.viewport().bottom) {
return this.scrollTo(this.viewport().top + this.pointerHeight());
}
},
if (optionEl) {
const bounds = this.getDropdownViewport();
const { top, bottom, height } = optionEl.getBoundingClientRect();

/**
* The distance in pixels from the top of the dropdown
* list to the top of the current pointer element.
* @returns {number}
*/
pixelsToPointerTop() {
let pixelsToPointerTop = 0;
if (this.$refs.dropdownMenu && this.dropdownOpen) {
for (let i = 0; i < this.typeAheadPointer; i++) {
pixelsToPointerTop += this.$refs.dropdownMenu.children[i]
.offsetHeight;
if (top < bounds.top) {
return (this.$refs.dropdownMenu.scrollTop = optionEl.offsetTop);
} else if (bottom > bounds.bottom) {
return (this.$refs.dropdownMenu.scrollTop =
optionEl.offsetTop - (bounds.height - height));
}
}
return pixelsToPointerTop;
},

/**
* The distance in pixels from the top of the dropdown
* list to the bottom of the current pointer element.
* @returns {*}
*/
pixelsToPointerBottom() {
return this.pixelsToPointerTop() + this.pointerHeight();
},

/**
* The offsetHeight of the current pointer element.
* @returns {number}
*/
pointerHeight() {
let element = this.$refs.dropdownMenu
? this.$refs.dropdownMenu.children[this.typeAheadPointer]
: false;
return element ? element.offsetHeight : 0;
},

/**
* The currently viewable portion of the dropdownMenu.
* @returns {{top: (string|*|number), bottom: *}}
*/
viewport() {
return {
top: this.$refs.dropdownMenu ? this.$refs.dropdownMenu.scrollTop : 0,
bottom: this.$refs.dropdownMenu
? this.$refs.dropdownMenu.offsetHeight +
this.$refs.dropdownMenu.scrollTop
: 0
};
},

/**
* Scroll the dropdownMenu to a given position.
* @param position
* @returns {*}
*/
scrollTo(position) {
getDropdownViewport() {
return this.$refs.dropdownMenu
? (this.$refs.dropdownMenu.scrollTop = position)
: null;
? this.$refs.dropdownMenu.getBoundingClientRect()
: {
height: 0,
top: 0,
bottom: 0
};
}
}
};
8 changes: 0 additions & 8 deletions src/mixins/typeAheadPointer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ export default {
for (let i = this.typeAheadPointer - 1; i >= 0; i--) {
if (this.selectable(this.filteredOptions[i])) {
this.typeAheadPointer = i;
if( this.maybeAdjustScroll ) {
this.maybeAdjustScroll()
}
break;
}
}
},
Expand All @@ -43,10 +39,6 @@ export default {
for (let i = this.typeAheadPointer + 1; i < this.filteredOptions.length; i++) {
if (this.selectable(this.filteredOptions[i])) {
this.typeAheadPointer = i;
if( this.maybeAdjustScroll ) {
this.maybeAdjustScroll()
}
break;
}
}
},
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/Autoscroll.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { mountDefault } from "../helpers";

describe("Automatic Scrolling", () => {
it("should check if the scroll position needs to be adjusted on up arrow keyUp", async () => {
// Given
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");
Select.vm.typeAheadPointer = 1;

// When
Select.find({ ref: "search" }).trigger("keydown.up");
await Select.vm.$nextTick();

// Then
expect(spy).toHaveBeenCalled();
});

it("should check if the scroll position needs to be adjusted on down arrow keyUp", async () => {
// Given
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");
Select.vm.typeAheadPointer = 1;

// When
Select.find({ ref: "search" }).trigger("keydown.down");
await Select.vm.$nextTick();

// Then
expect(spy).toHaveBeenCalled();
});

it("should check if the scroll position needs to be adjusted when filtered options changes", async () => {
// Given
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");
Select.vm.typeAheadPointer = 1;

// When
Select.vm.search = "two";
await Select.vm.$nextTick();

// Then
expect(spy).toHaveBeenCalled();
});

it("should not adjust scroll position when autoscroll is false", async () => {
// Given
const Select = mountDefault({
autoscroll: false
});
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");
Select.vm.typeAheadPointer = 1;

// When
Select.vm.search = "two";
await Select.vm.$nextTick();

// Then
expect(spy).toHaveBeenCalledTimes(0);
});
});
125 changes: 7 additions & 118 deletions tests/unit/TypeAhead.spec.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { shallowMount } from '@vue/test-utils';
import { shallowMount } from "@vue/test-utils";
import VueSelect from "../../src/components/Select";
import { mountDefault, mountWithoutTestUtils } from '../helpers';
import typeAheadMixin from '../../src/mixins/typeAheadPointer';
import Vue from 'vue';
import { mountDefault, mountWithoutTestUtils } from "../helpers";
import typeAheadMixin from "../../src/mixins/typeAheadPointer";
import Vue from "vue";

describe("Moving the Typeahead Pointer", () => {

it('should set the pointer to zero when the filteredOptions watcher is called', async () => {
it("should set the pointer to zero when the filteredOptions watcher is called", async () => {
const Select = shallowMount(VueSelect, {
propsData: { options: ['one', 'two', 'three'] },
propsData: { options: ["one", "two", "three"] },
sync: false
});

Select.vm.search = 'one';
Select.vm.search = "one";

await Select.vm.$nextTick();
expect(Select.vm.typeAheadPointer).toEqual(0);
Expand Down Expand Up @@ -45,114 +44,4 @@ describe("Moving the Typeahead Pointer", () => {
Select.vm.typeAheadDown();
expect(Select.vm.typeAheadPointer).toEqual(2);
});

describe("Automatic Scrolling", () => {
it("should check if the scroll position needs to be adjusted on up arrow keyUp", () => {
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");

Select.vm.typeAheadPointer = 1;

Select.find({ ref: "search" }).trigger("keydown.up");
expect(spy).toHaveBeenCalled();
});

it("should check if the scroll position needs to be adjusted on down arrow keyUp", () => {
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");

Select.vm.typeAheadPointer = 1;

Select.find({ ref: "search" }).trigger("keydown.down");
expect(spy).toHaveBeenCalled();
});

/**
* This test fails despite working in the browser.
* After many attempts to get it to pass, it's been
* rewritten below.
*/
it.skip("should check if the scroll position needs to be adjusted when filtered options changes", () => {
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "maybeAdjustScroll");

Select.vm.search = "two";

expect(spy).toHaveBeenCalled();
});

it("should scroll up if the pointer is above the current viewport bounds", () => {
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "scrollTo");

Select.setMethods({
pixelsToPointerTop() {
return 1;
},
viewport() {
return { top: 2, bottom: 0 };
}
});

Select.vm.maybeAdjustScroll();

expect(spy).toHaveBeenCalledWith(1);
});

it("should scroll down if the pointer is below the current viewport bounds", () => {
const Select = mountDefault();
const spy = jest.spyOn(Select.vm, "scrollTo");

Select.setMethods({
pixelsToPointerBottom() {
return 2;
},
viewport() {
return { top: 0, bottom: 1 };
}
});

Select.vm.maybeAdjustScroll();
expect(spy).toHaveBeenCalledWith(
Select.vm.viewport().top + Select.vm.pointerHeight()
);
});
});

describe("Measuring pixel distances", () => {
it("should calculate pointerHeight as the offsetHeight of the pointer element if it exists", async () => {
const Select = mountDefault();

// Drop down must be open for $refs to exist
Select.vm.open = true;
await Select.vm.$nextTick();

/**
* Since JSDom doesn't render layouts, set the offsetHeight explicitly
* to 25px for each list item.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
*/
let i = 0;
for (let option of Select.vm.$refs.dropdownMenu.children) {
Object.defineProperty(option, "offsetHeight", {
value: 1 + i
});
i++;
}

// Fresh instances start with the pointer at -1
Select.vm.typeAheadPointer = -1;
expect(Select.vm.pointerHeight()).toEqual(0);

Select.vm.typeAheadPointer = 0;
expect(Select.vm.pointerHeight()).toEqual(1);

Select.vm.typeAheadPointer = 1;
expect(Select.vm.pointerHeight()).toEqual(2);

Select.vm.typeAheadPointer = 2;
expect(Select.vm.pointerHeight()).toEqual(3);
});
});
});

0 comments on commit 2eb3908

Please sign in to comment.