Implement keyboard navigation in user list.
This commit is contained in:
parent
30bdfe9d3f
commit
060097c118
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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: {
|
||||||
|
Loading…
Reference in New Issue
Block a user