Skip to content

Commit efdb1c4

Browse files
committed
feat: make it possible to navigate cells using the keyboard
closes #116
1 parent d9ba2db commit efdb1c4

File tree

6 files changed

+129
-4
lines changed

6 files changed

+129
-4
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default defineConfig([
4242
},
4343
rules: {
4444
'no-console': 'error',
45+
'vue/no-template-shadow': 'error',
4546
'vue/attribute-hyphenation': ['error', 'never'],
4647
'vue/multi-word-component-names': 'off',
4748
'vue/require-default-prop': 'off',

src/app/components/base/currency-cell/CurrencyCell.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ watch(focused, (value) => {
9494
invalid.value = true;
9595
}
9696
});
97+
98+
defineExpose({ input });
9799
</script>
98100

99101
<style lang="scss" module>

src/app/components/feature/BudgetGroup.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
@action="performAction($event, budget.id, month, budget.values[month])"
7777
>
7878
<CurrencyCell
79+
:ref="onRefCallback"
7980
:testId="`${testId}-budget-${index}-${month}`"
8081
:modelValue="budget.values[month]"
8182
@update:model-value="setBudget(budget.id, month, $event)"
@@ -105,6 +106,7 @@ import { ReorderEvent } from '@components/base/draggable/Draggable.types';
105106
import Draggable from '@components/base/draggable/Draggable.vue';
106107
import { DraggableStore } from '@components/base/draggable/store';
107108
import TextCell from '@components/base/text-cell/TextCell.vue';
109+
import { useOrderedTemplateRefs } from '@composables/useOrderedTemplateRefs.ts';
108110
import { useStateUtils } from '@composables/useStateUtils.ts';
109111
import { RiAddCircleLine, RiCloseCircleLine } from '@remixicon/vue';
110112
import { useDataStore } from '@store/state';
@@ -134,6 +136,7 @@ const {
134136
135137
const { isCurrentMonth } = useStateUtils();
136138
const { t } = useI18n();
139+
const { onRefCallback, value: currencyCells } = useOrderedTemplateRefs<InstanceType<typeof CurrencyCell>>();
137140
const focused = ref<string>();
138141
139142
const totals = computed(() => {
@@ -187,6 +190,10 @@ const performAction = (action: CellMenuActionId, budgetId: string, month: number
187190
return fillBudget(budgetId, value, month);
188191
}
189192
};
193+
194+
defineExpose({
195+
currencyCells: computed(() => currencyCells.map((v) => v.input).filter((v) => !!v) as HTMLInputElement[])
196+
});
190197
</script>
191198

192199
<style lang="scss" module>

src/app/components/feature/BudgetGroups.vue

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@
3434
/>
3535
<span :class="[$style.sum, $style.totals]">{{ t('shared.totals') }}</span>
3636
<Currency
37-
v-for="(sum, index) of totals"
37+
v-for="(value, index) of totals"
3838
:key="index"
3939
:testId="`month-${index}-total`"
40-
:value="sum"
40+
:value="value"
4141
:class="$style.sum"
4242
/>
4343
<span />
@@ -52,7 +52,7 @@
5252
name="budget-groups"
5353
@drop="reorder"
5454
/>
55-
<BudgetGroup :allowDelete="allowDelete" :group="group" :testId="`group-${index}`" />
55+
<BudgetGroup :ref="onRefCallback" :allowDelete="allowDelete" :group="group" :testId="`group-${index}`" />
5656
</template>
5757

5858
<!-- Footer -->
@@ -74,11 +74,14 @@ import Currency from '@components/base/currency/Currency.vue';
7474
import { ReorderEvent } from '@components/base/draggable/Draggable.types';
7575
import Draggable from '@components/base/draggable/Draggable.vue';
7676
import { DraggableStore } from '@components/base/draggable/store';
77+
import { useKeyboardNavigation } from '@composables/useKeyboardNavigation.ts';
7778
import { useMonthNames } from '@composables/useMonthNames.ts';
79+
import { useOrderedTemplateRefs } from '@composables/useOrderedTemplateRefs.ts';
7880
import { useStateUtils } from '@composables/useStateUtils.ts';
7981
import { RiAddCircleLine, RiLockLine, RiLockUnlockLine, RiSkipDownLine } from '@remixicon/vue';
8082
import { useSettingsStore } from '@store/settings';
8183
import { useDataStore } from '@store/state';
84+
import { sum } from '@utils/array/array.ts';
8285
import { computed, ref } from 'vue';
8386
import { useI18n } from 'vue-i18n';
8487
import type { Component } from 'vue';
@@ -88,15 +91,21 @@ const props = defineProps<{
8891
}>();
8992
9093
const months = useMonthNames('long', () => settings.general.monthOffset);
94+
const { onRefCallback, value: budgetGroups } = useOrderedTemplateRefs<InstanceType<typeof BudgetGroup>>();
9195
const { isCurrentMonth } = useStateUtils();
9296
const { state, moveBudgetGroup, moveBudgetIntoGroup, addBudgetGroup, getBudgetGroup } = useDataStore();
9397
const { state: settings } = useSettingsStore();
9498
const { t } = useI18n();
9599
96100
const allowDelete = ref(false);
97-
98101
const groups = computed(() => state[props.type]);
99102
103+
useKeyboardNavigation(() => ({
104+
inputs: budgetGroups.flatMap((v) => v.currencyCells),
105+
rows: sum(groups.value.map((v) => v.budgets.length)),
106+
cols: 12
107+
}));
108+
100109
const totals = computed(() => {
101110
const totals = new Array(12).fill(0);
102111
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { MaybeRefOrGetter, onScopeDispose, toRef, watch } from 'vue';
2+
3+
type UseKeyboardNavigationOptions = {
4+
inputs: HTMLInputElement[];
5+
rows: number;
6+
cols: number;
7+
};
8+
9+
const arrowEvents = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
10+
11+
export const useKeyboardNavigation = (options: MaybeRefOrGetter<UseKeyboardNavigationOptions>) => {
12+
const rOptions = toRef(options);
13+
const controllers = new Set<AbortController>();
14+
15+
const unbindEvents = () => {
16+
controllers.forEach((controller) => controller.abort());
17+
controllers.clear();
18+
};
19+
20+
const resolveNextIndex = (currentIndex: number, key: string, cols: number, rows: number) => {
21+
const currentRow = Math.floor(currentIndex / cols);
22+
const currentCol = currentIndex % cols;
23+
24+
switch (key) {
25+
case 'ArrowUp':
26+
return currentRow > 0 ? (currentRow - 1) * cols + currentCol : (rows - 1) * cols + currentCol;
27+
case 'ArrowDown':
28+
return currentRow < rows - 1 ? (currentRow + 1) * cols + currentCol : currentCol;
29+
case 'ArrowLeft':
30+
return currentCol > 0 ? currentRow * cols + (currentCol - 1) : currentRow * cols + (cols - 1);
31+
case 'ArrowRight':
32+
return currentCol < cols - 1 ? currentRow * cols + (currentCol + 1) : currentRow * cols;
33+
default:
34+
return currentIndex; // Ignore other keys
35+
}
36+
};
37+
38+
const onKeyDown = (event: KeyboardEvent, index: number, options: UseKeyboardNavigationOptions) => {
39+
const newIndex = resolveNextIndex(index, event.key, options.cols, options.rows);
40+
41+
if (newIndex !== index) {
42+
event.preventDefault();
43+
options.inputs[index].blur();
44+
options.inputs[newIndex].focus();
45+
}
46+
};
47+
48+
watch(rOptions, (options) => {
49+
unbindEvents();
50+
51+
options.inputs.forEach((input) => {
52+
const controller = new AbortController();
53+
controllers.add(controller);
54+
55+
input.addEventListener(
56+
'keydown',
57+
(event) => {
58+
if (!arrowEvents.includes(event.key)) return;
59+
60+
const sortedInputs = options.inputs.toSorted((a, b) => {
61+
const position = a.compareDocumentPosition(b);
62+
63+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
64+
return -1;
65+
} else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
66+
return 1;
67+
}
68+
69+
return 0;
70+
});
71+
72+
const index = sortedInputs.indexOf(input);
73+
74+
if (index !== -1) {
75+
onKeyDown(event, index, { ...options, inputs: sortedInputs });
76+
}
77+
},
78+
{ signal: controller.signal }
79+
);
80+
});
81+
});
82+
83+
onScopeDispose(unbindEvents);
84+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { readonly, shallowReactive } from 'vue';
2+
3+
export const useOrderedTemplateRefs = <T>() => {
4+
const refs = shallowReactive<T[]>([]);
5+
6+
const onRefCallback = (el: T | null) => {
7+
if (el && !refs.includes(el)) {
8+
refs.push(el);
9+
} else if (!el) {
10+
const index = refs.findIndex((r) => r === el);
11+
12+
if (index !== -1) {
13+
refs.splice(index, 1);
14+
}
15+
}
16+
};
17+
18+
return {
19+
onRefCallback,
20+
value: readonly(refs)
21+
};
22+
};

0 commit comments

Comments
 (0)