Skip to content

Commit

Permalink
feat(keyboard-navigation): migrate keyboard-navigation component to c…
Browse files Browse the repository at this point in the history
…omposition API
  • Loading branch information
victorcg88 committed May 31, 2024
1 parent 1bffd7c commit 34eb986
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 124 deletions.
1 change: 1 addition & 0 deletions packages/_vue3-migration-test/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { default as TestBaseEventButton } from './test-base-event-button.vue';
export { default as TestBaseVariableColumnGrid } from './test-base-variable-column-grid.vue';
export { default as TestSlidingPanel } from './test-sliding-panel.vue';
export { default as TestUseLayouts } from './test-use-layouts.vue';
export { default as TestBaseKeyboardNavigation } from './test-base-keyboard-navigation.vue';
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<template>
<SearchInput />
<BaseKeyboardNavigation>
<ul>
<li>
<button>First</button>
</li>
<li>
<button>Second</button>
</li>
<li>
<button>Third</button>
</li>
</ul>
</BaseKeyboardNavigation>
</template>

<script setup lang="ts">
import BaseKeyboardNavigation from '../../../x-components/src/components/base-keyboard-navigation.vue';
import SearchInput from '../../../x-components/src/x-modules/search-box/components/search-input.vue';
</script>

<style>
button:focus-visible {
border: 3px solid red;
}
</style>
8 changes: 7 additions & 1 deletion packages/_vue3-migration-test/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
TestBaseVariableColumnGrid,
TestEmpathize,
TestUseLayouts,
TestSlidingPanel
TestSlidingPanel,
TestBaseKeyboardNavigation
} from './';

const routes = [
Expand Down Expand Up @@ -134,6 +135,11 @@ const routes = [
path: '/test-use-layouts',
name: 'TestUseLayouts',
component: TestUseLayouts
},
{
path: '/base-keyboard-navigation',
name: 'TestBaseKeyboardNavigation',
component: TestBaseKeyboardNavigation
}
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Vue from 'vue';
import { SearchInput } from '../../x-modules/search-box/components/index';
import { installNewXPlugin } from '../../__tests__/utils';
import BaseKeyboardNavigation from '../base-keyboard-navigation.vue';
import { DirectionalFocusNavigationService } from '../../services/directional-focus-navigation.service';

describe('testing keyboard navigation component', () => {
let localVue: typeof Vue;
Expand All @@ -12,9 +13,12 @@ describe('testing keyboard navigation component', () => {
});

it('takes control of the navigation when a defined condition is triggered', () => {
const mockedFocusNextNavigableElement = jest.fn();
const navigateToSpy = jest.spyOn(
DirectionalFocusNavigationService.prototype as any,
'navigateTo'
);
const searchInput = mount(SearchInput, { localVue });
const keyboardNavigation = mount(BaseKeyboardNavigation, {
mount(BaseKeyboardNavigation, {
localVue,
propsData: {
navigationHijacker: [
Expand All @@ -26,36 +30,28 @@ describe('testing keyboard navigation component', () => {
]
}
});
Object.defineProperty(keyboardNavigation.vm, 'focusNextNavigableElement', {
value: mockedFocusNextNavigableElement
});
searchInput.trigger('keydown', { key: 'ArrowUp' });
expect(mockedFocusNextNavigableElement).not.toHaveBeenCalled();
expect(navigateToSpy).not.toHaveBeenCalled();

searchInput.trigger('keydown', { key: 'ArrowDown' });
expect(mockedFocusNextNavigableElement).toHaveBeenCalled();
expect(navigateToSpy).toHaveBeenCalled();
});

it('emits the defined event when reaching the limit in the direction of the navigation', () => {
const listener = jest.fn();
const htmlElement = document.createElement('div');
// As cannot mock elementToFocus (it will be undefined), making the navigateTo method return undefined
jest
.spyOn(DirectionalFocusNavigationService.prototype as any, 'navigateTo')
.mockReturnValue(undefined);
const keyboardNavigation = mount(BaseKeyboardNavigation, {
localVue,
data() {
return {
elementToFocus: htmlElement
};
},
propsData: {
takeNavigationControl: [],
eventsForDirectionLimit: {
ArrowUp: 'UserReachedEmpathizeTop'
}
}
});
Object.defineProperty((keyboardNavigation.vm as any).navigationService, 'navigateTo', {
value: (): HTMLElement => htmlElement
});
keyboardNavigation.vm.$x.on('UserReachedEmpathizeTop').subscribe(listener);
keyboardNavigation.trigger('keydown', { key: 'ArrowUp' });

Expand Down
226 changes: 119 additions & 107 deletions packages/x-components/src/components/base-keyboard-navigation.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<div
ref="el"
@keydown.up.down.right.left.prevent="focusNextNavigableElement"
class="x-keyboard-navigation"
data-test="keyboard-navigation"
Expand All @@ -10,15 +11,14 @@
</template>

<script lang="ts">
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
import { PropType, computed, defineComponent, onMounted, ref } from 'vue';
// eslint-disable-next-line max-len
import { DirectionalFocusNavigationService } from '../services/directional-focus-navigation.service';
import { SpatialNavigation } from '../services/services.types';
import { ArrowKey, EventsForDirectionLimit, TakeNavigationControl } from '../utils/types';
import { XEventsOf } from '../wiring/events.types';
import { WireMetadata } from '../wiring/wiring.types';
import { XOn } from './decorators/bus.decorators';
import { use$x, useXBus } from '../composables';
/**
* Base component to handle keyboard navigation for elements inside it. It has a required slot to
Expand All @@ -32,116 +32,128 @@
*
* @public
*/
@Component
export default class BaseKeyboardNavigation extends Vue {
/**
* An array of {@link TakeNavigationControl} objects defining when to
* take control of the keyboard navigation.
*/
@Prop({
default: () => [
{ xEvent: 'UserPressedArrowKey', moduleName: 'searchBox', direction: 'ArrowDown' }
]
})
protected navigationHijacker!: TakeNavigationControl[];
/**
* An {@link EventsForDirectionLimit} to emit when the user is already at the furthest element
* in a direction and tries to keep going on the same direction.
*/
@Prop({ default: () => ({ ArrowUp: 'UserReachedEmpathizeTop' }) })
protected eventsForDirectionLimit!: Partial<EventsForDirectionLimit>;
/**
* The {@link SpatialNavigation} service to use.
*/
protected navigationService!: SpatialNavigation;
/**
* The element to focus.
*/
protected elementToFocus: HTMLElement | undefined;
mounted(): void {
// TODO Replace this with injection
this.navigationService = new DirectionalFocusNavigationService(this.$el as HTMLElement);
}
/**
* Get the navigation hijacker events.
*
* @remarks
* If the same {@link XEvent} is defined multiple times it is only inserted once.
*
* @returns The events to hijack the navigation.
*/
protected get navigationHijackerEvents(): XEventsOf<ArrowKey>[] {
const eventsSet = this.navigationHijacker.map(({ xEvent }) => xEvent);
return Array.from(new Set(eventsSet));
}
/**
* Trigger navigation if this component is in control of it.
*
* @param eventPayload - The {@link @empathyco/x-bus#SubjectPayload.eventPayload}.
* @param metadata - The {@link @empathyco/x-bus#SubjectPayload.metadata}.
* @public
*/
@XOn(component => (component as BaseKeyboardNavigation).navigationHijackerEvents)
triggerNavigation(eventPayload: ArrowKey, metadata: WireMetadata): void {
if (this.hasToTakeNavigationControl(eventPayload, metadata)) {
this.focusNextNavigableElement(eventPayload);
export default defineComponent({
name: 'BaseKeyboardNavigation',
props: {
/**
* An array of {@link TakeNavigationControl} objects defining when to
* take control of the keyboard navigation.
*/
navigationHijacker: {
type: Array as PropType<TakeNavigationControl[]>,
default: () => [
{ xEvent: 'UserPressedArrowKey', moduleName: 'searchBox', direction: 'ArrowDown' }
]
},
/**
* An {@link EventsForDirectionLimit} to emit when the user is already at the furthest element
* in a direction and tries to keep going on the same direction.
*/
eventsForDirectionLimit: {
type: Object as PropType<Partial<EventsForDirectionLimit>>,
default: () => ({ ArrowUp: 'UserReachedEmpathizeTop' })
}
},
setup: function (props) {
const el = ref<HTMLElement>();
const $x = use$x();
const xBus = useXBus();
/**
* The {@link SpatialNavigation} service to use.
*/
let navigationService!: SpatialNavigation;
/**
* The element to focus.
*/
let elementToFocus: HTMLElement | undefined;
/**
* Get the navigation hijacker events.
*
* @remarks
* If the same {@link XEvent} is defined multiple times it is only inserted once.
*
* @returns The events to hijack the navigation.
*/
const navigationHijackerEvents = computed((): XEventsOf<ArrowKey>[] => {
const eventsSet = props.navigationHijacker.map(({ xEvent }) => xEvent);
return Array.from(new Set(eventsSet));
});
onMounted(() => {
// TODO Replace this with injection
navigationService = new DirectionalFocusNavigationService(el.value!);
});
/**
* Checks if the component has to take control of the keyboard navigation.
*
* @param eventPayload - The {@link ArrowKey}.
* @param metadata - The {@link WireMetadata}.
*
* @returns Whether the component needs to take control of the keyboard navigation or not.
* @internal
*/
function hasToTakeNavigationControl(eventPayload: ArrowKey, metadata: WireMetadata): boolean {
return props.navigationHijacker.some(
({ moduleName, direction }) =>
moduleName === metadata.moduleName && direction === eventPayload
);
}
}
/**
* Checks if the component has to take control of the keyboard navigation.
*
* @param eventPayload - The {@link ArrowKey}.
* @param metadata - The {@link WireMetadata}.
*
* @returns Whether the component needs to take control of the keyboard navigation or not.
* @internal
*/
private hasToTakeNavigationControl(eventPayload: ArrowKey, metadata: WireMetadata): boolean {
return this.navigationHijacker.some(
({ moduleName, direction }) =>
moduleName === metadata.moduleName && direction === eventPayload
);
}
/**
* Focus the next navigable element returned by the navigation service.
*
* @param direction - The navigation direction.
* @internal
*/
protected focusNextNavigableElement(direction: ArrowKey | KeyboardEvent): void {
const dir = typeof direction === 'object' ? (direction.key as ArrowKey) : direction;
const nextElementToFocus = this.navigationService?.navigateTo(dir);
if (this.elementToFocus !== nextElementToFocus) {
this.elementToFocus = nextElementToFocus;
this.elementToFocus.focus();
} else {
this.emitDirectionalLimitReached(dir);
this.elementToFocus = undefined;
/**
* Focus the next navigable element returned by the navigation service.
*
* @param direction - The navigation direction.
* @internal
*/
function focusNextNavigableElement(direction: ArrowKey | KeyboardEvent): void {
const dir = typeof direction === 'object' ? (direction.key as ArrowKey) : direction;
const nextElementToFocus = navigationService?.navigateTo(dir);
if (elementToFocus !== nextElementToFocus) {
elementToFocus = nextElementToFocus;
elementToFocus.focus();
} else {
emitDirectionalLimitReached(dir);
elementToFocus = undefined;
}
}
}
/**
* Emit the {@link XEvent} associated to the navigation's direction when reaching its limit.
*
* @param direction - The navigation direction.
* @internal
*/
private emitDirectionalLimitReached(direction: ArrowKey): void {
const xEvent = this.eventsForDirectionLimit?.[direction];
if (xEvent) {
this.$x.emit(xEvent, undefined, { target: this.elementToFocus });
/**
* Emit the {@link XEvent} associated to the navigation's direction when reaching its limit.
*
* @param direction - The navigation direction.
* @internal
*/
function emitDirectionalLimitReached(direction: ArrowKey): void {
const xEvent = props.eventsForDirectionLimit?.[direction];
if (xEvent) {
$x.emit(xEvent, undefined, { target: elementToFocus });
}
}
/**
* Trigger navigation if this component is in control of it.
*
* @param eventPayload - The {@link @empathyco/x-bus#SubjectPayload.eventPayload}.
* @param metadata - The {@link @empathyco/x-bus#SubjectPayload.metadata}.
* @public
*/
navigationHijackerEvents.value.forEach(event => {
xBus.on(event, true).subscribe(({ eventPayload, metadata }) => {
if (hasToTakeNavigationControl(eventPayload, metadata)) {
focusNextNavigableElement(eventPayload);
}
});
});
return { el, focusNextNavigableElement };
}
}
});
</script>

<docs lang="mdx">
Expand Down

0 comments on commit 34eb986

Please sign in to comment.