Commit 36c3d27d authored by Zack Cuddy's avatar Zack Cuddy Committed by Kushal Pandya

Vue Shared - Dropdown Keyboard Navigation

parent 3fc8d136
<script>
import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes';
export default {
model: {
prop: 'index',
event: 'change',
},
props: {
/* v-model property to manage location in list */
index: {
type: Number,
required: true,
},
/* Highest index that can be navigated to */
max: {
type: Number,
required: true,
},
/* Lowest index that can be navigated to */
min: {
type: Number,
required: true,
},
/* Which index to set v-model to on init */
defaultIndex: {
type: Number,
required: true,
},
},
watch: {
max() {
// If the max index (list length) changes, reset the index
this.$emit('change', this.defaultIndex);
},
},
created() {
this.$emit('change', this.defaultIndex);
document.addEventListener('keydown', this.handleKeydown);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeydown);
},
methods: {
handleKeydown(event) {
if (event.keyCode === DOWN_KEY_CODE) {
// Prevents moving scrollbar
event.preventDefault();
event.stopPropagation();
// Moves to next index
this.increment(1);
} else if (event.keyCode === UP_KEY_CODE) {
// Prevents moving scrollbar
event.preventDefault();
event.stopPropagation();
// Moves to previous index
this.increment(-1);
} else if (event.keyCode === TAB_KEY_CODE) {
this.$emit('tab');
}
},
increment(val) {
if (this.max === 0) {
return;
}
const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
// Return if the index didn't change
if (nextIndex === this.index) {
return;
}
this.$emit('change', nextIndex);
},
},
render() {
return this.$slots.default;
},
};
</script>
import { shallowMount } from '@vue/test-utils';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes';
const MOCK_INDEX = 0;
const MOCK_MAX = 10;
const MOCK_MIN = 0;
const MOCK_DEFAULT_INDEX = 0;
describe('DropdownKeyboardNavigation', () => {
let wrapper;
const defaultProps = {
index: MOCK_INDEX,
max: MOCK_MAX,
min: MOCK_MIN,
defaultIndex: MOCK_DEFAULT_INDEX,
};
const createComponent = (props) => {
wrapper = shallowMount(DropdownKeyboardNavigation, {
propsData: {
...defaultProps,
...props,
},
});
};
const helpers = {
arrowDown: () => {
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: DOWN_KEY_CODE }));
},
arrowUp: () => {
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: UP_KEY_CODE }));
},
tab: () => {
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: TAB_KEY_CODE }));
},
};
afterEach(() => {
wrapper.destroy();
});
describe('onInit', () => {
beforeEach(() => {
createComponent();
});
it('should $emit @change with the default index', async () => {
expect(wrapper.emitted('change')[0]).toStrictEqual([MOCK_DEFAULT_INDEX]);
});
it('should $emit @change with the default index when max changes', async () => {
wrapper.setProps({ max: 20 });
await wrapper.vm.$nextTick();
// The first @change`call happens on created() so we test for the second [1]
expect(wrapper.emitted('change')[1]).toStrictEqual([MOCK_DEFAULT_INDEX]);
});
});
describe('keydown events', () => {
let incrementSpy;
beforeEach(() => {
createComponent();
incrementSpy = jest.spyOn(wrapper.vm, 'increment');
});
afterEach(() => {
incrementSpy.mockRestore();
});
it('onKeydown-Down calls increment(1)', () => {
helpers.arrowDown();
expect(incrementSpy).toHaveBeenCalledWith(1);
});
it('onKeydown-Up calls increment(-1)', () => {
helpers.arrowUp();
expect(incrementSpy).toHaveBeenCalledWith(-1);
});
it('onKeydown-Tab $emits @tab event', () => {
helpers.tab();
expect(wrapper.emitted('tab')).toHaveLength(1);
});
});
describe('increment', () => {
describe('when max is 0', () => {
beforeEach(() => {
createComponent({ max: 0 });
});
it('does not $emit any @change events', () => {
helpers.arrowDown();
// The first @change`call happens on created() so we test that we only have 1 call
expect(wrapper.emitted('change')).toHaveLength(1);
});
});
describe.each`
keyboardAction | direction | index | max | min
${helpers.arrowDown} | ${1} | ${10} | ${10} | ${0}
${helpers.arrowUp} | ${-1} | ${0} | ${10} | ${0}
`('moving out of bounds', ({ keyboardAction, direction, index, max, min }) => {
beforeEach(() => {
createComponent({ index, max, min });
keyboardAction();
});
it(`in ${direction} direction does not $emit any @change events`, () => {
// The first @change`call happens on created() so we test that we only have 1 call
expect(wrapper.emitted('change')).toHaveLength(1);
});
});
describe.each`
keyboardAction | direction | index | max | min
${helpers.arrowDown} | ${1} | ${0} | ${10} | ${0}
${helpers.arrowUp} | ${-1} | ${10} | ${10} | ${0}
`('moving in bounds', ({ keyboardAction, direction, index, max, min }) => {
beforeEach(() => {
createComponent({ index, max, min });
keyboardAction();
});
it(`in ${direction} direction $emits @change event with the correct index ${
index + direction
}`, () => {
// The first @change`call happens on created() so we test for the second [1]
expect(wrapper.emitted('change')[1]).toStrictEqual([index + direction]);
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment