-
Notifications
You must be signed in to change notification settings - Fork 0
WIP: Windows Menu Support #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
SeanBarker182
wants to merge
26
commits into
main
Choose a base branch
from
feature/windows-menu
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
8c660da
Simplify for example
SeanBarker182 ae2c078
fix: include modules that annotate with REACT_TURBO_MODULE
SeanBarker182 f0e9e97
Merge branch 'main' into feature/windows-menu
SeanBarker182 12c9590
use turbo module
SeanBarker182 9a2c3c8
Fix fabric imports
SeanBarker182 35f6eac
Add passthrough module
SeanBarker182 c687230
Group titlebar components
SeanBarker182 fb794cd
Code style fixes
SeanBarker182 a66f7c2
lint fix
SeanBarker182 d678ee7
Simplify module
SeanBarker182 d6a01be
Fix component registration
SeanBarker182 540d1d8
Fix memory leak issue that's caused by mounting and unmounting passt…
SeanBarker182 bdc9036
Merge branch 'feature/windows-menu' of https://github.com/infinitered…
SeanBarker182 d63a9e0
generate uuid
SeanBarker182 8e735a7
Add basic menu
SeanBarker182 0f06046
Add titlebar menu
SeanBarker182 3a1ae66
More stable useGlobal for windows
SeanBarker182 fa870f6
WIP: Add windows support for useMenuItem hook
SeanBarker182 0478fd9
Merge branch 'main' into feature/windows-menu
SeanBarker182 0745644
Rename IRMenuItemManager to IRSystemMenuManager for clarity
SeanBarker182 e3cc8ad
Fix native types
SeanBarker182 210bc9a
Break out platform implementations
SeanBarker182 10e6a51
improve and document the useGlobal windows hook
SeanBarker182 a5e2ac3
Remove RNW useKeyboard return
SeanBarker182 c9f3ea5
fix imports
SeanBarker182 fbd9f0f
rename
SeanBarker182 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import { View, type ViewStyle, type TextStyle } from "react-native" | ||
import { useRef, useMemo, memo } from "react" | ||
import { themed } from "../../theme/theme" | ||
import { Portal } from "../Portal" | ||
import { MenuDropdownItem } from "./MenuDropdownItem" | ||
import { useSubmenuState } from "./useSubmenuState" | ||
import { menuSettings } from "./menuSettings" | ||
import { type Position, type DropdownMenuItem, type MenuItem, MENU_SEPARATOR } from "./types" | ||
import { getUUID } from "../../utils/random/getUUID" | ||
import { Separator } from "../Separator" | ||
|
||
interface MenuDropdownProps { | ||
items: (DropdownMenuItem | typeof MENU_SEPARATOR)[] | ||
position: Position | ||
onItemPress: (item: MenuItem) => void | ||
isSubmenu?: boolean | ||
} | ||
|
||
const MenuDropdownComponent = ({ | ||
items, | ||
position, | ||
onItemPress, | ||
isSubmenu, | ||
}: MenuDropdownProps) => { | ||
const portalName = useRef( | ||
`${isSubmenu ? 'submenu' : 'dropdown'}-${getUUID()}` | ||
).current | ||
const { openSubmenu, submenuPosition, handleItemHover } = useSubmenuState(position) | ||
|
||
const isSeparator = (item: MenuItem | typeof MENU_SEPARATOR): item is typeof MENU_SEPARATOR => { | ||
return item === MENU_SEPARATOR | ||
} | ||
|
||
// Find the submenu item if one is open | ||
const submenuItem = openSubmenu | ||
? items.find(item => !isSeparator(item) && item.label === openSubmenu) as DropdownMenuItem | undefined | ||
Check failure on line 36 in app/components/Menu/MenuDropdown.tsx
|
||
: undefined | ||
|
||
const dropdownContent = useMemo(() => ( | ||
<View | ||
style={[ | ||
isSubmenu ? $submenuDropdown() : $dropdown(), | ||
{ left: position.x, top: position.y, zIndex: isSubmenu ? 10001 : 10000 } | ||
]} | ||
accessibilityRole="menu" | ||
> | ||
{items.map((item, index) => { | ||
if (isSeparator(item)) return <Separator key={`separator-${index}`} /> | ||
|
||
return ( | ||
<MenuDropdownItem | ||
key={item.label} | ||
item={item as MenuItem} | ||
index={index} | ||
onItemPress={onItemPress} | ||
onItemHover={handleItemHover} | ||
/> | ||
) | ||
})} | ||
</View> | ||
), [items, isSubmenu, position.x, position.y, onItemPress, handleItemHover]) | ||
|
||
return ( | ||
<> | ||
<Portal name={portalName}> | ||
{dropdownContent} | ||
</Portal> | ||
{/* Render submenu */} | ||
{submenuItem?.submenu && ( | ||
<MenuDropdown | ||
items={submenuItem.submenu} | ||
position={submenuPosition} | ||
onItemPress={onItemPress} | ||
isSubmenu={true} | ||
/> | ||
)} | ||
</> | ||
) | ||
} | ||
|
||
export const MenuDropdown = memo(MenuDropdownComponent) | ||
|
||
const $dropdown = themed<ViewStyle>(({ colors, spacing }) => ({ | ||
position: "absolute", | ||
backgroundColor: colors.cardBackground, | ||
borderColor: colors.keyline, | ||
borderWidth: 1, | ||
borderRadius: 4, | ||
minWidth: menuSettings.dropdownMinWidth, | ||
paddingVertical: spacing.xs, | ||
zIndex: menuSettings.zIndex.dropdown, | ||
})) | ||
|
||
const $submenuDropdown = themed<ViewStyle>(({ colors, spacing }) => ({ | ||
position: "absolute", | ||
backgroundColor: colors.cardBackground, | ||
borderColor: colors.keyline, | ||
borderWidth: 1, | ||
borderRadius: 4, | ||
minWidth: menuSettings.submenuMinWidth, | ||
paddingVertical: spacing.xs, | ||
zIndex: menuSettings.zIndex.submenu, | ||
})) | ||
|
||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
import { Pressable, Text, View, type ViewStyle, type TextStyle } from "react-native" | ||
import { useState, useRef, memo, useCallback } from "react" | ||
import { themed } from "../../theme/theme" | ||
import { menuSettings } from "./menuSettings" | ||
import type { MenuItem } from "./types" | ||
|
||
interface MenuDropdownItemProps { | ||
item: MenuItem | ||
index: number | ||
onItemPress: (item: MenuItem) => void | ||
onItemHover: (itemLabel: string, index: number, hasSubmenu: boolean) => void | ||
} | ||
|
||
const MenuDropdownItemComponent = ({ | ||
item, | ||
index, | ||
onItemPress, | ||
onItemHover, | ||
}: MenuDropdownItemProps) => { | ||
const [hoveredItem, setHoveredItem] = useState<string | null>(null) | ||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null) | ||
const enabled = item.enabled !== false | ||
|
||
const handleHoverIn = useCallback(() => { | ||
// Clear any pending hover clear | ||
if (hoverTimeoutRef.current) { | ||
clearTimeout(hoverTimeoutRef.current) | ||
hoverTimeoutRef.current = null | ||
} | ||
setHoveredItem(item.label) | ||
const hasSubmenu = !!item.submenu | ||
onItemHover(item.label, index, hasSubmenu) | ||
}, [item.label, item.submenu, index, onItemHover]) | ||
|
||
const handleHoverOut = useCallback(() => { | ||
// Use a small timeout to prevent flickering between items | ||
hoverTimeoutRef.current = setTimeout(() => { | ||
setHoveredItem((current) => current === item.label ? null : current) | ||
}, 10) | ||
}, [item.label]) | ||
|
||
const handlePress = useCallback(() => { | ||
if (!item.action || !enabled) return | ||
item.action() | ||
onItemPress(item) | ||
}, [item, onItemPress]) | ||
|
||
return ( | ||
<Pressable | ||
onHoverIn={handleHoverIn} | ||
onHoverOut={handleHoverOut} | ||
onPress={handlePress} | ||
disabled={!enabled} | ||
style={({ pressed }) => [ | ||
$dropdownItem(), | ||
((pressed || hoveredItem === item.label) && enabled) && $dropdownItemHovered(), | ||
!enabled && $dropdownItemDisabled, | ||
]} | ||
> | ||
<Text | ||
style={[ | ||
$dropdownItemText(), | ||
!enabled && $dropdownItemTextDisabled(), | ||
]} | ||
> | ||
{item.label} | ||
</Text> | ||
<View style={$rightContent}> | ||
{item.shortcut && ( | ||
<Text | ||
style={[$shortcut(), !enabled && $dropdownItemTextDisabled()]} | ||
> | ||
{formatShortcut(item.shortcut)} | ||
</Text> | ||
)} | ||
{item.submenu && ( | ||
<Text | ||
style={[ | ||
$submenuArrow(), | ||
!enabled && $dropdownItemTextDisabled(), | ||
]} | ||
> | ||
▶ | ||
</Text> | ||
)} | ||
</View> | ||
</Pressable> | ||
) | ||
} | ||
|
||
export const MenuDropdownItem = memo(MenuDropdownItemComponent) | ||
|
||
function formatShortcut(shortcut: string): string { | ||
return shortcut | ||
.replace(/cmd/gi, "Ctrl") | ||
.replace(/shift/gi, "Shift") | ||
.replace(/\+/g, "+") | ||
.split("+") | ||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | ||
.join("+") | ||
} | ||
|
||
const $dropdownItem = themed<ViewStyle>(({ spacing }) => ({ | ||
flexDirection: "row", | ||
justifyContent: "space-between", | ||
alignItems: "center", | ||
paddingHorizontal: spacing.sm, | ||
paddingVertical: spacing.xs, | ||
borderRadius: 4, | ||
minHeight: menuSettings.itemMinHeight, | ||
})) | ||
|
||
const $dropdownItemHovered = themed<ViewStyle>(({ colors }) => ({ | ||
backgroundColor: colors.neutralVery, | ||
})) | ||
|
||
const $dropdownItemDisabled = { | ||
opacity: 0.5, | ||
} | ||
|
||
const $dropdownItemText = themed<TextStyle>(({ colors, typography }) => ({ | ||
color: colors.mainText, | ||
fontSize: typography.caption, | ||
})) | ||
|
||
const $dropdownItemTextDisabled = themed<TextStyle>((theme) => ({ | ||
color: theme.colors.neutral, | ||
})) | ||
|
||
const $shortcut = themed<TextStyle>(({ colors, typography, spacing }) => ({ | ||
color: colors.neutral, | ||
fontSize: typography.small, | ||
marginLeft: spacing.md, | ||
})) | ||
|
||
const $submenuArrow = themed<TextStyle>(({ colors, typography, spacing }) => ({ | ||
color: colors.neutral, | ||
fontSize: typography.small, | ||
marginLeft: spacing.sm, | ||
})) | ||
|
||
const $rightContent: ViewStyle = { | ||
flexDirection: "row", | ||
alignItems: "center", | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { Pressable, type ViewStyle } from "react-native" | ||
import { Portal } from "../Portal" | ||
import { menuSettings } from "./menuSettings" | ||
|
||
interface MenuOverlayProps { | ||
onPress: () => void | ||
portalName?: string | ||
style?: ViewStyle | ||
excludeArea?: { | ||
top?: number | ||
left?: number | ||
right?: number | ||
bottom?: number | ||
} | ||
} | ||
|
||
export const MenuOverlay = ({ | ||
onPress, | ||
portalName = 'menu-overlay', | ||
style, | ||
excludeArea, | ||
}: MenuOverlayProps) => { | ||
|
||
return ( | ||
<Portal name={portalName}> | ||
<Pressable style={overlayStyle({ excludeArea, style })} onPress={onPress} /> | ||
</Portal> | ||
) | ||
} | ||
|
||
interface OverlayStyleArgs { | ||
excludeArea?: { top?: number, left?: number, right?: number, bottom?: number } | ||
style?: ViewStyle | ||
} | ||
|
||
const overlayStyle: (args: OverlayStyleArgs) => ViewStyle = ({ excludeArea, style }: OverlayStyleArgs) => ({ | ||
position: "absolute", | ||
top: excludeArea?.top ?? 0, | ||
left: excludeArea?.left ?? 0, | ||
right: excludeArea?.right ?? 0, | ||
bottom: excludeArea?.bottom ?? 0, | ||
zIndex: menuSettings.zIndex.menuOverlay, | ||
...style, | ||
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
export const menuSettings = { | ||
dropdownMinWidth: 200, | ||
submenuMinWidth: 150, | ||
itemMinHeight: 28, | ||
itemHeight: 32, | ||
submenuOffsetX: 200, | ||
submenuOffsetY: -5, | ||
zIndex: { | ||
menuOverlay: 9999, | ||
dropdown: 10000, | ||
submenu: 10001, | ||
} | ||
} as const |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
export interface Position { | ||
x: number | ||
y: number | ||
} | ||
|
||
// Generic menu item interface for UI components | ||
export interface MenuItem { | ||
label: string | ||
shortcut?: string | ||
enabled?: boolean | ||
action?: () => void | ||
submenu?: (MenuItem | typeof MENU_SEPARATOR)[] | ||
} | ||
|
||
// Type alias for dropdown menu items (same as MenuItem) | ||
export type DropdownMenuItem = MenuItem | ||
|
||
// Menu separator constant | ||
export const MENU_SEPARATOR = 'menu-item-separator' as const |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Check warning
Code scanning / CodeQL
Replacement of a substring with itself Medium
Copilot Autofix
AI 23 days ago
To fix this issue, simply remove the redundant line
.replace(/\+/g, "+")
from theformatShortcut
function in app/components/Menu/MenuDropdownItem.tsx. Removing this will not affect the logic, as the split and map steps will still operate as needed. No further changes, imports, or method updates are required.