arrowtab 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +5 -0
- package/.vscode/settings.json +4 -0
- package/README.md +35 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +177 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lib/getFocusable.d.mts +1 -0
- package/dist/lib/getFocusable.mjs +15 -0
- package/dist/lib/getFocusable.mjs.map +1 -0
- package/dist/lib/positions.d.mts +8 -0
- package/dist/lib/positions.mjs +12 -0
- package/dist/lib/positions.mjs.map +1 -0
- package/dist/lib/strategies.d.ts +26 -0
- package/dist/lib/strategies.js +48 -0
- package/dist/lib/strategies.js.map +1 -0
- package/dist/lib/sumBy.d.mts +1 -0
- package/dist/lib/sumBy.mjs +2 -0
- package/dist/lib/sumBy.mjs.map +1 -0
- package/index.html +142 -0
- package/package.json +22 -0
- package/src/index.mts +228 -0
- package/src/lib/getFocusable.mts +13 -0
- package/src/lib/positions.mts +12 -0
- package/src/lib/strategies.ts +62 -0
- package/src/lib/sumBy.mts +4 -0
- package/tsconfig.json +20 -0
package/.prettierrc
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ArrowTab
|
|
2
|
+
|
|
3
|
+
**ArrowTab** allows you to navigate a webpage using the arrow keys, automatically selecting the nearest focusable element. This enables fast and efficient keyboard-only navigation across the page.
|
|
4
|
+
|
|
5
|
+
ArrowTab is particularly **useful for business applications**, where power users need to prioritize **high productivity**.
|
|
6
|
+
|
|
7
|
+
Instead of hitting `Tab` a thousand times, you can simply use the arrow keys to indicate the direction and jump to the next input, button, or link you want to select.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
pnpm:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm install arrowtab
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
yarn:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
yarn add arrowtab
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
npm:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install arrowtab
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
import { initArrowTab } from 'arrowtab'
|
|
33
|
+
|
|
34
|
+
initArrowTab()
|
|
35
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const initArrowTab: () => void;
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// TODO: Options
|
|
2
|
+
// modifierKey: 'ctrlKey',
|
|
3
|
+
// onlyWithModifierKey: false, // TODO: Better name
|
|
4
|
+
// strategy: arrowTab.XWalkEuclidean,
|
|
5
|
+
// selector: undefined,
|
|
6
|
+
// elementFilter: () => boolean,
|
|
7
|
+
// getElementPosition: (element) => { x: number, y: number },
|
|
8
|
+
// eventTriggerName: 'keydown' | 'keyup',
|
|
9
|
+
// earlyReturn: (event) => boolean, // TODO: Better name
|
|
10
|
+
import { getFocusable } from './lib/getFocusable.mjs';
|
|
11
|
+
import { getMiddle } from './lib/positions.mjs';
|
|
12
|
+
import { strategy } from './lib/strategies.js';
|
|
13
|
+
import { sumBy } from './lib/sumBy.mjs';
|
|
14
|
+
export const initArrowTab = () => {
|
|
15
|
+
document.addEventListener('keydown', (event) => {
|
|
16
|
+
const activeStrategy = strategy.XWalkEuclidean;
|
|
17
|
+
const activeElement = document.activeElement === document.body
|
|
18
|
+
? getFocusable()?.[0]
|
|
19
|
+
: document.activeElement;
|
|
20
|
+
if (!activeElement) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (event.key !== 'ArrowLeft' &&
|
|
24
|
+
event.key !== 'ArrowRight' &&
|
|
25
|
+
event.key !== 'ArrowUp' &&
|
|
26
|
+
event.key !== 'ArrowDown') {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (activeElement instanceof HTMLInputElement ||
|
|
30
|
+
activeElement instanceof HTMLTextAreaElement) {
|
|
31
|
+
const selectionStart = activeElement.selectionStart;
|
|
32
|
+
const selectionEnd = activeElement.selectionEnd;
|
|
33
|
+
if (event.key === 'ArrowLeft' &&
|
|
34
|
+
selectionStart !== undefined &&
|
|
35
|
+
selectionStart !== null &&
|
|
36
|
+
selectionStart !== 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (event.key === 'ArrowRight' &&
|
|
40
|
+
selectionStart !== undefined &&
|
|
41
|
+
selectionStart !== null &&
|
|
42
|
+
selectionStart !== activeElement.value?.length) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
|
|
46
|
+
selectionStart !== undefined &&
|
|
47
|
+
selectionEnd !== undefined &&
|
|
48
|
+
selectionStart !== null &&
|
|
49
|
+
selectionEnd !== null &&
|
|
50
|
+
selectionStart !== selectionEnd) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (event.key === 'ArrowUp' &&
|
|
54
|
+
selectionStart !== undefined &&
|
|
55
|
+
selectionStart !== null &&
|
|
56
|
+
activeElement instanceof HTMLTextAreaElement) {
|
|
57
|
+
const rows = activeElement.value?.split('\n');
|
|
58
|
+
const start = selectionStart;
|
|
59
|
+
const firstRow = rows.at(0);
|
|
60
|
+
if (firstRow && start > firstRow.length) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (event.key === 'ArrowDown' &&
|
|
65
|
+
selectionStart !== undefined &&
|
|
66
|
+
selectionStart !== null &&
|
|
67
|
+
activeElement instanceof HTMLTextAreaElement) {
|
|
68
|
+
const rows = activeElement.value?.split('\n');
|
|
69
|
+
const withoutLastRow = rows.slice(0, -1);
|
|
70
|
+
const lengthUntilLastRow = sumBy(withoutLastRow, (row) => row.length);
|
|
71
|
+
const start = selectionStart;
|
|
72
|
+
if (start <= lengthUntilLastRow + 1) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const inputTypesThatNeedModifierKey = {
|
|
78
|
+
date: ['ArrowDown', 'ArrowUp'],
|
|
79
|
+
time: ['ArrowDown', 'ArrowUp'],
|
|
80
|
+
datetime: ['ArrowDown', 'ArrowUp'],
|
|
81
|
+
'datetime-local': ['ArrowDown', 'ArrowUp'],
|
|
82
|
+
month: ['ArrowDown', 'ArrowUp'],
|
|
83
|
+
week: ['ArrowDown', 'ArrowUp'],
|
|
84
|
+
number: ['ArrowDown', 'ArrowUp'],
|
|
85
|
+
range: ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'],
|
|
86
|
+
};
|
|
87
|
+
if (activeElement instanceof HTMLInputElement) {
|
|
88
|
+
const inputType = activeElement.type;
|
|
89
|
+
if (inputTypesThatNeedModifierKey[inputType] &&
|
|
90
|
+
inputTypesThatNeedModifierKey[inputType].includes(event.key)) {
|
|
91
|
+
// TODO: Use modifierKey
|
|
92
|
+
if (!event.shiftKey) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const activePosition = getMiddle(activeElement);
|
|
101
|
+
const allFocusable = getFocusable();
|
|
102
|
+
const withoutActiveElement = allFocusable.filter((element) => element !== activeElement);
|
|
103
|
+
const withPosition = withoutActiveElement.map((element) => {
|
|
104
|
+
const position = getMiddle(element);
|
|
105
|
+
// Calculate angle between active element and focusable element
|
|
106
|
+
const xDistance = position.x - activePosition.x;
|
|
107
|
+
const yDistance = position.y - activePosition.y;
|
|
108
|
+
let angle = Math.acos(yDistance / Math.sqrt(xDistance ** 2 + yDistance ** 2)) *
|
|
109
|
+
(180 / Math.PI);
|
|
110
|
+
const direction = xDistance > 0 ? 'right' : 'left';
|
|
111
|
+
if (direction === 'right') {
|
|
112
|
+
angle = 360 - angle;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
xDistance,
|
|
116
|
+
yDistance,
|
|
117
|
+
element,
|
|
118
|
+
angle,
|
|
119
|
+
position,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
const withDistance = withPosition.map((focusable) => {
|
|
123
|
+
const distance = activeStrategy.distanceMetric({ ...focusable, event });
|
|
124
|
+
return {
|
|
125
|
+
...focusable,
|
|
126
|
+
distance,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
const withWithinReach = withDistance.map((focusable) => {
|
|
130
|
+
const withinReach = activeStrategy.withinReachMetric({
|
|
131
|
+
...focusable,
|
|
132
|
+
event,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
...focusable,
|
|
136
|
+
withinReach,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
const reversed = [...withWithinReach].reverse(); // Prefer down / right if same distance
|
|
140
|
+
const sorted = reversed.sort((a, b) => {
|
|
141
|
+
return a.distance - b.distance;
|
|
142
|
+
});
|
|
143
|
+
const withinReach = sorted.filter(({ withinReach }) => withinReach);
|
|
144
|
+
let nearest = withinReach.at(0);
|
|
145
|
+
if (!nearest) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// const nearlySameDistance = _.filter(candidates, (candidate) => {
|
|
149
|
+
// return candidate.distance - nearest.distance < 10
|
|
150
|
+
// })
|
|
151
|
+
// console.log('nearlySameDistance', nearlySameDistance)
|
|
152
|
+
// if (nearlySameDistance.length > 1) {
|
|
153
|
+
// const ordered = _.orderBy(nearlySameDistance, (candidate) => {
|
|
154
|
+
// if (event.key === 'ArrowDown') {
|
|
155
|
+
// console.log('ArrowDown', candidate.xDistance)
|
|
156
|
+
// return -candidate.xDistance
|
|
157
|
+
// }
|
|
158
|
+
// if (event.key === 'ArrowUp') {
|
|
159
|
+
// return candidate.xDistance
|
|
160
|
+
// }
|
|
161
|
+
// if (event.key === 'ArrowLeft') {
|
|
162
|
+
// return candidate.yDistance
|
|
163
|
+
// }
|
|
164
|
+
// if (event.key === 'ArrowRight') {
|
|
165
|
+
// console.log('ArrowRight', candidate.yDistance)
|
|
166
|
+
// return -candidate.yDistance
|
|
167
|
+
// }
|
|
168
|
+
// })
|
|
169
|
+
// console.log('ordered', ordered)
|
|
170
|
+
// nearest = _.first(ordered)
|
|
171
|
+
// }
|
|
172
|
+
if (nearest.element instanceof HTMLElement) {
|
|
173
|
+
nearest.element.focus();
|
|
174
|
+
}
|
|
175
|
+
}, { capture: true });
|
|
176
|
+
};
|
|
177
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAAA,gBAAgB;AAChB,0BAA0B;AAC1B,mDAAmD;AACnD,qCAAqC;AACrC,uBAAuB;AACvB,gCAAgC;AAChC,6DAA6D;AAC7D,yCAAyC;AACzC,wDAAwD;AAExD,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAC9C,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAA;AAEvC,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,EAAE;IAC/B,QAAQ,CAAC,gBAAgB,CACvB,SAAS,EACT,CAAC,KAAK,EAAE,EAAE;QACR,MAAM,cAAc,GAAG,QAAQ,CAAC,cAAc,CAAA;QAE9C,MAAM,aAAa,GACjB,QAAQ,CAAC,aAAa,KAAK,QAAQ,CAAC,IAAI;YACtC,CAAC,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,CAAC;YACrB,CAAC,CAAC,QAAQ,CAAC,aAAa,CAAA;QAE5B,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,OAAM;QACR,CAAC;QAED,IACE,KAAK,CAAC,GAAG,KAAK,WAAW;YACzB,KAAK,CAAC,GAAG,KAAK,YAAY;YAC1B,KAAK,CAAC,GAAG,KAAK,SAAS;YACvB,KAAK,CAAC,GAAG,KAAK,WAAW,EACzB,CAAC;YACD,OAAM;QACR,CAAC;QAED,IACE,aAAa,YAAY,gBAAgB;YACzC,aAAa,YAAY,mBAAmB,EAC5C,CAAC;YACD,MAAM,cAAc,GAAG,aAAa,CAAC,cAAc,CAAA;YACnD,MAAM,YAAY,GAAG,aAAa,CAAC,YAAY,CAAA;YAE/C,IACE,KAAK,CAAC,GAAG,KAAK,WAAW;gBACzB,cAAc,KAAK,SAAS;gBAC5B,cAAc,KAAK,IAAI;gBACvB,cAAc,KAAK,CAAC,EACpB,CAAC;gBACD,OAAM;YACR,CAAC;YAED,IACE,KAAK,CAAC,GAAG,KAAK,YAAY;gBAC1B,cAAc,KAAK,SAAS;gBAC5B,cAAc,KAAK,IAAI;gBACvB,cAAc,KAAK,aAAa,CAAC,KAAK,EAAE,MAAM,EAC9C,CAAC;gBACD,OAAM;YACR,CAAC;YAED,IACE,CAAC,KAAK,CAAC,GAAG,KAAK,WAAW,IAAI,KAAK,CAAC,GAAG,KAAK,YAAY,CAAC;gBACzD,cAAc,KAAK,SAAS;gBAC5B,YAAY,KAAK,SAAS;gBAC1B,cAAc,KAAK,IAAI;gBACvB,YAAY,KAAK,IAAI;gBACrB,cAAc,KAAK,YAAY,EAC/B,CAAC;gBACD,OAAM;YACR,CAAC;YAED,IACE,KAAK,CAAC,GAAG,KAAK,SAAS;gBACvB,cAAc,KAAK,SAAS;gBAC5B,cAAc,KAAK,IAAI;gBACvB,aAAa,YAAY,mBAAmB,EAC5C,CAAC;gBACD,MAAM,IAAI,GAAG,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;gBAC7C,MAAM,KAAK,GAAG,cAAc,CAAA;gBAC5B,MAAM,QAAQ,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;gBAC3B,IAAI,QAAQ,IAAI,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC;oBACxC,OAAM;gBACR,CAAC;YACH,CAAC;YAED,IACE,KAAK,CAAC,GAAG,KAAK,WAAW;gBACzB,cAAc,KAAK,SAAS;gBAC5B,cAAc,KAAK,IAAI;gBACvB,aAAa,YAAY,mBAAmB,EAC5C,CAAC;gBACD,MAAM,IAAI,GAAG,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;gBAC7C,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;gBACxC,MAAM,kBAAkB,GAAG,KAAK,CAAC,cAAc,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;gBAErE,MAAM,KAAK,GAAG,cAAc,CAAA;gBAC5B,IAAI,KAAK,IAAI,kBAAkB,GAAG,CAAC,EAAE,CAAC;oBACpC,OAAM;gBACR,CAAC;YACH,CAAC;QACH,CAAC;QAED,MAAM,6BAA6B,GAA6B;YAC9D,IAAI,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAC9B,IAAI,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAC9B,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAClC,gBAAgB,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAC1C,KAAK,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAC/B,IAAI,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAC9B,MAAM,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;YAChC,KAAK,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,CAAC;SAC3D,CAAA;QAED,IAAI,aAAa,YAAY,gBAAgB,EAAE,CAAC;YAC9C,MAAM,SAAS,GAAG,aAAa,CAAC,IAAI,CAAA;YACpC,IACE,6BAA6B,CAAC,SAAS,CAAC;gBACxC,6BAA6B,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,EAC5D,CAAC;gBACD,wBAAwB;gBACxB,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;oBACpB,OAAM;gBACR,CAAC;qBAAM,CAAC;oBACN,KAAK,CAAC,cAAc,EAAE,CAAA;gBACxB,CAAC;YACH,CAAC;QACH,CAAC;QAED,MAAM,cAAc,GAAG,SAAS,CAAC,aAAa,CAAC,CAAA;QAE/C,MAAM,YAAY,GAAG,YAAY,EAAE,CAAA;QACnC,MAAM,oBAAoB,GAAG,YAAY,CAAC,MAAM,CAC9C,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,aAAa,CACvC,CAAA;QACD,MAAM,YAAY,GAAG,oBAAoB,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;YACxD,MAAM,QAAQ,GAAG,SAAS,CAAC,OAAO,CAAC,CAAA;YAEnC,+DAA+D;YAC/D,MAAM,SAAS,GAAG,QAAQ,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAA;YAC/C,MAAM,SAAS,GAAG,QAAQ,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAA;YAC/C,IAAI,KAAK,GACP,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,GAAG,SAAS,IAAI,CAAC,CAAC,CAAC;gBACjE,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;YACjB,MAAM,SAAS,GAAG,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAA;YAClD,IAAI,SAAS,KAAK,OAAO,EAAE,CAAC;gBAC1B,KAAK,GAAG,GAAG,GAAG,KAAK,CAAA;YACrB,CAAC;YAED,OAAO;gBACL,SAAS;gBACT,SAAS;gBACT,OAAO;gBACP,KAAK;gBACL,QAAQ;aACT,CAAA;QACH,CAAC,CAAC,CAAA;QACF,MAAM,YAAY,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;YAClD,MAAM,QAAQ,GAAG,cAAc,CAAC,cAAc,CAAC,EAAE,GAAG,SAAS,EAAE,KAAK,EAAE,CAAC,CAAA;YAEvE,OAAO;gBACL,GAAG,SAAS;gBACZ,QAAQ;aACT,CAAA;QACH,CAAC,CAAC,CAAA;QACF,MAAM,eAAe,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;YACrD,MAAM,WAAW,GAAG,cAAc,CAAC,iBAAiB,CAAC;gBACnD,GAAG,SAAS;gBACZ,KAAK;aACN,CAAC,CAAA;YAEF,OAAO;gBACL,GAAG,SAAS;gBACZ,WAAW;aACZ,CAAA;QACH,CAAC,CAAC,CAAA;QAEF,MAAM,QAAQ,GAAG,CAAC,GAAG,eAAe,CAAC,CAAC,OAAO,EAAE,CAAA,CAAC,uCAAuC;QAEvF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;YACpC,OAAO,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAA;QAChC,CAAC,CAAC,CAAA;QAEF,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC,WAAW,CAAC,CAAA;QAEnE,IAAI,OAAO,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;QAE/B,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,mEAAmE;QACnE,sDAAsD;QACtD,KAAK;QAEL,wDAAwD;QAExD,uCAAuC;QACvC,mEAAmE;QACnE,uCAAuC;QACvC,sDAAsD;QACtD,oCAAoC;QACpC,QAAQ;QACR,qCAAqC;QACrC,mCAAmC;QACnC,QAAQ;QACR,uCAAuC;QACvC,mCAAmC;QACnC,QAAQ;QACR,wCAAwC;QACxC,uDAAuD;QACvD,oCAAoC;QACpC,QAAQ;QACR,OAAO;QACP,oCAAoC;QACpC,+BAA+B;QAC/B,IAAI;QAEJ,IAAI,OAAO,CAAC,OAAO,YAAY,WAAW,EAAE,CAAC;YAC3C,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,CAAA;QACzB,CAAC;IACH,CAAC,EACD,EAAE,OAAO,EAAE,IAAI,EAAE,CAClB,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getFocusable: () => Element[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const getFocusable = () => {
|
|
2
|
+
// Selectors from https://zellwk.com/blog/keyboard-focusable-elements/
|
|
3
|
+
const allFocusable = document.querySelectorAll('a[href], button, input, textarea, select, details, [tabindex]:not([tabindex^="-"])');
|
|
4
|
+
const focusable = Array.from(allFocusable).filter((element) => {
|
|
5
|
+
if ('disabled' in element && element.disabled)
|
|
6
|
+
return false;
|
|
7
|
+
if ('hidden' in element && element.hidden)
|
|
8
|
+
return false;
|
|
9
|
+
if (element.getAttribute('aria-hidden') === 'true')
|
|
10
|
+
return false;
|
|
11
|
+
return true;
|
|
12
|
+
});
|
|
13
|
+
return focusable;
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=getFocusable.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"getFocusable.mjs","sourceRoot":"","sources":["../../src/lib/getFocusable.mts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,EAAE;IAC/B,sEAAsE;IACtE,MAAM,YAAY,GAAG,QAAQ,CAAC,gBAAgB,CAC5C,oFAAoF,CACrF,CAAA;IACD,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE;QAC5D,IAAI,UAAU,IAAI,OAAO,IAAI,OAAO,CAAC,QAAQ;YAAE,OAAO,KAAK,CAAA;QAC3D,IAAI,QAAQ,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM;YAAE,OAAO,KAAK,CAAA;QACvD,IAAI,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,KAAK,MAAM;YAAE,OAAO,KAAK,CAAA;QAChE,OAAO,IAAI,CAAA;IACb,CAAC,CAAC,CAAA;IACF,OAAO,SAAS,CAAA;AAClB,CAAC,CAAA"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const getMiddle = (element) => {
|
|
2
|
+
const { x, y, width, height } = element.getBoundingClientRect();
|
|
3
|
+
return {
|
|
4
|
+
x: x + width / 2,
|
|
5
|
+
y: y + height / 2,
|
|
6
|
+
};
|
|
7
|
+
};
|
|
8
|
+
export const getTopLeft = (element) => {
|
|
9
|
+
const { x, y } = element.getBoundingClientRect();
|
|
10
|
+
return { x, y };
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=positions.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"positions.mjs","sourceRoot":"","sources":["../../src/lib/positions.mts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,OAAgB,EAAE,EAAE;IAC5C,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAA;IAC/D,OAAO;QACL,CAAC,EAAE,CAAC,GAAG,KAAK,GAAG,CAAC;QAChB,CAAC,EAAE,CAAC,GAAG,MAAM,GAAG,CAAC;KAClB,CAAA;AACH,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,OAAgB,EAAE,EAAE;IAC7C,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAA;IAChD,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,CAAA;AACjB,CAAC,CAAA"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type StrategyInput = {
|
|
2
|
+
event: KeyboardEvent;
|
|
3
|
+
xDistance: number;
|
|
4
|
+
yDistance: number;
|
|
5
|
+
element: Element;
|
|
6
|
+
angle: number;
|
|
7
|
+
position: {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export declare const strategy: {
|
|
13
|
+
XWalkEuclidean: {
|
|
14
|
+
withinReachMetric: ({ angle, event }: StrategyInput) => boolean;
|
|
15
|
+
distanceMetric: ({ xDistance, yDistance }: StrategyInput) => number;
|
|
16
|
+
};
|
|
17
|
+
XWalkManhattan: {
|
|
18
|
+
withinReachMetric: ({ angle, event }: StrategyInput) => boolean;
|
|
19
|
+
distanceMetric: ({ xDistance, yDistance }: StrategyInput) => number;
|
|
20
|
+
};
|
|
21
|
+
XWalkManhattanWeighted: {
|
|
22
|
+
withinReachMetric: ({ angle, event }: StrategyInput) => boolean;
|
|
23
|
+
distanceMetric: ({ xDistance, yDistance, event }: StrategyInput) => number | undefined;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const withinReachMetrics = {
|
|
2
|
+
XWalk: ({ angle, event }) => {
|
|
3
|
+
if (event.key === 'ArrowDown' && (angle >= 315 || angle <= 45)) {
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
if (event.key === 'ArrowLeft' && angle > 45 && angle < 135) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (event.key === 'ArrowUp' && angle >= 135 && angle <= 225) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
if (event.key === 'ArrowRight' && angle > 225 && angle < 315) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const distanceMetrics = {
|
|
19
|
+
euclidean: ({ xDistance, yDistance }) => {
|
|
20
|
+
return Math.sqrt(xDistance ** 2 + yDistance ** 2);
|
|
21
|
+
},
|
|
22
|
+
manhattan: ({ xDistance, yDistance }) => {
|
|
23
|
+
return Math.abs(xDistance) + Math.abs(yDistance);
|
|
24
|
+
},
|
|
25
|
+
manhattanWeighted: ({ xDistance, yDistance, event }) => {
|
|
26
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
27
|
+
return Math.abs(xDistance) + Math.abs(yDistance) * 0.1;
|
|
28
|
+
}
|
|
29
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
30
|
+
return Math.abs(xDistance) * 0.1 + Math.abs(yDistance);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export const strategy = {
|
|
35
|
+
XWalkEuclidean: {
|
|
36
|
+
withinReachMetric: withinReachMetrics.XWalk,
|
|
37
|
+
distanceMetric: distanceMetrics.euclidean,
|
|
38
|
+
},
|
|
39
|
+
XWalkManhattan: {
|
|
40
|
+
withinReachMetric: withinReachMetrics.XWalk,
|
|
41
|
+
distanceMetric: distanceMetrics.manhattan,
|
|
42
|
+
},
|
|
43
|
+
XWalkManhattanWeighted: {
|
|
44
|
+
withinReachMetric: withinReachMetrics.XWalk,
|
|
45
|
+
distanceMetric: distanceMetrics.manhattanWeighted,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
//# sourceMappingURL=strategies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strategies.js","sourceRoot":"","sources":["../../src/lib/strategies.ts"],"names":[],"mappings":"AASA,MAAM,kBAAkB,GAAG;IACzB,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,KAAK,EAAiB,EAAE,EAAE;QACzC,IAAI,KAAK,CAAC,GAAG,KAAK,WAAW,IAAI,CAAC,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC;YAC/D,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,KAAK,CAAC,GAAG,KAAK,WAAW,IAAI,KAAK,GAAG,EAAE,IAAI,KAAK,GAAG,GAAG,EAAE,CAAC;YAC3D,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,GAAG,EAAE,CAAC;YAC5D,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,KAAK,CAAC,GAAG,KAAK,YAAY,IAAI,KAAK,GAAG,GAAG,IAAI,KAAK,GAAG,GAAG,EAAE,CAAC;YAC7D,OAAO,IAAI,CAAA;QACb,CAAC;QAED,OAAO,KAAK,CAAA;IACd,CAAC;CACF,CAAA;AAED,MAAM,eAAe,GAAG;IACtB,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,SAAS,EAAiB,EAAE,EAAE;QACrD,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,GAAG,SAAS,IAAI,CAAC,CAAC,CAAA;IACnD,CAAC;IACD,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,SAAS,EAAiB,EAAE,EAAE;QACrD,OAAO,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;IAClD,CAAC;IACD,iBAAiB,EAAE,CAAC,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAiB,EAAE,EAAE;QACpE,IAAI,KAAK,CAAC,GAAG,KAAK,WAAW,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;YACzD,OAAO,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,GAAG,CAAA;QACxD,CAAC;QACD,IAAI,KAAK,CAAC,GAAG,KAAK,WAAW,IAAI,KAAK,CAAC,GAAG,KAAK,YAAY,EAAE,CAAC;YAC5D,OAAO,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;QACxD,CAAC;IACH,CAAC;CACF,CAAA;AAED,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,cAAc,EAAE;QACd,iBAAiB,EAAE,kBAAkB,CAAC,KAAK;QAC3C,cAAc,EAAE,eAAe,CAAC,SAAS;KAC1C;IACD,cAAc,EAAE;QACd,iBAAiB,EAAE,kBAAkB,CAAC,KAAK;QAC3C,cAAc,EAAE,eAAe,CAAC,SAAS;KAC1C;IACD,sBAAsB,EAAE;QACtB,iBAAiB,EAAE,kBAAkB,CAAC,KAAK;QAC3C,cAAc,EAAE,eAAe,CAAC,iBAAiB;KAClD;CACF,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const sumBy: <T extends unknown>(arr: Array<T>, func: (item: T) => number) => number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sumBy.mjs","sourceRoot":"","sources":["../../src/lib/sumBy.mts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,KAAK,GAAG,CACnB,GAAa,EACb,IAAyB,EACzB,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA"}
|
package/index.html
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>arrowTab</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="flex flex-col gap-48">
|
|
11
|
+
<div class="grid grid-cols-3 gap-4 p-4 items-baseline">
|
|
12
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
13
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
14
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
15
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
16
|
+
<input type="text" class="border-2 border-gray-300 p-2" value="Hello" />
|
|
17
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
18
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
19
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
20
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="grid grid-cols-3 gap-4 p-4 items-baseline">
|
|
24
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
25
|
+
<input type="text" disabled class="border-2 border-gray-300 p-2" />
|
|
26
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
27
|
+
<input type="text" disabled class="border-2 border-gray-300 p-2" />
|
|
28
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
29
|
+
<input type="text" disabled class="border-2 border-gray-300 p-2" />
|
|
30
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
31
|
+
<input type="text" disabled class="border-2 border-gray-300 p-2" />
|
|
32
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="grid grid-cols-3 gap-4 p-4 items-baseline">
|
|
36
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
37
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
38
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
39
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
40
|
+
<input
|
|
41
|
+
type="text"
|
|
42
|
+
class="border-2 border-gray-300 p-2"
|
|
43
|
+
value="✅ Input with Text"
|
|
44
|
+
/>
|
|
45
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
46
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
47
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
48
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
49
|
+
|
|
50
|
+
<hr class="col-span-3" />
|
|
51
|
+
|
|
52
|
+
<input
|
|
53
|
+
type="text"
|
|
54
|
+
class="border-2 border-gray-300 p-2"
|
|
55
|
+
value="✅ input"
|
|
56
|
+
/>
|
|
57
|
+
<div>
|
|
58
|
+
<span>❌ input hidden</span>
|
|
59
|
+
<input type="text" hidden class="border-2 border-gray-300 p-2" />
|
|
60
|
+
</div>
|
|
61
|
+
<input
|
|
62
|
+
type="text"
|
|
63
|
+
disabled
|
|
64
|
+
class="border-2 border-gray-300 p-2"
|
|
65
|
+
value="❌ input disabled"
|
|
66
|
+
/>
|
|
67
|
+
<button>✅ Button</button>
|
|
68
|
+
<button disabled>❌ button disabled</button>
|
|
69
|
+
<button aria-hidden="true">❌ button aria-hidden</button>
|
|
70
|
+
<a href="#" class="focus:outline">✅ a tag with href</a>
|
|
71
|
+
<a class="focus:outline">❌ only a tag</a>
|
|
72
|
+
<div></div>
|
|
73
|
+
<div tabindex="10">✅ tabindex="10"</div>
|
|
74
|
+
<div tabindex="0">✅ tabindex="0"</div>
|
|
75
|
+
<div tabindex="-1">❌ tabindex="-1"</div>
|
|
76
|
+
|
|
77
|
+
<hr class="col-span-3" />
|
|
78
|
+
|
|
79
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
80
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
81
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
82
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
83
|
+
<textarea class="border-2 border-gray-300 p-2">
|
|
84
|
+
✅ Multiline
|
|
85
|
+
Textarea
|
|
86
|
+
</textarea>
|
|
87
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
88
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
89
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
90
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
91
|
+
|
|
92
|
+
<hr class="col-span-3" />
|
|
93
|
+
|
|
94
|
+
<input
|
|
95
|
+
type="button"
|
|
96
|
+
class="border-2 border-gray-300 p-2"
|
|
97
|
+
value="input button"
|
|
98
|
+
/>
|
|
99
|
+
<input
|
|
100
|
+
type="submit"
|
|
101
|
+
class="border-2 border-gray-300 p-2"
|
|
102
|
+
value="input submit"
|
|
103
|
+
/>
|
|
104
|
+
<input
|
|
105
|
+
type="reset"
|
|
106
|
+
class="border-2 border-gray-300 p-2"
|
|
107
|
+
value="input reset"
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<input type="password" class="border-2 border-gray-300 p-2" />
|
|
111
|
+
<input type="checkbox" class="border-2 border-gray-300 p-2" />
|
|
112
|
+
<input type="radio" class="border-2 border-gray-300 p-2" />
|
|
113
|
+
<input type="file" class="border-2 border-gray-300 p-2" />
|
|
114
|
+
<input type="image" class="border-2 border-gray-300 p-2" />
|
|
115
|
+
<input type="email" class="border-2 border-gray-300 p-2" />
|
|
116
|
+
<input type="url" class="border-2 border-gray-300 p-2" />
|
|
117
|
+
<input type="tel" class="border-2 border-gray-300 p-2" />
|
|
118
|
+
<input type="search" class="border-2 border-gray-300 p-2" />
|
|
119
|
+
<input type="date" class="border-2 border-gray-300 p-2" />
|
|
120
|
+
<input type="time" class="border-2 border-gray-300 p-2" />
|
|
121
|
+
<input type="datetime" class="border-2 border-gray-300 p-2" />
|
|
122
|
+
<input type="datetime-local" class="border-2 border-gray-300 p-2" />
|
|
123
|
+
<input type="month" class="border-2 border-gray-300 p-2" />
|
|
124
|
+
<input type="week" class="border-2 border-gray-300 p-2" />
|
|
125
|
+
<input type="number" class="border-2 border-gray-300 p-2" />
|
|
126
|
+
<input type="range" class="border-2 border-gray-300 p-2" />
|
|
127
|
+
<input type="color" class="border-2 border-gray-300 p-2" />
|
|
128
|
+
|
|
129
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
130
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
131
|
+
<input type="text" class="border-2 border-gray-300 p-2" />
|
|
132
|
+
|
|
133
|
+
<!-- TODO: select, details -->
|
|
134
|
+
<!-- TODO: summary, iframe (?) -->
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<script type="module">
|
|
138
|
+
import { initArrowTab } from './dist/index.mjs'
|
|
139
|
+
initArrowTab()
|
|
140
|
+
</script>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arrowtab",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"keywords": [],
|
|
7
|
+
"author": "",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"concurrently": "^8.2.2",
|
|
12
|
+
"http-server": "^14.1.1",
|
|
13
|
+
"typescript": "^5.5.4"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
17
|
+
"dev": "concurrently \"tsc --watch\" \"http-server -o\"",
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"clean": "rm -rf dist",
|
|
20
|
+
"prepublish": "pnpm clean && pnpm build"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.mts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// TODO: Options
|
|
2
|
+
// modifierKey: 'ctrlKey',
|
|
3
|
+
// onlyWithModifierKey: false, // TODO: Better name
|
|
4
|
+
// strategy: arrowTab.XWalkEuclidean,
|
|
5
|
+
// selector: undefined,
|
|
6
|
+
// elementFilter: () => boolean,
|
|
7
|
+
// getElementPosition: (element) => { x: number, y: number },
|
|
8
|
+
// eventTriggerName: 'keydown' | 'keyup',
|
|
9
|
+
// earlyReturn: (event) => boolean, // TODO: Better name
|
|
10
|
+
|
|
11
|
+
import { getFocusable } from './lib/getFocusable.mjs'
|
|
12
|
+
import { getMiddle } from './lib/positions.mjs'
|
|
13
|
+
import { strategy } from './lib/strategies.js'
|
|
14
|
+
import { sumBy } from './lib/sumBy.mjs'
|
|
15
|
+
|
|
16
|
+
export const initArrowTab = () => {
|
|
17
|
+
document.addEventListener(
|
|
18
|
+
'keydown',
|
|
19
|
+
(event) => {
|
|
20
|
+
const activeStrategy = strategy.XWalkEuclidean
|
|
21
|
+
|
|
22
|
+
const activeElement =
|
|
23
|
+
document.activeElement === document.body
|
|
24
|
+
? getFocusable()?.[0]
|
|
25
|
+
: document.activeElement
|
|
26
|
+
|
|
27
|
+
if (!activeElement) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
event.key !== 'ArrowLeft' &&
|
|
33
|
+
event.key !== 'ArrowRight' &&
|
|
34
|
+
event.key !== 'ArrowUp' &&
|
|
35
|
+
event.key !== 'ArrowDown'
|
|
36
|
+
) {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
activeElement instanceof HTMLInputElement ||
|
|
42
|
+
activeElement instanceof HTMLTextAreaElement
|
|
43
|
+
) {
|
|
44
|
+
const selectionStart = activeElement.selectionStart
|
|
45
|
+
const selectionEnd = activeElement.selectionEnd
|
|
46
|
+
|
|
47
|
+
if (
|
|
48
|
+
event.key === 'ArrowLeft' &&
|
|
49
|
+
selectionStart !== undefined &&
|
|
50
|
+
selectionStart !== null &&
|
|
51
|
+
selectionStart !== 0
|
|
52
|
+
) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
event.key === 'ArrowRight' &&
|
|
58
|
+
selectionStart !== undefined &&
|
|
59
|
+
selectionStart !== null &&
|
|
60
|
+
selectionStart !== activeElement.value?.length
|
|
61
|
+
) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
(event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
|
|
67
|
+
selectionStart !== undefined &&
|
|
68
|
+
selectionEnd !== undefined &&
|
|
69
|
+
selectionStart !== null &&
|
|
70
|
+
selectionEnd !== null &&
|
|
71
|
+
selectionStart !== selectionEnd
|
|
72
|
+
) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
event.key === 'ArrowUp' &&
|
|
78
|
+
selectionStart !== undefined &&
|
|
79
|
+
selectionStart !== null &&
|
|
80
|
+
activeElement instanceof HTMLTextAreaElement
|
|
81
|
+
) {
|
|
82
|
+
const rows = activeElement.value?.split('\n')
|
|
83
|
+
const start = selectionStart
|
|
84
|
+
const firstRow = rows.at(0)
|
|
85
|
+
if (firstRow && start > firstRow.length) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
event.key === 'ArrowDown' &&
|
|
92
|
+
selectionStart !== undefined &&
|
|
93
|
+
selectionStart !== null &&
|
|
94
|
+
activeElement instanceof HTMLTextAreaElement
|
|
95
|
+
) {
|
|
96
|
+
const rows = activeElement.value?.split('\n')
|
|
97
|
+
const withoutLastRow = rows.slice(0, -1)
|
|
98
|
+
const lengthUntilLastRow = sumBy(withoutLastRow, (row) => row.length)
|
|
99
|
+
|
|
100
|
+
const start = selectionStart
|
|
101
|
+
if (start <= lengthUntilLastRow + 1) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const inputTypesThatNeedModifierKey: Record<string, string[]> = {
|
|
108
|
+
date: ['ArrowDown', 'ArrowUp'],
|
|
109
|
+
time: ['ArrowDown', 'ArrowUp'],
|
|
110
|
+
datetime: ['ArrowDown', 'ArrowUp'],
|
|
111
|
+
'datetime-local': ['ArrowDown', 'ArrowUp'],
|
|
112
|
+
month: ['ArrowDown', 'ArrowUp'],
|
|
113
|
+
week: ['ArrowDown', 'ArrowUp'],
|
|
114
|
+
number: ['ArrowDown', 'ArrowUp'],
|
|
115
|
+
range: ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (activeElement instanceof HTMLInputElement) {
|
|
119
|
+
const inputType = activeElement.type
|
|
120
|
+
if (
|
|
121
|
+
inputTypesThatNeedModifierKey[inputType] &&
|
|
122
|
+
inputTypesThatNeedModifierKey[inputType].includes(event.key)
|
|
123
|
+
) {
|
|
124
|
+
// TODO: Use modifierKey
|
|
125
|
+
if (!event.shiftKey) {
|
|
126
|
+
return
|
|
127
|
+
} else {
|
|
128
|
+
event.preventDefault()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const activePosition = getMiddle(activeElement)
|
|
134
|
+
|
|
135
|
+
const allFocusable = getFocusable()
|
|
136
|
+
const withoutActiveElement = allFocusable.filter(
|
|
137
|
+
(element) => element !== activeElement,
|
|
138
|
+
)
|
|
139
|
+
const withPosition = withoutActiveElement.map((element) => {
|
|
140
|
+
const position = getMiddle(element)
|
|
141
|
+
|
|
142
|
+
// Calculate angle between active element and focusable element
|
|
143
|
+
const xDistance = position.x - activePosition.x
|
|
144
|
+
const yDistance = position.y - activePosition.y
|
|
145
|
+
let angle =
|
|
146
|
+
Math.acos(yDistance / Math.sqrt(xDistance ** 2 + yDistance ** 2)) *
|
|
147
|
+
(180 / Math.PI)
|
|
148
|
+
const direction = xDistance > 0 ? 'right' : 'left'
|
|
149
|
+
if (direction === 'right') {
|
|
150
|
+
angle = 360 - angle
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
xDistance,
|
|
155
|
+
yDistance,
|
|
156
|
+
element,
|
|
157
|
+
angle,
|
|
158
|
+
position,
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
const withDistance = withPosition.map((focusable) => {
|
|
162
|
+
const distance = activeStrategy.distanceMetric({ ...focusable, event })
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...focusable,
|
|
166
|
+
distance,
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
const withWithinReach = withDistance.map((focusable) => {
|
|
170
|
+
const withinReach = activeStrategy.withinReachMetric({
|
|
171
|
+
...focusable,
|
|
172
|
+
event,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
...focusable,
|
|
177
|
+
withinReach,
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const reversed = [...withWithinReach].reverse() // Prefer down / right if same distance
|
|
182
|
+
|
|
183
|
+
const sorted = reversed.sort((a, b) => {
|
|
184
|
+
return a.distance - b.distance
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const withinReach = sorted.filter(({ withinReach }) => withinReach)
|
|
188
|
+
|
|
189
|
+
let nearest = withinReach.at(0)
|
|
190
|
+
|
|
191
|
+
if (!nearest) {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// const nearlySameDistance = _.filter(candidates, (candidate) => {
|
|
196
|
+
// return candidate.distance - nearest.distance < 10
|
|
197
|
+
// })
|
|
198
|
+
|
|
199
|
+
// console.log('nearlySameDistance', nearlySameDistance)
|
|
200
|
+
|
|
201
|
+
// if (nearlySameDistance.length > 1) {
|
|
202
|
+
// const ordered = _.orderBy(nearlySameDistance, (candidate) => {
|
|
203
|
+
// if (event.key === 'ArrowDown') {
|
|
204
|
+
// console.log('ArrowDown', candidate.xDistance)
|
|
205
|
+
// return -candidate.xDistance
|
|
206
|
+
// }
|
|
207
|
+
// if (event.key === 'ArrowUp') {
|
|
208
|
+
// return candidate.xDistance
|
|
209
|
+
// }
|
|
210
|
+
// if (event.key === 'ArrowLeft') {
|
|
211
|
+
// return candidate.yDistance
|
|
212
|
+
// }
|
|
213
|
+
// if (event.key === 'ArrowRight') {
|
|
214
|
+
// console.log('ArrowRight', candidate.yDistance)
|
|
215
|
+
// return -candidate.yDistance
|
|
216
|
+
// }
|
|
217
|
+
// })
|
|
218
|
+
// console.log('ordered', ordered)
|
|
219
|
+
// nearest = _.first(ordered)
|
|
220
|
+
// }
|
|
221
|
+
|
|
222
|
+
if (nearest.element instanceof HTMLElement) {
|
|
223
|
+
nearest.element.focus()
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
{ capture: true },
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const getFocusable = () => {
|
|
2
|
+
// Selectors from https://zellwk.com/blog/keyboard-focusable-elements/
|
|
3
|
+
const allFocusable = document.querySelectorAll(
|
|
4
|
+
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex^="-"])',
|
|
5
|
+
)
|
|
6
|
+
const focusable = Array.from(allFocusable).filter((element) => {
|
|
7
|
+
if ('disabled' in element && element.disabled) return false
|
|
8
|
+
if ('hidden' in element && element.hidden) return false
|
|
9
|
+
if (element.getAttribute('aria-hidden') === 'true') return false
|
|
10
|
+
return true
|
|
11
|
+
})
|
|
12
|
+
return focusable
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const getMiddle = (element: Element) => {
|
|
2
|
+
const { x, y, width, height } = element.getBoundingClientRect()
|
|
3
|
+
return {
|
|
4
|
+
x: x + width / 2,
|
|
5
|
+
y: y + height / 2,
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const getTopLeft = (element: Element) => {
|
|
10
|
+
const { x, y } = element.getBoundingClientRect()
|
|
11
|
+
return { x, y }
|
|
12
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
type StrategyInput = {
|
|
2
|
+
event: KeyboardEvent
|
|
3
|
+
xDistance: number
|
|
4
|
+
yDistance: number
|
|
5
|
+
element: Element
|
|
6
|
+
angle: number
|
|
7
|
+
position: { x: number; y: number }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const withinReachMetrics = {
|
|
11
|
+
XWalk: ({ angle, event }: StrategyInput) => {
|
|
12
|
+
if (event.key === 'ArrowDown' && (angle >= 315 || angle <= 45)) {
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (event.key === 'ArrowLeft' && angle > 45 && angle < 135) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (event.key === 'ArrowUp' && angle >= 135 && angle <= 225) {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (event.key === 'ArrowRight' && angle > 225 && angle < 315) {
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const distanceMetrics = {
|
|
33
|
+
euclidean: ({ xDistance, yDistance }: StrategyInput) => {
|
|
34
|
+
return Math.sqrt(xDistance ** 2 + yDistance ** 2)
|
|
35
|
+
},
|
|
36
|
+
manhattan: ({ xDistance, yDistance }: StrategyInput) => {
|
|
37
|
+
return Math.abs(xDistance) + Math.abs(yDistance)
|
|
38
|
+
},
|
|
39
|
+
manhattanWeighted: ({ xDistance, yDistance, event }: StrategyInput) => {
|
|
40
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
41
|
+
return Math.abs(xDistance) + Math.abs(yDistance) * 0.1
|
|
42
|
+
}
|
|
43
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
44
|
+
return Math.abs(xDistance) * 0.1 + Math.abs(yDistance)
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const strategy = {
|
|
50
|
+
XWalkEuclidean: {
|
|
51
|
+
withinReachMetric: withinReachMetrics.XWalk,
|
|
52
|
+
distanceMetric: distanceMetrics.euclidean,
|
|
53
|
+
},
|
|
54
|
+
XWalkManhattan: {
|
|
55
|
+
withinReachMetric: withinReachMetrics.XWalk,
|
|
56
|
+
distanceMetric: distanceMetrics.manhattan,
|
|
57
|
+
},
|
|
58
|
+
XWalkManhattanWeighted: {
|
|
59
|
+
withinReachMetric: withinReachMetrics.XWalk,
|
|
60
|
+
distanceMetric: distanceMetrics.manhattanWeighted,
|
|
61
|
+
},
|
|
62
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"esModuleInterop": true,
|
|
4
|
+
"skipLibCheck": true,
|
|
5
|
+
"target": "es2022",
|
|
6
|
+
"allowJs": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"moduleDetection": "force",
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noUncheckedIndexedAccess": true,
|
|
13
|
+
"noImplicitOverride": true,
|
|
14
|
+
"module": "NodeNext",
|
|
15
|
+
"outDir": "dist",
|
|
16
|
+
"sourceMap": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"lib": ["es2022", "dom", "dom.iterable"]
|
|
19
|
+
}
|
|
20
|
+
}
|