Skip to content

Commit ac135cb

Browse files
committed
Add arrow navigation
1 parent 00bfa51 commit ac135cb

File tree

8 files changed

+259
-6
lines changed

8 files changed

+259
-6
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@tauri-apps/api": "^1.2.0",
15+
"@vueuse/core": "^9.12.0",
1516
"pinia": "^2.0.29",
1617
"redaxios": "^0.5.1",
1718
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store",
@@ -32,6 +33,8 @@
3233
"async-mutex": "^0.4.0",
3334
"eslint": "^8.32.0",
3435
"execa": "^6.1.0",
36+
"focus-visible": "^5.2.0",
37+
"hotkeys-js": "^3.10.1",
3538
"npm-run-all": "^4.1.5",
3639
"sass": "^1.57.1",
3740
"tsx": "^3.12.3",

pnpm-lock.yaml

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/assets/mixins.scss

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44
mask-image: -webkit-radial-gradient(white, black);
55
}
66

7+
@mixin text-outline($size: 1px) {
8+
text-shadow: 0 0 $size black, 0 0 $size black, 0 0 $size black, 0 0 $size black;
9+
}
10+
711
@mixin focus-visible {
8-
&:focus-visible {
12+
&:not([disabled])[data-focus-visible-added] {
913
box-shadow: 0px 0px 0px 1px var(--white-faded);
1014
}
1115
}
1216

13-
@mixin text-outline($size: 1px) {
14-
text-shadow: 0 0 $size black, 0 0 $size black, 0 0 $size black, 0 0 $size black;
17+
@mixin focus-visible-content {
18+
&:not([disabled])[data-focus-visible-added] {
19+
@content;
20+
}
1521
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Ref } from 'vue'
2+
import { onMounted, onUpdated, shallowRef } from 'vue'
3+
import type { Option } from '../types'
4+
import { type UseKeyOptions, useKey } from './useKey'
5+
6+
export interface UseElementNavigationOptions {
7+
target: Ref<Option<HTMLElement>>
8+
targetQuery: string
9+
navigateNextHotkey: string
10+
navigatePreviousHotkey: string
11+
}
12+
13+
export enum Navigation {
14+
Next,
15+
Previous,
16+
}
17+
18+
const getFocusedItemIndex = (elements: HTMLElement[]) => elements
19+
.findIndex(item => document.activeElement === item)
20+
21+
const hotkeyOptions: UseKeyOptions = { prevent: true, repeat: true }
22+
23+
export function useElementNavigation({
24+
navigateNextHotkey,
25+
navigatePreviousHotkey,
26+
target,
27+
targetQuery,
28+
}: UseElementNavigationOptions) {
29+
const elements = shallowRef<HTMLElement[]>([])
30+
31+
function queryNotificationItems() {
32+
elements.value = Array.from(target.value?.querySelectorAll(targetQuery) || [])
33+
}
34+
35+
onMounted(queryNotificationItems)
36+
onUpdated(queryNotificationItems)
37+
38+
function focusItemInDirection(navigation: Navigation) {
39+
let currentIndex = getFocusedItemIndex(elements.value)
40+
41+
if (navigation === Navigation.Next && currentIndex < elements.value.length - 1)
42+
currentIndex++
43+
else if (navigation === Navigation.Previous && currentIndex > 0)
44+
currentIndex--
45+
46+
const element = elements.value[currentIndex]
47+
element.focus()
48+
element.scrollIntoView({
49+
inline: 'nearest',
50+
block: 'nearest',
51+
})
52+
}
53+
54+
useKey(
55+
navigateNextHotkey,
56+
() => focusItemInDirection(Navigation.Next),
57+
hotkeyOptions,
58+
)
59+
useKey(
60+
navigatePreviousHotkey,
61+
() => focusItemInDirection(Navigation.Previous),
62+
hotkeyOptions,
63+
)
64+
}

src/composables/useKey.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import hotkeys, { type HotkeysEvent } from 'hotkeys-js'
2+
import { type Ref, onMounted, onUnmounted, unref, watch } from 'vue'
3+
4+
type MaybeRef<T> = T | Ref<T>
5+
6+
export interface UseKeyOptions {
7+
prevent?: MaybeRef<boolean>
8+
stop?: MaybeRef<boolean>
9+
repeat?: MaybeRef<boolean>
10+
input?: MaybeRef<boolean>
11+
source?: (() => boolean) | Ref<boolean>
12+
}
13+
14+
export type UseKeyCallback = (event: KeyboardEvent, hotkeysEvent: HotkeysEvent) => void
15+
16+
const getLast = <T>(arr: T[]) => arr[arr.length - 1]
17+
18+
const isInputing = () =>
19+
document.activeElement instanceof HTMLTextAreaElement
20+
|| document.activeElement?.hasAttribute('contenteditable')
21+
|| document.activeElement instanceof HTMLInputElement
22+
|| document.activeElement instanceof HTMLSelectElement
23+
24+
hotkeys.filter = () => true
25+
26+
const bindings = new Map<string, UseKeyCallback[]>()
27+
28+
/**
29+
* @source https://github.com/kadiryazici/use-key-composable-vue3/blob/main/src/composables/useKey.ts
30+
*/
31+
export function useKey(
32+
keys: string,
33+
callback: UseKeyCallback,
34+
{
35+
source, //
36+
input = false,
37+
prevent = false,
38+
repeat = false,
39+
stop = false,
40+
}: UseKeyOptions = {},
41+
) {
42+
let initialized = false
43+
44+
const keyList = keys
45+
.split(',')
46+
.map(key => key.trim())
47+
.filter(Boolean)
48+
49+
const handler: UseKeyCallback = (event, hotkeysEvent) => {
50+
if (!unref(input) && isInputing())
51+
return
52+
if (unref(prevent))
53+
event.preventDefault()
54+
if (unref(stop))
55+
event.stopPropagation()
56+
if (!unref(repeat) && event.repeat)
57+
return
58+
59+
callback(event, hotkeysEvent)
60+
}
61+
62+
const init = () => {
63+
if (initialized)
64+
return
65+
66+
initialized = true
67+
68+
for (const key of keyList) {
69+
if (bindings.has(key)) {
70+
bindings.set(key, [...bindings.get(key)!, handler])
71+
}
72+
else {
73+
bindings.set(key, [handler])
74+
hotkeys(key, (...args) => {
75+
const func = getLast(bindings.get(key)!)
76+
func(...args)
77+
})
78+
}
79+
}
80+
}
81+
82+
const destroy = () => {
83+
if (!initialized)
84+
return
85+
86+
initialized = false
87+
88+
for (const key of keyList) {
89+
bindings.set(
90+
key,
91+
bindings.get(key)!.filter(cb => cb !== handler),
92+
)
93+
if (bindings.get(key)!.length === 0) {
94+
bindings.delete(key)
95+
hotkeys.unbind(key)
96+
}
97+
}
98+
}
99+
100+
if (source) {
101+
watch(
102+
source,
103+
(newSourceValue) => {
104+
if (newSourceValue)
105+
init()
106+
else destroy()
107+
},
108+
{ immediate: true, flush: 'post' },
109+
)
110+
}
111+
else {
112+
onMounted(init)
113+
}
114+
115+
onUnmounted(destroy)
116+
}

src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { createApp } from 'vue'
21
import './assets/main.scss'
2+
import 'focus-visible'
3+
4+
import { createApp } from 'vue'
35
import { createPinia } from 'pinia'
46
import App from './App.vue'
57
import { AppStorage, cacheStorageFromDisk } from './storage'

src/pages/HomePage.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
<script lang="ts">
2+
</script>
3+
14
<script lang="ts" setup>
25
import { open } from '@tauri-apps/api/shell'
6+
import { onMounted, onUpdated, ref, shallowRef } from 'vue'
37
import { useStore } from '../stores/store'
48
import NotificationList from '../components/NotificationList.vue'
59
import { useInterval } from '../composables/useInterval'
@@ -8,6 +12,8 @@ import { toGithubWebURL } from '../utils/github'
812
import { AppStorage } from '../storage'
913
import NotificationSkeleton from '../components/NotificationSkeleton.vue'
1014
import { Page } from '../constants'
15+
import type { Option } from '../types'
16+
import { useElementNavigation } from '../composables/useElementNavigation'
1117
1218
const store = useStore()
1319
@@ -22,10 +28,22 @@ function handleNotificationClick(notification: Thread) {
2228
function handleRepoClick(repoFullName: string) {
2329
open(`https://github.com/${repoFullName}`)
2430
}
31+
32+
const home = ref<Option<HTMLElement>>(null)
33+
34+
useElementNavigation({
35+
target: home,
36+
navigateNextHotkey: 'down',
37+
navigatePreviousHotkey: 'up',
38+
targetQuery: '.notification-item, .notification-title',
39+
})
2540
</script>
2641

2742
<template>
28-
<div class="home">
43+
<div
44+
ref="home"
45+
class="home"
46+
>
2947
<NotificationSkeleton v-if="store.skeletonVisible" />
3048

3149
<template v-else>

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type { Raw } from 'vue'
1+
import type { Raw, Ref } from 'vue'
22
import type { Thread } from './api/notifications'
33
import type { User } from './api/user'
44

55
export type Option<T> = T | null
6+
export type MaybeRef<T> = T | Ref<T>
67

78
export interface NotificationListData {
89
repoFullName: string

0 commit comments

Comments
 (0)