diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 8722d1e1..5c03eeae 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -54,6 +54,7 @@ import { useScreenSize } from './providers/ScreenSizeProvider' import { routes } from './routes' import modalManager from './services/modal-manager.service' import CreateWalletGuideToast from './components/CreateWalletGuideToast' +import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp' type TPrimaryPageContext = { @@ -1427,6 +1428,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { display: secondaryStack.length === 0 }} > + + ) } @@ -1546,6 +1549,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { display: true }} > + + ) } diff --git a/src/components/BottomNavigationBar/WriteButton.tsx b/src/components/BottomNavigationBar/WriteButton.tsx index 2c1590ee..03ca092c 100644 --- a/src/components/BottomNavigationBar/WriteButton.tsx +++ b/src/components/BottomNavigationBar/WriteButton.tsx @@ -1,13 +1,22 @@ import PostEditor from '@/components/PostEditor' import { useNostr } from '@/providers/NostrProvider' +import postEditorService from '@/services/post-editor.service' import { PencilLine } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function WriteButton() { const { checkLogin } = useNostr() const [open, setOpen] = useState(false) + useEffect(() => { + const onRequest = () => { + checkLogin(() => setOpen(true)) + } + postEditorService.addEventListener('requestOpenNewPost', onRequest) + return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) + }, [checkLogin]) + return ( <> void +} + +const KeyboardShortcutsHelpContext = createContext(null) + +export function useKeyboardShortcutsHelp(): KeyboardShortcutsHelpContextValue { + const ctx = useContext(KeyboardShortcutsHelpContext) + if (!ctx) { + throw new Error('useKeyboardShortcutsHelp must be used within KeyboardShortcutsHelpProvider') + } + return ctx +} + +function Kbd({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function KbdRow({ keys, label }: { keys: ReactNode; label: string }) { + return ( +
+ {label} +
{keys}
+
+ ) +} + +export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false) + const openHelp = useCallback(() => setOpen(true), []) + const { t } = useTranslation() + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (open) return + if (shouldIgnoreKeyboardShortcutEvent(e.target)) return + if (isRadixDialogOpen()) return + + const isQuestionMark = + e.key === '?' || (e.shiftKey && e.code === 'Slash' && !e.ctrlKey && !e.metaKey && !e.altKey) + + if (isQuestionMark && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault() + setOpen(true) + return + } + + if (e.key === 'F1' && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault() + setOpen(true) + return + } + + if ( + e.altKey && + e.shiftKey && + e.key.toLowerCase() === OPEN_NEW_POST_SHORTCUT_KEY && + !e.ctrlKey && + !e.metaKey + ) { + e.preventDefault() + postEditorService.requestOpenNewPost() + } + } + + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, [open]) + + const value = useMemo(() => ({ openHelp }), [openHelp]) + + return ( + + {children} + + + + {t('shortcuts.title')} + {t('shortcuts.intro')} + +
+
+

+ {t('shortcuts.sectionApp')} +

+
+ + ? + {t('shortcuts.or')} + F1 + + } + /> + + Shift + + + Alt + + + F + + } + /> + + Shift + + + Alt + + + S + + } + /> + + Shift + + + Alt + + + N + + } + /> +
+
+
+

+ {t('shortcuts.sectionSearch')} +

+
+ + + + {t('shortcuts.then')} + Enter + + } + /> + Esc} + /> +
+
+
+

+ {t('shortcuts.sectionStandard')} +

+
+ + Tab + {t('shortcuts.or')} + Shift + + + Tab + + } + /> + + Enter + {t('shortcuts.or')} + Space + + } + /> + Esc} + /> + + + + PgUp + PgDn + Home + End + + } + /> + + Alt + + + + + } + /> +
+
+
+
+
+
+ ) +} + +/** Titlebar-sized help control (e.g. home feed, next to profile). */ +export function KeyboardShortcutsHelpButton() { + const { openHelp } = useKeyboardShortcutsHelp() + const { t } = useTranslation() + return ( + + ) +} diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index da1f10f0..7aa68820 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -22,7 +22,6 @@ const SearchInput = forwardRef>( return (
+ + + ) +} diff --git a/src/components/Sidebar/PostButton.tsx b/src/components/Sidebar/PostButton.tsx index 39258a55..179fd7a6 100644 --- a/src/components/Sidebar/PostButton.tsx +++ b/src/components/Sidebar/PostButton.tsx @@ -1,13 +1,22 @@ import PostEditor from '@/components/PostEditor' import { useNostr } from '@/providers/NostrProvider' +import postEditorService from '@/services/post-editor.service' import { PencilLine } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import SidebarItem from './SidebarItem' export default function PostButton() { const { checkLogin } = useNostr() const [open, setOpen] = useState(false) + useEffect(() => { + const onRequest = () => { + checkLogin(() => setOpen(true)) + } + postEditorService.addEventListener('requestOpenNewPost', onRequest) + return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) + }, [checkLogin]) + return (
+
diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx index 806732b5..0b1b2d17 100644 --- a/src/components/Tabs/index.tsx +++ b/src/components/Tabs/index.tsx @@ -24,7 +24,7 @@ export default function Tabs({ }) { const { t } = useTranslation() const { deepBrowsing, lastScrollTop } = useDeepBrowsing() - const tabRefs = useRef<(HTMLDivElement | null)[]>([]) + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]) const containerRef = useRef(null) const tabsContainerRef = useRef(null) const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 }) @@ -129,13 +129,24 @@ export default function Tabs({ )} >
-
+
{tabs.map((tab, index) => ( -
(tabRefs.current[index] = el)} + type="button" + role="tab" + aria-selected={value === tab.value} + ref={(el) => { + tabRefs.current[index] = el + }} className={cn( - `text-center py-2 px-2 sm:px-4 md:px-6 font-semibold whitespace-nowrap clickable cursor-pointer rounded-lg text-xs sm:text-sm md:text-base shrink-0 flex items-center gap-2 justify-center`, + 'text-center py-2 px-2 sm:px-4 md:px-6 font-semibold whitespace-nowrap rounded-lg text-xs sm:text-sm md:text-base shrink-0 flex items-center gap-2 justify-center', + 'bg-transparent border-0 shadow-none cursor-pointer transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', value === tab.value ? '' : 'text-muted-foreground' )} onClick={() => { @@ -144,7 +155,7 @@ export default function Tabs({ > {tab.icon && {tab.icon}} {t(tab.label)} -
+ ))}
{ + if (isSmallScreen) return + if (current !== pageName || !display) return + + const onKeyDown = (e: KeyboardEvent) => { + if (!e.altKey || !e.shiftKey || e.key.toLowerCase() !== FOCUS_PRIMARY_SCROLL_SHORTCUT_KEY) return + if (e.metaKey || e.ctrlKey) return + if (shouldIgnoreKeyboardShortcutEvent(e.target)) return + if (isRadixDialogOpen()) return + + e.preventDefault() + scrollAreaRef.current?.focus({ preventScroll: true }) + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [isSmallScreen, current, pageName, display]) + if (isSmallScreen) { return ( @@ -101,6 +124,7 @@ const PrimaryPageLayout = forwardRef( {subHeader &&
{subHeader}
}
{children} diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index da76658c..3b030449 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -1,6 +1,11 @@ import ScrollToTopButton from '@/components/ScrollToTopButton' import { Titlebar } from '@/components/Titlebar' import { Button } from '@/components/ui/button' +import { + FOCUS_SECONDARY_SCROLL_SHORTCUT_KEY, + isRadixDialogOpen, + shouldIgnoreKeyboardShortcutEvent +} from '@/lib/keyboard-shortcuts' import { useSecondaryPage } from '@/PageManager' import { DeepBrowsingProvider } from '@/providers/DeepBrowsingProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -57,6 +62,24 @@ const SecondaryPageLayout = forwardRef( } }, []) + useEffect(() => { + if (isSmallScreen) return + if (currentIndex !== index) return + + const onKeyDown = (e: KeyboardEvent) => { + if (!e.altKey || !e.shiftKey || e.key.toLowerCase() !== FOCUS_SECONDARY_SCROLL_SHORTCUT_KEY) return + if (e.metaKey || e.ctrlKey) return + if (shouldIgnoreKeyboardShortcutEvent(e.target)) return + if (isRadixDialogOpen()) return + + e.preventDefault() + scrollAreaRef.current?.focus({ preventScroll: true }) + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [isSmallScreen, currentIndex, index]) + if (isSmallScreen) { return ( @@ -107,7 +130,11 @@ const SecondaryPageLayout = forwardRef( /> )} -
+
{children}
diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts new file mode 100644 index 00000000..e8a0a556 --- /dev/null +++ b/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,22 @@ +/** Shared guards for app-wide keyboard shortcuts (help, focus panes, etc.). */ + +export function isRadixDialogOpen(): boolean { + return !!document.querySelector('[data-radix-dialog-content][data-state="open"]') +} + +export function shouldIgnoreKeyboardShortcutEvent(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + const el = target + const tag = el.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true + if (el.isContentEditable) return true + if (el.closest('[contenteditable="true"]')) return true + if (el.closest('.ProseMirror')) return true + if (el.getAttribute('role') === 'textbox') return true + return false +} + +export const FOCUS_PRIMARY_SCROLL_SHORTCUT_KEY = 'f' +export const FOCUS_SECONDARY_SCROLL_SHORTCUT_KEY = 's' +/** Shift+Alt+N — open new note composer (handled in KeyboardShortcutsHelpProvider). */ +export const OPEN_NEW_POST_SHORTCUT_KEY = 'n' diff --git a/src/pages/primary/MePage/index.tsx b/src/pages/primary/MePage/index.tsx index 827f0c2b..0d4c12ee 100644 --- a/src/pages/primary/MePage/index.tsx +++ b/src/pages/primary/MePage/index.tsx @@ -21,7 +21,7 @@ import { UserRound, Wallet } from 'lucide-react' -import { forwardRef, HTMLProps, useState } from 'react' +import { forwardRef, HTMLProps, useState, type KeyboardEvent, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' const MePage = forwardRef((_, ref) => { @@ -117,6 +117,8 @@ function Item({ children, className, hideChevron = false, + onClick, + onKeyDown, ...props }: HTMLProps & { hideChevron?: boolean }) { return ( @@ -126,6 +128,16 @@ function Item({ className )} {...props} + role="button" + tabIndex={0} + onClick={onClick} + onKeyDown={(e: KeyboardEvent) => { + onKeyDown?.(e) + if (!e.defaultPrevented && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onClick?.(e as unknown as MouseEvent) + } + }} >
{children}
{!hideChevron && } diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index c2a01474..3f7aeba2 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -21,6 +21,7 @@ import React, { } from 'react' import { useTranslation } from 'react-i18next' import FeedButton from './FeedButton' +import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp' import ExploreButton from '@/components/Titlebar/ExploreButton' import AccountButton from '@/components/Titlebar/AccountButton' import FollowingFeed from './FollowingFeed' @@ -185,6 +186,7 @@ function NoteListPageTitlebar({ )} +
diff --git a/src/services/post-editor.service.ts b/src/services/post-editor.service.ts index 607b6a32..0793fb7d 100644 --- a/src/services/post-editor.service.ts +++ b/src/services/post-editor.service.ts @@ -17,6 +17,11 @@ class PostEditorService extends EventTarget { this.dispatchEvent(new CustomEvent('closeSuggestionPopup')) } } + + /** Opens the main “new note” composer (same as sidebar / write button). Listeners run login check. */ + requestOpenNewPost() { + this.dispatchEvent(new CustomEvent('requestOpenNewPost')) + } } const instance = new PostEditorService()