Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@ export const ExampleComponent = () => {

#### Changing a scope's active state

You can change the active state of a scope using the `disableScope`, `enableScope` and `toggleScope` functions
returned by the `useHotkeysContext()` hook. Note that you have to have your app wrapped in a `<HotkeysProvider>` component.
You can change the active state of a scope using the `disableScope`, `enableScope` and `toggleScope` functions returned by the `useHotkeysScopeContext()` hook. Wrap your app in a `<HotkeysProvider>` component.

```jsx harmony
const App = () => {
Expand All @@ -95,7 +94,7 @@ const App = () => {
}

export const ExampleComponent = () => {
const { toggleScope } = useHotkeysContext()
const { toggleScope } = useHotkeysScopeContext()

return (
<button onClick={() => toggleScope('settings')}>
Expand All @@ -105,6 +104,12 @@ export const ExampleComponent = () => {
}
```

`useHotkeysScopeContext()` returns `activeScopes`, `enableScope`, `disableScope`, and `toggleScope` without subscribing to the registered-hotkey list. Use it for components that only read or change scopes. Its return type is exported as `HotkeysScopeContextType`.

`useHotkeysContext()` remains supported and returns the same scope controls plus `hotkeys`. Use it when you need the live registry, such as a shortcut inspector. Registry additions and removals still update these consumers, but no longer trigger context-driven rerenders of unrelated `useHotkeys` or `useHotkeysScopeContext` consumers. Parent rerenders and genuine scope changes can still rerender them.

Enabling an already-enabled scope or disabling an absent scope preserves the active-scopes reference when the resulting scopes are unchanged. Existing wildcard behavior is preserved: enabling a scope while `'*'` is active replaces the active scopes with that scope.

### Focus trap

This will only trigger the hotkey if the component is focused.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@humanlayer/react-hotkeys-hook",
"description": "React hook for handling keyboard shortcuts (HumanLayer fork)",
"version": "5.3.1",
"version": "5.4.0",
"sideEffects": false,
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/react-hotkeys-hook/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@humanlayer/react-hotkeys-hook",
"version": "5.3.1",
"version": "5.4.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, type ReactNode, useContext } from 'react'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
import type { Hotkey } from './types'

type BoundHotkeysProxyProviderType = {
Expand All @@ -19,9 +19,6 @@ interface Props {
}

export default function BoundHotkeysProxyProviderProvider({ addHotkey, removeHotkey, children }: Props) {
return (
<BoundHotkeysProxyProvider.Provider value={{ addHotkey, removeHotkey }}>
{children}
</BoundHotkeysProxyProvider.Provider>
)
const value = useMemo(() => ({ addHotkey, removeHotkey }), [addHotkey, removeHotkey])
return <BoundHotkeysProxyProvider.Provider value={value}>{children}</BoundHotkeysProxyProvider.Provider>
}
55 changes: 35 additions & 20 deletions packages/react-hotkeys-hook/src/lib/HotkeysProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Hotkey } from './types'
import { createContext, type ReactNode, useState, useContext, useCallback } from 'react'
import { createContext, type ReactNode, useState, useContext, useCallback, useMemo } from 'react'
import BoundHotkeysProxyProviderProvider from './BoundHotkeysProxyProvider'
import deepEqual from './deepEqual'

Expand All @@ -11,19 +11,28 @@ export type HotkeysContextType = {
disableScope: (scope: string) => void
}

export type HotkeysScopeContextType = Omit<HotkeysContextType, 'hotkeys'>

// The context is only needed for special features like global scoping, so we use a graceful default fallback
const HotkeysContext = createContext<HotkeysContextType>({
hotkeys: [],
const defaultScopeContext: HotkeysScopeContextType = {
activeScopes: [], // This array has to be empty instead of containing '*' as default, to check if the provider is set or not
toggleScope: () => {},
enableScope: () => {},
disableScope: () => {},
})
}

const HotkeysScopeContext = createContext<HotkeysScopeContextType>(defaultScopeContext)
const HotkeysContext = createContext<HotkeysContextType>({ ...defaultScopeContext, hotkeys: [] })

export const useHotkeysContext = () => {
return useContext(HotkeysContext)
}

// Bindings and scope controls don't need updates when unrelated hotkeys register or unregister.
export const useHotkeysScopeContext = (): HotkeysScopeContextType => {
return useContext(HotkeysScopeContext)
}

interface Props {
initiallyActiveScopes?: string[]
children: ReactNode
Expand All @@ -35,29 +44,26 @@ export const HotkeysProvider = ({ initiallyActiveScopes = ['*'], children }: Pro

const enableScope = useCallback((scope: string) => {
setInternalActiveScopes((prev) => {
if (prev.includes('*')) {
return [scope]
}
return Array.from(new Set([...prev, scope]))
const next = prev.includes('*') ? [scope] : Array.from(new Set([...prev, scope]))
return next.length === prev.length && next.every((value, index) => value === prev[index]) ? prev : next
})
}, [])

const disableScope = useCallback((scope: string) => {
setInternalActiveScopes((prev) => {
return prev.filter((s) => s !== scope)
return prev.includes(scope) ? prev.filter((s) => s !== scope) : prev
})
}, [])

const toggleScope = useCallback((scope: string) => {
setInternalActiveScopes((prev) => {
if (prev.includes(scope)) {
return prev.filter((s) => s !== scope)
} else {
if (prev.includes('*')) {
return [scope]
}
return Array.from(new Set([...prev, scope]))
}
if (prev.includes('*')) {
return [scope]
}
return Array.from(new Set([...prev, scope]))
})
}, [])

Expand All @@ -69,13 +75,22 @@ export const HotkeysProvider = ({ initiallyActiveScopes = ['*'], children }: Pro
setBoundHotkeys((prev) => prev.filter((h) => !deepEqual(h, hotkey)))
}, [])

const scopeContext = useMemo<HotkeysScopeContextType>(
() => ({ activeScopes: internalActiveScopes, enableScope, disableScope, toggleScope }),
[internalActiveScopes, enableScope, disableScope, toggleScope],
)
const context = useMemo<HotkeysContextType>(
() => ({ ...scopeContext, hotkeys: boundHotkeys }),
[scopeContext, boundHotkeys],
)

return (
<HotkeysContext.Provider
value={{ activeScopes: internalActiveScopes, hotkeys: boundHotkeys, enableScope, disableScope, toggleScope }}
>
<BoundHotkeysProxyProviderProvider addHotkey={addBoundHotkey} removeHotkey={removeBoundHotkey}>
{children}
</BoundHotkeysProxyProviderProvider>
<HotkeysContext.Provider value={context}>
<HotkeysScopeContext.Provider value={scopeContext}>
<BoundHotkeysProxyProviderProvider addHotkey={addBoundHotkey} removeHotkey={removeBoundHotkey}>
{children}
</BoundHotkeysProxyProviderProvider>
</HotkeysScopeContext.Provider>
</HotkeysContext.Provider>
)
}
9 changes: 8 additions & 1 deletion packages/react-hotkeys-hook/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import useHotkeys from './useHotkeys'
import type { Options, Keys, HotkeyCallback } from './types'
import { HotkeysProvider, useHotkeysContext } from './HotkeysProvider'
import {
HotkeysProvider,
useHotkeysContext,
useHotkeysScopeContext,
type HotkeysScopeContextType,
} from './HotkeysProvider'
import { isHotkeyPressed } from './isHotkeyPressed'
import useRecordHotkeys from './useRecordHotkeys'

export {
useHotkeys,
useRecordHotkeys,
useHotkeysContext,
useHotkeysScopeContext,
isHotkeyPressed,
HotkeysProvider,
type Options,
type Keys,
type HotkeyCallback,
type HotkeysScopeContextType,
}
4 changes: 2 additions & 2 deletions packages/react-hotkeys-hook/src/lib/useHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
isScopeActive,
maybePreventDefault,
} from './validators'
import { useHotkeysContext } from './HotkeysProvider'
import { useHotkeysScopeContext } from './HotkeysProvider'
import { useBoundHotkeysProxy } from './BoundHotkeysProxyProvider'
import useDeepEqualMemo from './useDeepEqualMemo'
import { isReadonlyArray, pushToCurrentlyPressedKeys, removeFromCurrentlyPressedKeys } from './isHotkeyPressed'
Expand Down Expand Up @@ -55,7 +55,7 @@ export default function useHotkeys<T extends HTMLElement>(

const memoisedOptions = useDeepEqualMemo(_options)

const { activeScopes } = useHotkeysContext()
const { activeScopes } = useHotkeysScopeContext()
const proxy = useBoundHotkeysProxy()

useSafeLayoutEffect(() => {
Expand Down
Loading