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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user