Implement keyboard navigation in user list.

This commit is contained in:
Richard Lewis 2018-07-10 23:29:53 +03:00 committed by Pavel Djundik
parent 30bdfe9d3f
commit 060097c118
4 changed files with 94 additions and 3 deletions

View File

@ -1,13 +1,22 @@
<template> <template>
<aside class="userlist"> <aside
ref="userlist"
class="userlist"
>
<div class="count"> <div class="count">
<input <input
ref="input"
:placeholder="channel.users.length + ' user' + (channel.users.length === 1 ? '' : 's')" :placeholder="channel.users.length + ' user' + (channel.users.length === 1 ? '' : 's')"
v-model="userSearchInput" v-model="userSearchInput"
type="search" type="search"
class="search" class="search"
aria-label="Search among the user list" aria-label="Search among the user list"
tabindex="-1" tabindex="-1"
@keydown.up="navigateUserList(-1)"
@keydown.down="navigateUserList(1)"
@keydown.page-up="navigateUserList(-10)"
@keydown.page-down="navigateUserList(10)"
@keydown.enter="selectUser"
> >
</div> </div>
<div class="names"> <div class="names">
@ -20,12 +29,14 @@
<UsernameFiltered <UsernameFiltered
v-for="user in users" v-for="user in users"
:key="user.original.nick" :key="user.original.nick"
:active="user.original === activeUser"
:user="user"/> :user="user"/>
</template> </template>
<template v-else> <template v-else>
<Username <Username
v-for="user in users" v-for="user in users"
:key="user.nick" :key="user.nick"
:active="user === activeUser"
:user="user"/> :user="user"/>
</template> </template>
</div> </div>
@ -60,6 +71,7 @@ export default {
data() { data() {
return { return {
userSearchInput: "", userSearchInput: "",
activeUser: null,
}; };
}, },
computed: { computed: {
@ -101,6 +113,78 @@ export default {
getModeClass(mode) { getModeClass(mode) {
return modes[mode]; return modes[mode];
}, },
selectUser() {
// Simulate a click on the active user to open the context menu.
// Coordinates are provided to position the menu correctly.
if (!this.activeUser) {
return;
}
const el = this.$refs.userlist.querySelector(".active");
const rect = el.getBoundingClientRect();
const ev = new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: true,
clientX: rect.x,
clientY: rect.y + rect.height,
});
el.dispatchEvent(ev);
},
navigateUserList(direction) {
let users = this.channel.users;
// If a search is active, get the matching user objects
// TODO: this could probably be cached via `computed`
// to avoid refiltering on each keypress
if (this.userSearchInput) {
const results = fuzzy.filter(
this.userSearchInput,
this.channel.users,
{
extract: (u) => u.nick,
}
);
users = results.map((result) => result.original);
}
// Bail out if there's no users to select
if (!users.length) {
this.activeUser = null;
return;
}
let currentIndex = users.indexOf(this.activeUser);
// If there's no active user select the first or last one depending on direction
if (!this.activeUser || currentIndex === -1) {
this.activeUser = direction ? users[0] : users[users.length - 1];
this.scrollToActiveUser();
return;
}
currentIndex += direction;
// Wrap around the list if necessary. Normaly each loop iterates once at most,
// but might iterate more often if pgup or pgdown are used in a very short user list
while (currentIndex < 0) {
currentIndex += users.length;
}
while (currentIndex > users.length - 1) {
currentIndex -= users.length;
}
this.activeUser = users[currentIndex];
this.scrollToActiveUser();
},
scrollToActiveUser() {
// Scroll the list if needed after the active class is applied
this.$nextTick(() => {
const el = this.$refs.userlist.querySelector(".active");
el.scrollIntoView({block: "nearest", inline: "nearest"});
});
},
}, },
}; };
</script> </script>

View File

@ -1,6 +1,6 @@
<template> <template>
<span <span
:class="['user', $options.filters.colorClass(user.nick)]" :class="['user', $options.filters.colorClass(user.nick), active ? 'active' : '']"
:data-name="user.nick" :data-name="user.nick"
role="button">{{ user.mode }}{{ user.nick }}</span> role="button">{{ user.mode }}{{ user.nick }}</span>
</template> </template>
@ -10,6 +10,7 @@ export default {
name: "Username", name: "Username",
props: { props: {
user: Object, user: Object,
active: Boolean,
}, },
}; };
</script> </script>

View File

@ -1,6 +1,6 @@
<template> <template>
<span <span
:class="['user', $options.filters.colorClass(user.original.nick)]" :class="['user', $options.filters.colorClass(user.original.nick), active ? 'active' : '']"
:data-name="user.original.nick" :data-name="user.original.nick"
role="button" role="button"
v-html="user.original.mode + user.string"/> v-html="user.original.mode + user.string"/>
@ -11,6 +11,7 @@ export default {
name: "UsernameFiltered", name: "UsernameFiltered",
props: { props: {
user: Object, user: Object,
active: Boolean,
}, },
}; };
</script> </script>

View File

@ -20,6 +20,11 @@ Vue.filter("friendlysize", friendlysize);
Vue.filter("colorClass", colorClass); Vue.filter("colorClass", colorClass);
Vue.filter("roundBadgeNumber", roundBadgeNumber); Vue.filter("roundBadgeNumber", roundBadgeNumber);
Vue.config.keyCodes = {
"page-up": 33,
"page-down": 34,
};
const vueApp = new Vue({ const vueApp = new Vue({
el: "#viewport", el: "#viewport",
data: { data: {