feat: enhance command binding and shortcut management

- Introduced `useCommandBinding` to streamline the registration of command hotkeys.
- Added new entry navigation commands for scrolling to the next and previous entries.
- Updated existing components to utilize the new command binding approach, improving consistency and maintainability.
- Refactored shortcut handling to support new entry render commands, enhancing user experience.

This update aims to improve the overall efficiency of command and shortcut management in the application.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-13 21:12:47 +08:00
parent 93f19c4f6c
commit 262a7ce55e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
14 changed files with 199 additions and 128 deletions

View File

@ -32,7 +32,6 @@ import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
import { HotkeyScope } from "~/constants"
import { ROOT_CONTAINER_ID } from "~/constants/dom"
import { shortcuts } from "~/constants/shortcuts"
import { useDailyTask } from "~/hooks/biz/useDailyTask"
import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions"
import { useI18n } from "~/hooks/common"
@ -40,7 +39,7 @@ import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator"
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { DebugRegistry } from "~/modules/debug/registry"
import { CmdF } from "~/modules/panel/cmdf"
import { SearchCmdK } from "~/modules/panel/cmdk"
@ -244,9 +243,8 @@ const FeedResponsiveResizerContainer = ({
const activeScopes = useHotkeyScope()
useCommandHotkey({
useCommandBinding({
commandId: COMMAND_ID.layout.toggleTimelineColumn,
shortcut: shortcuts.layout.toggleSidebar.key,
when: activeScopes.includes(HotkeyScope.Home),
})

View File

@ -8,9 +8,13 @@ declare module "@follow/utils/event-bus" {
interface EventBusMap {
"entry-render:scroll-down": never
"entry-render:scroll-up": never
"entry-render:next-entry": never
"entry-render:previous-entry": never
}
}
const LABEL_PREFIX = "Entry Render"
const category = "follow:entry-render"
export const useRegisterEntryRenderCommand = () => {
useRegisterCommandEffect([
{
@ -18,7 +22,7 @@ export const useRegisterEntryRenderCommand = () => {
run: () => {
EventBus.dispatch(COMMAND_ID.entryRender.scrollDown)
},
category: "follow:entry-render",
category,
label: `${LABEL_PREFIX}: Scroll down`,
},
{
@ -26,9 +30,27 @@ export const useRegisterEntryRenderCommand = () => {
run: () => {
EventBus.dispatch(COMMAND_ID.entryRender.scrollUp)
},
category: "follow:entry-render",
category,
label: `${LABEL_PREFIX}: Scroll up`,
},
{
id: COMMAND_ID.entryRender.nextEntry,
run: () => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
EventBus.dispatch(COMMAND_ID.entryRender.nextEntry)
},
category,
label: `${LABEL_PREFIX}: Next entry`,
},
{
id: COMMAND_ID.entryRender.previousEntry,
run: () => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
EventBus.dispatch(COMMAND_ID.entryRender.previousEntry)
},
category,
label: `${LABEL_PREFIX}: Previous entry`,
},
])
}
@ -42,4 +64,18 @@ type EntryScrollUpCommand = Command<{
fn: () => void
}>
export type EntryRenderCommand = EntryScrollDownCommand | EntryScrollUpCommand
type EntryNextEntryCommand = Command<{
id: typeof COMMAND_ID.entryRender.nextEntry
fn: () => void
}>
type EntryPreviousEntryCommand = Command<{
id: typeof COMMAND_ID.entryRender.previousEntry
fn: () => void
}>
export type EntryRenderCommand =
| EntryScrollDownCommand
| EntryScrollUpCommand
| EntryNextEntryCommand
| EntryPreviousEntryCommand

View File

@ -55,6 +55,8 @@ export const COMMAND_ID = {
entryRender: {
scrollDown: "entry-render:scroll-down",
scrollUp: "entry-render:scroll-up",
nextEntry: "entry-render:next-entry",
previousEntry: "entry-render:previous-entry",
},
subscription: {
switchTabToNext: "subscription:switch-tab-to-next",

View File

@ -0,0 +1,40 @@
import { shortcuts } from "~/constants/shortcuts"
import { COMMAND_ID } from "../commands/id"
const defaultCommandShortcuts = {
[COMMAND_ID.entry.read]: shortcuts.entry.toggleRead.key,
[COMMAND_ID.entry.openInBrowser]: shortcuts.entry.openInBrowser.key,
[COMMAND_ID.entry.star]: shortcuts.entry.toggleStarred.key,
[COMMAND_ID.entry.copyLink]: shortcuts.entry.copyLink.key,
[COMMAND_ID.entry.copyTitle]: shortcuts.entry.copyTitle.key,
[COMMAND_ID.entry.tts]: shortcuts.entry.tts.key,
[COMMAND_ID.entry.tip]: shortcuts.entry.tip.key,
[COMMAND_ID.entry.share]: shortcuts.entry.share.key,
[COMMAND_ID.entryRender.scrollUp]: shortcuts.entry.scrollUp.key,
[COMMAND_ID.entryRender.scrollDown]: shortcuts.entry.scrollDown.key,
[COMMAND_ID.timeline.switchToNext]: shortcuts.entries.next.key,
[COMMAND_ID.timeline.switchToPrevious]: shortcuts.entries.previous.key,
[COMMAND_ID.timeline.refetch]: shortcuts.entries.refetch.key,
[COMMAND_ID.layout.toggleTimelineColumn]: shortcuts.layout.toggleSidebar.key,
[COMMAND_ID.subscription.switchTabToNext]: shortcuts.feeds.switchNextView.key,
[COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.feeds.switchPreviousView.key,
[COMMAND_ID.global.showShortcuts]: shortcuts.layout.showShortcuts.key,
[COMMAND_ID.entryRender.nextEntry]: shortcuts.entry.nextEntry.key,
[COMMAND_ID.entryRender.previousEntry]: shortcuts.entry.previousEntry.key,
} as const
export type BindingCommandId = keyof typeof defaultCommandShortcuts
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
export const useCommandShortcut = (commandId: BindingCommandId): string => {
const commandShortcut = defaultCommandShortcuts[commandId]
return commandShortcut
}

View File

@ -1,8 +1,10 @@
import { useEffect, useRef } from "react"
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
import type { FollowCommand, FollowCommandId } from "../types"
import { getCommand } from "./use-command"
import type { BindingCommandId } from "./use-command-shortcut"
import { useCommandShortcut } from "./use-command-shortcut"
interface RegisterHotkeyOptions<T extends FollowCommandId> {
shortcut: string
@ -14,14 +16,15 @@ interface RegisterHotkeyOptions<T extends FollowCommandId> {
export const useCommandHotkey = <T extends FollowCommandId>({
shortcut,
commandId,
when = true,
when,
args,
}: RegisterHotkeyOptions<T>) => {
const unsubscribeRef = useRef<() => void>(void 0)
useEffect(() => {
if (!when) {
unsubscribeRef.current?.()
return
}
if (!shortcut) {
return
}
@ -55,10 +58,21 @@ export const useCommandHotkey = <T extends FollowCommandId>({
}
})
unsubscribeRef.current = tinykeys(document.documentElement, keyMap)
return () => {
unsubscribeRef.current?.()
}
return tinykeys(document.documentElement, keyMap)
}, [shortcut, commandId, when, args])
}
export const useCommandBinding = <T extends BindingCommandId>({
commandId,
when = true,
args,
}: Omit<RegisterHotkeyOptions<T>, "shortcut">) => {
const commandShortcut = useCommandShortcut(commandId)
return useCommandHotkey({
shortcut: commandShortcut,
commandId,
when,
args,
})
}

View File

@ -5,14 +5,13 @@ import { memo, useEffect, useLayoutEffect, useState } from "react"
import { useMainContainerElement } from "~/atoms/dom"
import { HotkeyScope } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteEntryId } from "~/hooks/biz/useRouteParams"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-register-hotkey"
export const EntryColumnShortcutHandler: FC<{
refetch: () => void
@ -26,26 +25,24 @@ export const EntryColumnShortcutHandler: FC<{
const when =
activeScope.includes(HotkeyScope.Timeline) && !activeScope.includes(HotkeyScope.EntryRender)
useCommandHotkey({
shortcut: shortcuts.entries.next.key,
useCommandBinding({
commandId: COMMAND_ID.timeline.switchToNext,
when,
})
useCommandHotkey({
shortcut: shortcuts.entries.previous.key,
useCommandBinding({
commandId: COMMAND_ID.timeline.switchToPrevious,
when,
})
useCommandHotkey({
shortcut: shortcuts.entries.refetch.key,
useCommandBinding({
commandId: COMMAND_ID.timeline.refetch,
when,
})
const currentEntryIdRef = useRefValue(useRouteEntryId())
const navigate = useNavigateEntry()
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.timeline.switchToNext, () => {
const data = dataRef.current
@ -60,7 +57,7 @@ export const EntryColumnShortcutHandler: FC<{
entryId: nextId,
})
})
}, [currentEntryIdRef, dataRef, handleScrollTo, navigate])
}, [currentEntryIdRef, dataRef, handleScrollTo, navigate, when])
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, () => {

View File

@ -3,10 +3,9 @@ import type { FeedViewType } from "@follow/constants"
import { MenuItemText } from "~/atoms/context-menu"
import { CommandActionButton } from "~/components/ui/button/CommandActionButton"
import { useHasModal } from "~/components/ui/modal/stacked/hooks"
import { shortcuts } from "~/constants/shortcuts"
import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { useEntry } from "~/store/entry/hooks"
export const EntryHeaderActions = ({
@ -23,9 +22,8 @@ export const EntryHeaderActions = ({
const hasModal = useHasModal()
useCommandHotkey({
useCommandBinding({
when: !!entry?.entries.url && !hasModal,
shortcut: shortcuts.entry.openInBrowser.key,
commandId: COMMAND_ID.entry.openInBrowser,
args: [{ entryId }],
})

View File

@ -1,6 +1,5 @@
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { createElement, useCallback, useEffect } from "react"
import { createElement, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -8,22 +7,6 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { ImageGalleryContent } from "./components/ImageGalleryContent"
declare module "@follow/utils/event-bus" {
export interface CustomEvent {
FOCUS_ENTRY_CONTAINER: never
}
}
export const useFocusEntryContainerSubscriptions = (
ref: React.RefObject<HTMLDivElement | null>,
) => {
useEffect(() => {
return EventBus.subscribe("FOCUS_ENTRY_CONTAINER", () => {
ref.current?.focus()
})
}, [ref])
}
export const useGalleryModal = () => {
const { present } = useModalStack()
const { t } = useTranslation()

View File

@ -1,3 +1,4 @@
import { Focusable, useFocusable } from "@follow/components/common/Focusable.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import type { FeedViewType } from "@follow/constants"
@ -6,10 +7,10 @@ import type { FeedModel, InboxModel } from "@follow/models/types"
import { stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
import { cn, combineCleanupFunctions } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import * as React from "react"
import { useEffect, useMemo, useRef } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
useEntryIsInReadability,
@ -21,7 +22,6 @@ import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery, useConditionalHotkeyScope } from "~/hooks/common"
@ -35,7 +35,7 @@ import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-register-hotkey"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
@ -43,7 +43,6 @@ import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { useFocusEntryContainerSubscriptions } from "./hooks"
import type { EntryContentProps } from "./index.shared"
import {
ContainerToc,
@ -82,13 +81,20 @@ export const EntryContent: Component<EntryContentProps> = ({
const isInReadabilityMode = useEntryIsInReadability(entryId)
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const scrollerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
scrollerRef.current?.scrollTo(0, 0)
scrollerRef.current?.focus()
}, [entryId])
useFocusEntryContainerSubscriptions(scrollerRef)
const scrollerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const scrollAndFocus = () => {
scrollerRef.current?.scrollTo(0, 0)
}
scrollAndFocus()
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.timeline.switchToNext, scrollAndFocus),
EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, scrollAndFocus),
)
}, [])
const safeUrl = useFeedSafeUrl(entryId)
@ -101,11 +107,7 @@ export const EntryContent: Component<EntryContentProps> = ({
const isInPeekModal = useInPeekModal()
const { setIsUserInteraction } = useRegisterCommands({
entryId,
scrollerRef,
})
const [isUserInteraction, setIsUserInteraction] = useState(false)
if (!entry) return null
const entryContent = isInReadabilityMode
@ -129,7 +131,15 @@ export const EntryContent: Component<EntryContentProps> = ({
/>
)}
<div className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible">
<Focusable
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
onFocus={() => setIsUserInteraction(true)}
>
<RegisterCommands
entryId={entry.entries.id}
scrollerRef={scrollerRef}
isUserInteraction={isUserInteraction}
/>
<EntryTimelineSidebar entryId={entry.entries.id} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
<div
@ -138,7 +148,7 @@ export const EntryContent: Component<EntryContentProps> = ({
>
<article
tabIndex={-1}
onClick={() => setIsUserInteraction(true)}
onFocus={() => setIsUserInteraction(true)}
data-testid="entry-render"
onContextMenu={stopPropagation}
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
@ -202,7 +212,7 @@ export const EntryContent: Component<EntryContentProps> = ({
</div>
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</div>
</Focusable>
</>
)
}
@ -286,41 +296,37 @@ const Renderer: React.FC<{
)
})
const useRegisterCommands = ({
entryId,
const RegisterCommands = ({
scrollerRef,
isUserInteraction,
}: {
entryId: string
scrollerRef: React.RefObject<HTMLDivElement | null>
isUserInteraction: boolean
}) => {
const [isUserInteraction, setIsUserInteraction] = React.useState(false)
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction, true)
const containerFocused = useFocusable()
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
const activeScope = useHotkeyScope()
const when = activeScope.includes(HotkeyScope.EntryRender)
useCommandHotkey({
shortcut: shortcuts.entry.scrollUp.key,
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollUp,
when,
})
useCommandHotkey({
shortcut: shortcuts.entry.scrollDown.key,
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollDown,
when,
})
useCommandHotkey({
shortcut: shortcuts.entry.nextEntry.key,
commandId: COMMAND_ID.timeline.switchToNext,
useCommandBinding({
commandId: COMMAND_ID.entryRender.nextEntry,
when,
})
useCommandHotkey({
shortcut: shortcuts.entry.previousEntry.key,
commandId: COMMAND_ID.timeline.switchToPrevious,
useCommandBinding({
commandId: COMMAND_ID.entryRender.previousEntry,
when,
})
@ -345,11 +351,5 @@ const useRegisterCommands = ({
})
}, [scrollerRef])
useEffect(() => {
return () => setIsUserInteraction(false)
}, [entryId])
return {
setIsUserInteraction,
}
return null
}

View File

@ -5,7 +5,6 @@ import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { WEB_BUILD } from "@follow/shared/constants"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
import type { FallbackRender } from "@sentry/react"
@ -261,9 +260,6 @@ export const ContainerToc = memo(
<div className="sticky top-0">
<Toc
ref={ref}
onItemClick={() => {
EventBus.dispatch("FOCUS_ENTRY_CONTAINER")
}}
className={cn(
"animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft flex flex-col items-end",
"scrollbar-none max-h-[calc(100vh-100px)] overflow-auto",

View File

@ -17,7 +17,6 @@ import { useRootContainerElement } from "~/atoms/dom"
import { useUISettingKey } from "~/atoms/settings/ui"
import { setTimelineColumnShow, useTimelineColumnShow } from "~/atoms/sidebar"
import { HotkeyScope } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry"
import { useReduceMotion } from "~/hooks/biz/useReduceMotion"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
@ -27,7 +26,7 @@ import { useHotkeyScope } from "~/providers/hotkey-provider"
import { WindowUnderBlur } from "../../components/ui/background"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-register-hotkey"
import { getSelectedFeedIds, resetSelectedFeedIds, setSelectedFeedIds } from "./atom"
import { useShouldFreeUpSpace } from "./hook"
import { TimelineColumnHeader } from "./TimelineColumnHeader"
@ -245,15 +244,13 @@ const useRegisterCommands = ({
const activeScope = useHotkeyScope()
const when =
activeScope.includes(HotkeyScope.SubscriptionList) || activeScope.includes(HotkeyScope.Timeline)
useCommandHotkey({
useCommandBinding({
commandId: COMMAND_ID.subscription.switchTabToNext,
shortcut: shortcuts.feeds.switchNextView.key,
when,
})
useCommandHotkey({
useCommandBinding({
commandId: COMMAND_ID.subscription.switchTabToPrevious,
shortcut: shortcuts.feeds.switchPreviousView.key,
when,
})

View File

@ -1,17 +1,16 @@
import { HotkeyScope } from "~/constants/hotkeys"
import { shortcuts } from "~/constants/shortcuts"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { useHotkeyScope } from "./hotkey-provider"
export const GlobalHotkeysProvider = () => {
const activeScopes = useHotkeyScope()
useCommandHotkey({
useCommandBinding({
commandId: COMMAND_ID.global.showShortcuts,
shortcut: shortcuts.layout.showShortcuts.key,
when: activeScopes.includes(HotkeyScope.Home),
when:
activeScopes.includes(HotkeyScope.Home) && !activeScopes.includes(HotkeyScope.EntryRender),
})
return null

View File

@ -1,40 +1,37 @@
import type { FocusEvent } from "react"
import { createContext, use, useCallback, useState } from "react"
import { createContext, use, useImperativeHandle, useRef, useState } from "react"
import { useEventListener } from "usehooks-ts"
// const
const FocusableContext = createContext(false)
const FocusTargetRefContext = createContext<React.RefObject<HTMLElement | undefined>>(null!)
export const Focusable: Component<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ ref, ...props }) => {
const { onBlur, onFocus, ...rest } = props
const [isFocusWithIn, setIsFocusWithIn] = useState(false)
const handleFocus = useCallback(
(e: FocusEvent<HTMLDivElement>) => {
onFocus?.(e)
const focusTargetRef = useRef<HTMLElement | undefined>(void 0)
const containerRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => containerRef.current!)
useEventListener("focusin", (e) => {
if (containerRef.current?.contains(e.target as Node)) {
setIsFocusWithIn(true)
},
[onFocus],
)
const handleBlur = useCallback(
(e: FocusEvent<HTMLDivElement>) => {
onBlur?.(e)
} else {
setIsFocusWithIn(false)
},
[onBlur],
)
}
})
// useEventListener("focusout", (e) => {
// if (!containerRef.current?.contains(e.target as Node)) {
// setIsFocusWithIn(false)
// }
// })
return (
<FocusableContext value={isFocusWithIn}>
<div
tabIndex={-1}
role="region"
ref={ref}
{...rest}
onBlur={handleBlur}
onFocusCapture={handleFocus}
onBlurCapture={handleBlur}
onFocus={handleFocus}
/>
<FocusTargetRefContext value={focusTargetRef}>
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
</FocusTargetRefContext>
</FocusableContext>
)
}
@ -42,3 +39,7 @@ export const Focusable: Component<
export const useFocusable = () => {
return use(FocusableContext)
}
export const useFocusTargetRef = () => {
return use(FocusTargetRefContext)
}

View File

@ -407,3 +407,13 @@ export function duplicateIfLengthLessThan(text: string, length: number) {
? text.repeat(Math.ceil(length / text.length))
: text
}
export function combineCleanupFunctions(...fns: Array<Nullable<(() => void) | void>>) {
return () => {
fns.forEach((fn) => {
if (typeof fn === "function") {
fn()
}
})
}
}