feat(app): make the New tab launcher keyboard-first

Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:15:16 +02:00
parent 8655722296
commit 60bafad3ee
38 changed files with 1047 additions and 168 deletions
@@ -207,7 +207,6 @@ export function MenuItem({
tooltip,
}: PropsWithChildren<MenuItemProps>): ReactElement {
const { selectItem } = useMenuContext("MenuItem");
const isPending = status === "pending" || loading;
const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess;
@@ -230,12 +229,17 @@ export function MenuItem({
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
({
pressed,
hovered = false,
focused = false,
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) => [
styles.item,
active ? styles.itemActive : null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
focused && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
],
[active, isDisabled, muted],
@@ -250,11 +254,17 @@ export function MenuItem({
],
[destructive, isSuccess, muted, isDisabled],
);
const itemDataSet = useMemo(
() => ({ menuItem: "true", menuDisabled: isDisabled ? "true" : "false" }),
[isDisabled],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityRole="menuitem"
tabIndex={-1}
dataSet={itemDataSet}
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
@@ -362,6 +372,8 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
borderRadius: theme.borderRadius.md,
outlineWidth: 0,
outlineColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface2,
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useId, useMemo, useState, type ReactElement } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useState,
type ReactElement,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
@@ -15,6 +23,7 @@ import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import type { KeyboardFocusScope } from "@/keyboard/actions";
import {
getOverlayRoot,
OverlayLayerProvider,
@@ -230,6 +239,7 @@ export interface AnchoredSurfaceProps {
onPointerEnter?: () => void;
onPointerLeave?: () => void;
testID?: string;
keyboardFocusScope?: KeyboardFocusScope;
children: ReactElement;
}
@@ -258,6 +268,7 @@ export function AnchoredSurface({
onPointerEnter,
onPointerLeave,
testID,
keyboardFocusScope,
children,
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
@@ -281,6 +292,17 @@ export function AnchoredSurface({
revision,
});
useEffect(() => {
if (!isWeb || !open || !contentSize || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => {
document
.getElementById(surfaceNativeID)
?.querySelector<HTMLElement>('[data-menu-item="true"]:not([data-menu-disabled="true"])')
?.focus();
});
return () => cancelAnimationFrame(frame);
}, [contentSize, open, surfaceNativeID]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
@@ -315,6 +337,13 @@ export function AnchoredSurface({
() => [visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize],
);
const surfaceDataSet = useMemo(
() => ({
menuSurface: "true",
...(keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : null),
}),
[keyboardFocusScope],
);
if (!open) return null;
@@ -346,6 +375,7 @@ export function AnchoredSurface({
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={contentEntering}
@@ -385,21 +415,56 @@ export function AnchoredSurface({
export function MenuOverlay({
visible,
onClose,
restoreFocusRef,
children,
}: {
visible: boolean;
onClose: () => void;
restoreFocusRef?: RefObject<View | null>;
children: ReactElement | null;
}): ReactElement | null {
const floatingLayer = useOverlayLayer("floating");
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
event.stopPropagation();
onClose();
return true;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
return true;
}
const target = event.target instanceof Element ? event.target : null;
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
if (!surface) return false;
const items = Array.from(
surface.querySelectorAll<HTMLElement>(
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
),
);
if (items.length === 0) return false;
const currentIndex = items.findIndex((item) => item === document.activeElement);
let nextIndex: number | null = null;
if (event.key === "ArrowDown")
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
if (event.key === "ArrowUp")
nextIndex =
currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = items.length - 1;
if (nextIndex !== null) {
event.preventDefault();
event.stopPropagation();
items[nextIndex]?.focus();
return true;
}
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
event.preventDefault();
event.stopPropagation();
items[currentIndex]?.click();
return true;
}
return false;
},
[onClose],
);
@@ -407,6 +472,7 @@ export function MenuOverlay({
active: isWeb && visible,
layer: floatingLayer,
onKeyDown: handleWebOverlayKeyDown,
restoreFocusRef,
});
if (!visible) return null;
@@ -1,6 +1,7 @@
import {
forwardRef,
useCallback,
useMemo,
type PropsWithChildren,
type ReactElement,
type ReactNode,
@@ -20,6 +21,7 @@ import {
useMenuState,
type MenuCompactMode,
} from "./menu-context";
import { isWeb } from "@/constants/platform";
/**
* Owns one menu's state. Wrap a trigger and a `MenuSurface` in it.
@@ -67,7 +69,7 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
}
export const MenuTrigger = forwardRef<View, MenuTriggerProps>(function MenuTrigger(
{ children, disabled, style, ...props },
{ children, disabled, style, accessibilityState, ...props },
forwardedRef,
): ReactElement {
const ctx = useMenuContext("MenuTrigger");
@@ -102,13 +104,23 @@ export const MenuTrigger = forwardRef<View, MenuTriggerProps>(function MenuTrigg
},
[children, ctx.open],
);
const resolvedAccessibilityState = useMemo(
() => ({ ...accessibilityState, disabled: Boolean(disabled), expanded: ctx.open }),
[accessibilityState, ctx.open, disabled],
);
const webExpandedState = useMemo(
() => (isWeb ? ({ "aria-expanded": ctx.open } as const) : null),
[ctx.open],
);
return (
<Pressable
{...props}
{...webExpandedState}
ref={handleTriggerRef}
collapsable={false}
disabled={disabled}
accessibilityState={resolvedAccessibilityState}
onPress={handlePress}
style={pressableStyle}
>
@@ -27,6 +27,7 @@ import { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
import type { Alignment, Placement } from "./menu-anchor";
import type { KeyboardFocusScope } from "@/keyboard/actions";
const ThemedChevronLeft = withUnistyles(ChevronLeft);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -105,6 +106,8 @@ export interface MenuSurfaceProps {
horizontalPadding?: number;
scrollable?: boolean;
testID?: string;
/** Limits ordinary app shortcuts to the time this menu owns keyboard focus. */
keyboardFocusScope?: KeyboardFocusScope;
}
/**
@@ -188,6 +191,7 @@ function MenuPopoverSurface({
horizontalPadding = 16,
scrollable = false,
testID,
keyboardFocusScope,
}: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue, getAnchor } = useSubAnchors();
@@ -208,7 +212,7 @@ function MenuPopoverSurface({
return (
<MenuSurfaceContext.Provider value={surfaceValue}>
<MenuOverlay visible={menu.open} onClose={handleClose}>
<MenuOverlay visible={menu.open} onClose={handleClose} restoreFocusRef={menu.triggerRef}>
<>
<AnchoredSurface
open={menu.open}
@@ -226,6 +230,7 @@ function MenuPopoverSurface({
horizontalPadding={horizontalPadding}
scrollable={scrollable}
testID={testID}
keyboardFocusScope={keyboardFocusScope}
>
<MenuPage depth={0}>{children}</MenuPage>
</AnchoredSurface>
@@ -304,6 +309,7 @@ function MenuSheetSurface({
pages = [],
sheetTitle,
testID,
keyboardFocusScope,
}: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue } = useSubAnchors();
@@ -318,6 +324,10 @@ function MenuSheetSurface({
}),
[safeAreaInsets.bottom],
);
const sheetDataSet = useMemo(
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
[keyboardFocusScope],
);
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
@@ -369,6 +379,7 @@ function MenuSheetSurface({
keyboardBlurBehavior="restore"
>
<BottomSheetScrollView
dataSet={sheetDataSet}
contentContainerStyle={sheetScrollContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}