Skip to content

Commit c3108b4

Browse files
committed
Use focus manager for navigation context menus
Removes custom keyboard navigation for context menus, and replaces it with the new focus manager functionality. Note that ContextMenuItem contains two focusables, one for navigation and one to support f-keys.
1 parent c7241a0 commit c3108b4

File tree

6 files changed

+52
-75
lines changed

6 files changed

+52
-75
lines changed

packages/ui/src/lib/components/ContextMenu.svelte

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,6 @@
329329
return isVisible;
330330
}
331331
332-
function handleKeyNavigation(e: KeyboardEvent) {
333-
if (e.key === 'Escape') {
334-
e.preventDefault();
335-
close();
336-
}
337-
}
338-
339332
$effect(() => {
340333
if (!menuContainer) return;
341334
const config = { attributes: false, childList: true, subtree: true };
@@ -358,17 +351,26 @@
358351
</script>
359352

360353
{#if isVisible}
361-
<div class="portal-wrap" use:portal={'body'}>
362-
<!-- svelte-ignore a11y_autofocus -->
354+
<!-- `use:focusable` must come before `use:portal`-->
355+
<div
356+
class="portal-wrap"
357+
use:focusable={{
358+
activate: true,
359+
trap: true,
360+
vertical: true,
361+
onEsc: () => {
362+
close();
363+
return true;
364+
}
365+
}}
366+
use:portal={'body'}
367+
>
363368
<div
364369
data-testid={testId}
365370
bind:this={menuContainer}
366371
tabindex="-1"
367-
use:focusable={{ activate: true, isolate: true, focusable: true, dim: true, trap: true }}
368-
autofocus
369372
{onclick}
370373
{onkeypress}
371-
onkeydown={handleKeyNavigation}
372374
class="context-menu hide-native-scrollbar"
373375
class:top-oriented={side === 'top'}
374376
class:bottom-oriented={side === 'bottom'}

packages/ui/src/lib/components/ContextMenuItem.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import Icon from '$components/Icon.svelte';
33
import Tooltip from '$components/Tooltip.svelte';
4+
import { focusable } from '$lib/focus/focusable';
45
import { keysStringToArr } from '$lib/utils/hotkeys';
56
import { getContext } from 'svelte';
67
import type iconsJson from '@gitbutler/ui/data/icons.json';
@@ -62,12 +63,13 @@
6263
<button
6364
data-testid={testId}
6465
type="button"
65-
class="menu-item focus-state no-select"
66+
class="menu-item no-select"
6667
style:--item-height={caption ? 'auto' : '1.625rem'}
6768
class:disabled
6869
{disabled}
6970
onclick={handleClick}
7071
onmouseenter={handleMouseEnter}
72+
use:focusable={{ focusable: true, button: true }}
7173
>
7274
<div class="menu-item__content">
7375
{#if icon}
@@ -119,6 +121,7 @@
119121
padding: 6px 8px;
120122
gap: 4px;
121123
border-radius: var(--radius-s);
124+
outline: none;
122125
color: var(--clr-text-1);
123126
text-align: left;
124127
cursor: pointer;

packages/ui/src/lib/components/ContextMenuItemSubmenu.svelte

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -110,39 +110,6 @@
110110
isSubmenuOpen = false;
111111
contextMenu?.close();
112112
}
113-
114-
// Handle arrow key navigation
115-
function handleKeyDown(e: KeyboardEvent) {
116-
if (disabled) return;
117-
118-
switch (e.key) {
119-
case 'ArrowRight':
120-
if (submenuSide === 'right') {
121-
e.preventDefault();
122-
e.stopPropagation();
123-
isSubmenuOpen = true;
124-
contextMenu?.open();
125-
}
126-
break;
127-
case 'ArrowLeft':
128-
if (submenuSide === 'left') {
129-
e.preventDefault();
130-
e.stopPropagation();
131-
isSubmenuOpen = true;
132-
contextMenu?.open();
133-
}
134-
break;
135-
case 'Enter':
136-
case ' ':
137-
e.preventDefault();
138-
e.stopPropagation();
139-
if (disabled) return;
140-
141-
isSubmenuOpen = !isSubmenuOpen;
142-
contextMenu?.toggle();
143-
break;
144-
}
145-
}
146113
</script>
147114

148115
<div
@@ -151,11 +118,7 @@
151118
class:active={isSubmenuOpen}
152119
onmouseenter={handleMouseEnter}
153120
onmouseleave={handleMouseLeave}
154-
onkeydown={handleKeyDown}
155-
role="menuitem"
156-
aria-haspopup="menu"
157-
aria-expanded={isSubmenuOpen}
158-
tabindex="-1"
121+
role="group"
159122
>
160123
<ContextMenuItem
161124
{icon}
@@ -182,7 +145,6 @@
182145
align={submenuVerticalAlign === 'top' ? 'start' : 'end'}
183146
onclose={() => {
184147
isSubmenuOpen = false;
185-
menuItemElement?.focus();
186148
}}
187149
>
188150
{@render submenu({ close: closeSubmenu })}

packages/ui/src/lib/components/KebabButton.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@
6868
onclick?.(e.currentTarget as HTMLElement);
6969
}
7070
71+
function onKeyDown(e: KeyboardEvent) {
72+
if (e.key !== 'Enter') {
73+
return;
74+
}
75+
e.stopPropagation();
76+
e.preventDefault();
77+
isContextMenuOpen = !isContextMenuOpen;
78+
openedViaClick = isContextMenuOpen; // Track if opened via click
79+
onclick?.(e.currentTarget as HTMLElement);
80+
}
81+
7182
$effect(() => {
7283
if (contextElement) {
7384
contextElement.addEventListener('contextmenu', onContextMenu);
@@ -110,6 +121,7 @@
110121
class:visible={visible || isContextMenuOpen}
111122
class:activated={activated || (isContextMenuOpen && openedViaClick)}
112123
onclick={onClick}
124+
onkeypress={onKeyDown}
113125
data-testid={testId}
114126
>
115127
<Icon name="kebab" />
@@ -123,6 +135,7 @@
123135
kind="ghost"
124136
activated={activated || (isContextMenuOpen && openedViaClick)}
125137
onclick={onClick}
138+
onkeydown={onKeyDown}
126139
/>
127140
{/if}
128141

packages/ui/src/lib/focus/focusManager.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export class FocusManager {
154154

155155
this.unregisterElement(element);
156156

157-
const parentNode = options.isolate ? undefined : this.findParentNode(element);
157+
const parentNode = this.findParentNode(element);
158158

159159
const newNode: FocusableNode = {
160160
element,
@@ -166,14 +166,14 @@ export class FocusManager {
166166
this.nodeMap.set(element, newNode);
167167
this.establishParentChildRelationships(element, newNode, parentNode);
168168
this.resolvePendingRelationships();
169-
this.handlePendingRegistration(element, parentNode, options);
169+
this.handlePendingRegistration(element, parentNode);
170170

171171
if (this.fModeManager.active) {
172172
this.fModeManager.addElement(element, newNode);
173173
}
174174

175175
if (options.activate) {
176-
this.setActiveNode(newNode);
176+
this.setActiveNode(this.findNavigableDescendant(newNode));
177177
}
178178
}
179179

@@ -254,7 +254,7 @@ export class FocusManager {
254254
(forward && currentIndex === navigableSiblings.length - 1) ||
255255
(!forward && currentIndex === 0);
256256

257-
if (isAtBoundary) {
257+
if (isAtBoundary && !parentNode.options.trap) {
258258
// Traverse parents while vertical: true
259259
const linkedTarget = this.findNextInColumnNode(currentNode, forward);
260260
if (!linkedTarget) return false;
@@ -266,7 +266,7 @@ export class FocusManager {
266266

267267
focusNextVertical(forward: boolean): boolean {
268268
if (!this.currentNode?.element) return false;
269-
const nextChild = this.findInNextColumn(this.currentNode.element, forward);
269+
const nextChild = this.findInNextColumn(this.currentNode, forward);
270270
if (nextChild) {
271271
return this.setActiveNode(nextChild);
272272
}
@@ -326,6 +326,9 @@ export class FocusManager {
326326
if (navigableChild) {
327327
return navigableChild;
328328
}
329+
if (node.options.trap) {
330+
return;
331+
}
329332
}
330333
pointer = pointer.parentElement;
331334
}
@@ -521,7 +524,7 @@ export class FocusManager {
521524
case 'right':
522525
if (trap) return true;
523526
if (!this.focusNextVertical(true)) {
524-
this.focusAnyNode();
527+
// this.focusAnyNode();
525528
}
526529
return true;
527530

@@ -620,8 +623,7 @@ export class FocusManager {
620623
}
621624

622625
// Finds focusable in adjacent column for horizontal navigation
623-
findInNextColumn(element: HTMLElement, forward: boolean): FocusableNode | undefined {
624-
const node = this.getNode(element);
626+
findInNextColumn(node: FocusableNode, forward: boolean): FocusableNode | undefined {
625627
if (!node) return undefined;
626628

627629
let searchNode: FocusableNode | undefined = node.parent;
@@ -631,7 +633,7 @@ export class FocusManager {
631633
if (!ancestorNode) return;
632634

633635
// Find the current branch from the non-list parent's perspective
634-
const ancestorChild = this.findChildNodeByDescendent(ancestorNode, element);
636+
const ancestorChild = this.findChildNodeByDescendent(ancestorNode, node);
635637
if (!ancestorChild) return;
636638

637639
// Find non-button siblings of the current branch for navigation
@@ -663,20 +665,22 @@ export class FocusManager {
663665
if (!current.options.vertical) {
664666
return current;
665667
}
668+
if (current.options.trap) {
669+
return;
670+
}
666671
current = current.parent;
667672
}
668-
return undefined;
669673
}
670674

671675
// Finds which child branch contains the given element
672676
private findChildNodeByDescendent(
673677
ancestor: FocusableNode,
674-
element: HTMLElement
678+
node: FocusableNode
675679
): FocusableNode | undefined {
676680
// Check each child (including buttons) to see which one contains our current element
677681
// We need to check all children here because we're looking for containment, not navigation
678682
for (const child of ancestor.children) {
679-
if (this.isNodeDescendantOf(child, element)) {
683+
if (this.isNodeDescendantOf(child, node.element)) {
680684
return child;
681685
}
682686
}
@@ -773,12 +777,8 @@ export class FocusManager {
773777
});
774778
}
775779

776-
private handlePendingRegistration(
777-
element: HTMLElement,
778-
parentNode: FocusableNode | undefined,
779-
options: FocusableOptions
780-
) {
781-
if (!parentNode && !options.isolate) {
780+
private handlePendingRegistration(element: HTMLElement, parentNode: FocusableNode | undefined) {
781+
if (!parentNode) {
782782
this.pendingRelationships.push(element);
783783
}
784784
}
@@ -1044,7 +1044,6 @@ export class FocusManager {
10441044
if (node.options.disabled) flags.push('disabled');
10451045
if (node.options.trap) flags.push('trap');
10461046
if (node.options.vertical) flags.push('vertical');
1047-
if (node.options.isolate) flags.push('isolate');
10481047
if (this.isContainerElement(node)) flags.push('container');
10491048
const flagsStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
10501049

packages/ui/src/lib/focus/focusTypes.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,10 @@ export interface FocusableOptions {
2323
disabled?: boolean;
2424
// Treat children as vertically oriented (changes arrow key behavior, automatically skips during navigation)
2525
vertical?: boolean;
26-
// Prevent keyboard navigation from leaving this element
27-
trap?: boolean;
2826
// Automatically focus this element when registered
2927
activate?: boolean;
30-
// Don't establish parent-child relationships with other focusables
31-
isolate?: boolean;
28+
// Prevent keyboard navigation from leaving this element
29+
trap?: boolean;
3230
// Never highlight the element
3331
dim?: boolean;
3432
// Automatically trigger onAction when this element becomes active

0 commit comments

Comments
 (0)