Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: autoscroll #1160

Merged
merged 4 commits into from
Apr 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
});