Skip to content

Commit 7796a8e

Browse files
authored
fix: add keyboard navigation to svelte inspector and improve a11y (#438)
* feat: add next/prev keyboard navigation to svelte inspector and improve a11y * refactor: make is_selectable more readable * feat: add keyboard shortcut for opening the editor, position bubble on navigated element, clamp y pos of bubble to viewport * chore: use patch and more cleanup for changesets * feat: immediately activate svelte inspector * fix: innermost_hover_el
1 parent f6d7007 commit 7796a8e

File tree

7 files changed

+164
-47
lines changed

7 files changed

+164
-47
lines changed

.changeset/fair-dodos-dance.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/light-readers-leave.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/pretty-boxes-fail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/vite-plugin-svelte': patch
3+
---
4+
5+
svelte-inspector: add keyboard navigation, select element on activation, improve a11y and info bubble position/content

docs/config.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,12 +306,25 @@ export default {
306306
toggleKeyCombo?: string;
307307

308308
/**
309-
* define keys to drill from the active element (up selects parent, down selects child).
310-
* @default {up: 'ArrowUp',down: 'ArrowDown'}
309+
* define keys to select elements with via keyboard
310+
* @default {parent: 'ArrowUp', child: 'ArrowDown', next: 'ArrowRight', prev: 'ArrowLeft' }
311311
*
312-
* This is useful when components wrap another one without providing any hoverable area between them
312+
* improves accessibility and also helps when you want to select elements that do not have a hoverable surface area
313+
* due to tight wrapping
314+
*
315+
* parent: select closest parent
316+
* child: select first child (or grandchild)
317+
* next: next sibling (or parent if no next sibling exists)
318+
* prev: previous sibling (or parent if no prev sibling exists)
319+
*/
320+
navKeys?: { parent: string; child: string; next: string; prev: string };
321+
322+
/**
323+
* define key to open the editor for the currently selected dom node
324+
*
325+
* @default 'Enter'
313326
*/
314-
drillKeys?: { up: string; down: string };
327+
openKey?: string;
315328

316329
/**
317330
* inspector is automatically disabled when releasing toggleKeyCombo after holding it for a longpress

packages/vite-plugin-svelte/src/ui/inspector/Inspector.svelte

Lines changed: 118 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// eslint-disable-next-line node/no-missing-import
55
import options from 'virtual:svelte-inspector-options';
66
const toggle_combo = options.toggleKeyCombo?.toLowerCase().split('-');
7-
7+
const nav_keys = Object.values(options.navKeys).map((k) => k.toLowerCase());
88
let enabled = false;
99
1010
const icon = `data:image/svg+xml;base64,${btoa(
@@ -37,30 +37,67 @@
3737
y = event.y;
3838
}
3939
40-
function find_parent_with_meta(el) {
41-
while (el) {
42-
if (has_meta(el)) {
40+
function find_selectable_parent(el) {
41+
do {
42+
el = el.parentNode;
43+
if (is_selectable(el)) {
4344
return el;
4445
}
45-
el = el.parentNode;
46-
}
46+
} while (el);
47+
}
48+
49+
function find_selectable_child(el) {
50+
return [...el.querySelectorAll('*')].find(is_selectable);
51+
}
52+
53+
function find_selectable_sibling(el, prev = false) {
54+
do {
55+
el = prev ? el.previousElementSibling : el.nextElementSibling;
56+
if (is_selectable(el)) {
57+
return el;
58+
}
59+
} while (el);
4760
}
4861
49-
function find_child_with_meta(el) {
50-
return [...el.querySelectorAll('*')].find(has_meta);
62+
function find_selectable_for_nav(key) {
63+
const el = active_el;
64+
if (!el) {
65+
return find_selectable_child(document?.body);
66+
}
67+
switch (key) {
68+
case options.navKeys.parent:
69+
return find_selectable_parent(el);
70+
case options.navKeys.child:
71+
return find_selectable_child(el);
72+
case options.navKeys.next:
73+
return find_selectable_sibling(el) || find_selectable_parent(el);
74+
case options.navKeys.prev:
75+
return find_selectable_sibling(el, true) || find_selectable_parent(el);
76+
default:
77+
return;
78+
}
5179
}
5280
53-
function has_meta(el) {
54-
const file = el.__svelte_meta?.loc?.file;
55-
return el !== toggle_el && file && !file.includes('node_modules/');
81+
function is_selectable(el) {
82+
if (el === toggle_el) {
83+
return false; // toggle is our own
84+
}
85+
const file = el?.__svelte_meta?.loc?.file;
86+
if (!file || file.includes('node_modules/')) {
87+
return false; // no file or 3rd party
88+
}
89+
if (['svelte-announcer', 'svelte-inspector-announcer'].includes(el.getAttribute('id'))) {
90+
return false; // ignore some elements by id that would be selectable from keyboard nav otherwise
91+
}
92+
return true;
5693
}
5794
5895
function mouseover(event) {
59-
const el = find_parent_with_meta(event.target);
60-
activate(el);
96+
const el = find_selectable_parent(event.target);
97+
activate(el, false);
6198
}
6299
63-
function activate(el) {
100+
function activate(el, set_bubble_pos = true) {
64101
if (options.customStyles && el !== active_el) {
65102
if (active_el) {
66103
active_el.classList.remove('svelte-inspector-active-target');
@@ -76,9 +113,14 @@
76113
file_loc = null;
77114
}
78115
active_el = el;
116+
if (set_bubble_pos) {
117+
const pos = el.getBoundingClientRect();
118+
x = Math.ceil(pos.left);
119+
y = Math.ceil(pos.bottom - 20);
120+
}
79121
}
80122
81-
function click(event) {
123+
function open_editor(event) {
82124
if (file_loc) {
83125
stop(event);
84126
fetch(`/__open-in-editor?file=${encodeURIComponent(file_loc)}`);
@@ -104,6 +146,14 @@
104146
return toggle_combo?.every((key) => is_key_active(key, event));
105147
}
106148
149+
function is_nav(event) {
150+
return nav_keys?.some((key) => is_key_active(key, event));
151+
}
152+
153+
function is_open(event) {
154+
return options.openKey && options.openKey.toLowerCase() === event.key.toLowerCase();
155+
}
156+
107157
function is_holding() {
108158
return enabled_ts && Date.now() - enabled_ts > 250;
109159
}
@@ -124,18 +174,14 @@
124174
if (options.holdMode && enabled) {
125175
enabled_ts = Date.now();
126176
}
127-
} else if (event.key === options.drillKeys.up && active_el) {
128-
const el = find_parent_with_meta(active_el.parentNode);
129-
if (el) {
130-
activate(el);
131-
stop(event);
132-
}
133-
} else if (event.key === options.drillKeys.down && active_el) {
134-
const el = find_child_with_meta(active_el);
177+
} else if (is_nav(event)) {
178+
const el = find_selectable_for_nav(event.key);
135179
if (el) {
136180
activate(el);
137181
stop(event);
138182
}
183+
} else if (is_open(event)) {
184+
open_editor(event);
139185
}
140186
}
141187
@@ -159,7 +205,7 @@
159205
const l = enabled ? body.addEventListener : body.removeEventListener;
160206
l('mousemove', mousemove);
161207
l('mouseover', mouseover);
162-
l('click', click, true);
208+
l('click', open_editor, true);
163209
}
164210
165211
function enable() {
@@ -169,6 +215,32 @@
169215
b.classList.add('svelte-inspector-enabled');
170216
}
171217
listeners(b, enabled);
218+
activate_initial_el();
219+
}
220+
221+
function activate_initial_el() {
222+
const hov = innermost_hover_el();
223+
let el = is_selectable(hov) ? hov : find_selectable_parent(hov);
224+
if (!el) {
225+
const act = document.activeElement;
226+
el = is_selectable(act) ? act : find_selectable_parent(act);
227+
}
228+
if (!el) {
229+
el = find_selectable_child(document.body);
230+
}
231+
if (el) {
232+
activate(el);
233+
}
234+
}
235+
236+
function innermost_hover_el() {
237+
let e = document.body.querySelector(':hover');
238+
let result;
239+
while (e) {
240+
result = e;
241+
e = e.querySelector(':hover');
242+
}
243+
return result;
172244
}
173245
174246
function disable() {
@@ -213,7 +285,7 @@
213285
</script>
214286
215287
{#if show_toggle}
216-
<div
288+
<button
217289
class="svelte-inspector-toggle"
218290
class:enabled
219291
style={`background-image: var(--svelte-inspector-icon);${options.toggleButtonPos
@@ -222,17 +294,22 @@
222294
.join('')}`}
223295
on:click={() => toggle()}
224296
bind:this={toggle_el}
297+
aria-label={`${enabled ? 'disable' : 'enable'} svelte-inspector`}
225298
/>
226299
{/if}
227-
{#if enabled && file_loc}
300+
{#if enabled && active_el && file_loc}
301+
{@const loc = active_el.__svelte_meta.loc}
228302
<div
229303
class="svelte-inspector-overlay"
230-
style:left="{Math.min(x + 3, document.body.clientWidth - w - 10)}px"
231-
style:top="{y + 30}px"
304+
style:left="{Math.min(x + 3, document.documentElement.clientWidth - w - 10)}px"
305+
style:top="{document.documentElement.clientHeight < y + 50 ? y - 30 : y + 30}px"
232306
bind:offsetWidth={w}
233307
>
234308
&lt;{active_el.tagName.toLowerCase()}&gt;&nbsp;{file_loc}
235309
</div>
310+
<div id="svelte-inspector-announcer" aria-live="assertive" aria-atomic="true">
311+
{active_el.tagName.toLowerCase()} in file {loc.file} on line {loc.line} column {loc.column}
312+
</div>
236313
{/if}
237314
238315
<style>
@@ -253,6 +330,7 @@
253330
}
254331
255332
.svelte-inspector-toggle {
333+
all: unset;
256334
border: 1px solid #ff3e00;
257335
border-radius: 8px;
258336
position: fixed;
@@ -264,6 +342,18 @@
264342
cursor: pointer;
265343
}
266344
345+
#svelte-inspector-announcer {
346+
position: absolute;
347+
left: 0px;
348+
top: 0px;
349+
clip: rect(0px, 0px, 0px, 0px);
350+
clip-path: inset(50%);
351+
overflow: hidden;
352+
white-space: nowrap;
353+
width: 1px;
354+
height: 1px;
355+
}
356+
267357
.svelte-inspector-toggle:not(.enabled) {
268358
filter: grayscale(1);
269359
}

packages/vite-plugin-svelte/src/ui/inspector/plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { idToFile } from './utils';
88

99
const defaultInspectorOptions: InspectorOptions = {
1010
toggleKeyCombo: process.platform === 'win32' ? 'control-shift' : 'meta-shift',
11-
drillKeys: { up: 'ArrowUp', down: 'ArrowDown' },
11+
navKeys: { parent: 'ArrowUp', child: 'ArrowDown', next: 'ArrowRight', prev: 'ArrowLeft' },
12+
openKey: 'Enter',
1213
holdMode: false,
1314
showToggleButton: 'active',
1415
toggleButtonPos: 'top-right',

packages/vite-plugin-svelte/src/utils/options.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -660,12 +660,30 @@ export interface InspectorOptions {
660660
toggleKeyCombo?: string;
661661

662662
/**
663-
* define keys to drill from the active element (up selects parent, down selects child).
664-
* @default {up: 'ArrowUp',down: 'ArrowDown'}
663+
* define keys to select elements with via keyboard
664+
* @default {parent: 'ArrowUp', child: 'ArrowDown', next: 'ArrowRight', prev: 'ArrowLeft' }
665665
*
666-
* This is useful when components wrap another one without providing any hoverable area between them
666+
* improves accessibility and also helps when you want to select elements that do not have a hoverable surface area
667+
* due to tight wrapping
668+
*
669+
* A note for users of screen-readers:
670+
* If you are using arrow keys to navigate the page itself, change the navKeys to avoid conflicts.
671+
* e.g. navKeys: {parent: 'w', prev: 'a', child: 's', next: 'd'}
672+
*
673+
*
674+
* parent: select closest parent
675+
* child: select first child (or grandchild)
676+
* next: next sibling (or parent if no next sibling exists)
677+
* prev: previous sibling (or parent if no prev sibling exists)
678+
*/
679+
navKeys?: { parent: string; child: string; next: string; prev: string };
680+
681+
/**
682+
* define key to open the editor for the currently selected dom node
683+
*
684+
* @default 'Enter'
667685
*/
668-
drillKeys?: { up: string; down: string };
686+
openKey?: string;
669687

670688
/**
671689
* inspector is automatically disabled when releasing toggleKeyCombo after holding it for a longpress

0 commit comments

Comments
 (0)