Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .changeset/vast-jokes-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/hotkeys-devtools': patch
'@tanstack/hotkeys': patch
---

feat: callback variant of conflictBehavior
6 changes: 5 additions & 1 deletion docs/reference/type-aliases/ConflictBehavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ title: ConflictBehavior
# Type Alias: ConflictBehavior

```ts
type ConflictBehavior = "warn" | "error" | "replace" | "allow";
type ConflictBehavior = "warn" | "error" | "replace" | "allow" | (
keyDisplay: string,
unregisterConflicting: () => void
) => void;
```

Defined in: [manager.utils.ts:11](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/manager.utils.ts#L11)
Expand All @@ -17,3 +20,4 @@ Behavior when registering a hotkey/sequence that conflicts with an existing regi
- `'error'` - Throw an error and prevent the new registration
- `'replace'` - Unregister the existing registration and register the new one
- `'allow'` - Allow multiple registrations without warning
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a new entry here but autofix removed it.

  • custom callback - You can log the issue with configuration or unregister conflicting registration conditionally e.g. with showing confirmation popup

How could I keep it?

- custom callback - You can log the issue with configuration or unregister conflicting registration conditionally e.g. with showing confirmation popup
17 changes: 14 additions & 3 deletions packages/hotkeys-devtools/src/components/DetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,18 @@ function getConflictLabel(
if (behavior === 'allow') return 'allowed'
if (behavior === 'error') return 'error'
if (behavior === 'replace') return 'replaced'
return 'warning'
if (behavior === 'warn') return 'warning'
return 'callback handled conflict with'
}

function serializeConflictBehavior(
behavior: ConflictBehavior
): string {
if (typeof behavior === 'string') {
return behavior
}

return '[Function function]'
}

function HotkeyDetails(props: {
Expand Down Expand Up @@ -285,7 +296,7 @@ function HotkeyDetails(props: {
</div>
<div class={styles().optionRow}>
<span class={styles().optionLabel}>conflictBehavior</span>
<span class={styles().optionValue}>{conflictBehavior()}</span>
<span class={styles().optionValue}>{serializeConflictBehavior(conflictBehavior())}</span>
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conflictBehavior() cannot be directly rendered in JSX any more. I considered String(conflictBehavior()) but it just dumps callback version as plain JS code which does not look good 😅

</div>
<div class={styles().optionRow}>
<span class={styles().optionLabel}>hasFired</span>
Expand Down Expand Up @@ -498,7 +509,7 @@ function SequenceDetails(props: {
</div>
<div class={styles().optionRow}>
<span class={styles().optionLabel}>conflictBehavior</span>
<span class={styles().optionValue}>{conflictBehavior()}</span>
<span class={styles().optionValue}>{serializeConflictBehavior(conflictBehavior())}</span>
</div>
</div>
</div>
Expand Down
12 changes: 11 additions & 1 deletion packages/hotkeys/src/manager.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ParsedHotkey } from './hotkey'

type CustomConflictHandler = (
keyDisplay: string,
unregisterAnotherConflictingId: () => void
) => void

/**
* Behavior when registering a hotkey/sequence that conflicts with an existing registration.
*
Expand All @@ -8,7 +13,7 @@ import type { ParsedHotkey } from './hotkey'
* - `'replace'` - Unregister the existing registration and register the new one
* - `'allow'` - Allow multiple registrations without warning
*/
export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow'
export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow' | CustomConflictHandler

/**
* Default options for hotkey/sequence registration.
Expand Down Expand Up @@ -164,6 +169,11 @@ export function handleConflict(
)
}

if (typeof conflictBehavior === 'function') {
conflictBehavior(keyDisplay, () => unregister(conflictingId))
Copy link
Copy Markdown
Author

@skyboyer skyboyer Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it makes sense to expose conflictingId and unregister separately in this case with a callback

return
}

// At this point, conflictBehavior must be 'replace'
unregister(conflictingId)
}
15 changes: 15 additions & 0 deletions packages/hotkeys/tests/manager.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,5 +286,20 @@ describe('manager.utils', () => {

expect(unregister).toHaveBeenCalledWith('id-1')
})

it('should call custom callback if passed as conflictBehaviour', () => {
const unregister = vi.fn()
const handleConflictCallback = vi.fn()

handleConflict('id-1', 'Mod+S', handleConflictCallback, unregister)

expect(unregister).not.toHaveBeenCalledWith()
expect(handleConflictCallback).toHaveBeenCalledWith(
'Mod+S',
expect.any(Function),
)
handleConflictCallback.mock.calls[0]?.[1]()
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mimics custom async logic inside the callback, e.g. call unregisterConflicting after displaying a popup and user clicked "Confirm"

expect(unregister).toHaveBeenCalledWith('id-1')
})
})
})
Loading