From aab2fb38d5e2d48e760a19fab8fd08a3793b1c82 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 4 Mar 2026 09:55:21 +0100 Subject: [PATCH 1/5] feat: migrate to hotkeys --- docs/configuration.md | 14 +- .../interfaces/tanstackdevtoolsinit.md | 3 +- packages/devtools/package.json | 2 +- .../src/components/source-inspector.tsx | 10 +- .../devtools/src/context/devtools-store.ts | 32 ++-- packages/devtools/src/devtools.tsx | 45 +++--- packages/devtools/src/tabs/hotkey-config.tsx | 99 ++++++------ packages/devtools/src/tabs/settings-tab.tsx | 4 +- packages/devtools/src/utils/hotkey.test.ts | 147 +++++++++--------- packages/devtools/src/utils/hotkey.ts | 84 ++++------ pnpm-lock.yaml | 41 ++++- 11 files changed, 238 insertions(+), 243 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a49d5db3..e47f8009 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,22 +41,18 @@ The `config` object is mainly focused around user interaction with the devtools { panelLocation: 'top' | 'bottom' } ``` -- `openHotkey` - The hotkey set to open the devtools +- `openHotkey` - The hotkey to open the devtools. Uses the [`Hotkey`](https://tanstack.com/hotkeys) type from `@tanstack/hotkeys` (e.g. `"Control+\``"`, `"Mod+D"`). `Mod` maps to Command on macOS and Control on Windows/Linux. ```ts -type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' | 'CtrlOrMeta'; -type KeyboardKey = ModifierKey | (string & {}); +import type { Hotkey } from '@tanstack/hotkeys' -{ openHotkey: Array } +{ openHotkey: Hotkey } ``` -- `inspectHotkey` - The hotkey set to open the source inspector +- `inspectHotkey` - The hotkey to open the source inspector. Uses [TanStack Hotkeys](https://tanstack.com/hotkeys) string format (e.g. `"Mod+Shift"`). `Mod` maps to Command on macOS and Control on Windows/Linux. ```ts -type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' | 'CtrlOrMeta'; -type KeyboardKey = ModifierKey | (string & {}); - -{ inspectHotkey: Array } +{ inspectHotkey: string } ``` - `requireUrlFlag` - Requires a flag present in the url to enable devtools diff --git a/docs/reference/interfaces/tanstackdevtoolsinit.md b/docs/reference/interfaces/tanstackdevtoolsinit.md index eeb4e5f6..e4abdc4b 100644 --- a/docs/reference/interfaces/tanstackdevtoolsinit.md +++ b/docs/reference/interfaces/tanstackdevtoolsinit.md @@ -18,7 +18,8 @@ optional config: Partial<{ customTrigger: (el, props) => void; defaultOpen: boolean; hideUntilHover: boolean; - openHotkey: KeyboardKey[]; + openHotkey: Hotkey; + inspectHotkey: string; panelLocation: "top" | "bottom"; position: TriggerPosition; requireUrlFlag: boolean; diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 50acc092..120ae2cb 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -60,9 +60,9 @@ }, "dependencies": { "@solid-primitives/event-listener": "^2.4.3", - "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "workspace:*", + "@tanstack/solid-hotkeys": "^0.3.0", "@tanstack/devtools-event-bus": "workspace:*", "@tanstack/devtools-ui": "workspace:*", "clsx": "^2.1.1", diff --git a/packages/devtools/src/components/source-inspector.tsx b/packages/devtools/src/components/source-inspector.tsx index 6ae5401e..6a46800a 100644 --- a/packages/devtools/src/components/source-inspector.tsx +++ b/packages/devtools/src/components/source-inspector.tsx @@ -1,11 +1,12 @@ import { createEffect, createMemo, createSignal } from 'solid-js' import { createStore } from 'solid-js/store' import { createElementSize } from '@solid-primitives/resize-observer' -import { useKeyDownList } from '@solid-primitives/keyboard' +import { createHeldKeys } from '@tanstack/solid-hotkeys' import { createEventListener } from '@solid-primitives/event-listener' import { useDevtoolsSettings } from '../context/use-devtools-context' -import { isHotkeyCombinationPressed } from '../utils/hotkey' +import { initialState } from '../context/devtools-store' +import { isHotkeyHeld } from '../utils/hotkey' export const SourceInspector = () => { const { settings } = useDevtoolsSettings() @@ -28,10 +29,11 @@ export const SourceInspector = () => { setMousePosition({ x: e.clientX, y: e.clientY }) }) - const downList = useKeyDownList() + const heldKeys = createHeldKeys() const isHighlightingKeysHeld = createMemo(() => { - return isHotkeyCombinationPressed(downList(), settings().inspectHotkey) + const hotkey = settings().inspectHotkey || initialState.settings.inspectHotkey + return isHotkeyHeld(heldKeys(), hotkey) }) createEffect(() => { diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index 94e20032..787b39c2 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -1,17 +1,7 @@ +import type { Hotkey } from '@tanstack/solid-hotkeys' import type { TabName } from '../tabs' import type { TanStackDevtoolsPlugin } from './devtools-context' -type ModifierKey = 'Alt' | 'Control' | 'Meta' | 'Shift' | 'CtrlOrMeta' -type KeyboardKey = ModifierKey | (string & {}) -export type { ModifierKey, KeyboardKey } -export const keyboardModifiers: Array = [ - 'Alt', - 'Control', - 'Meta', - 'Shift', - 'CtrlOrMeta', -] - type TriggerPosition = | 'top-left' | 'top-right' @@ -48,15 +38,19 @@ export type DevtoolsStore = { */ panelLocation: 'top' | 'bottom' /** - * The hotkey to open the dev tools - * @default ["Control", "~"] + * The hotkey to open the dev tools. + * Uses TanStack Hotkeys string format (e.g. "Mod+S", "Control+`"). + * "Mod" maps to Command on macOS and Control on Windows/Linux. + * @default "Control+`" */ - openHotkey: Array + openHotkey: Hotkey /** - * The hotkey to open the source inspector - * @default ["Shift", "CtrlOrMeta"] + * The hotkey to open the source inspector. + * Uses TanStack Hotkeys string format (e.g. "Mod+Shift"). + * "Mod" maps to Command on macOS and Control on Windows/Linux. + * @default "Mod+Shift" */ - inspectHotkey: Array + inspectHotkey: string /** * Whether to require the URL flag to open the dev tools * @default false @@ -99,8 +93,8 @@ export const initialState: DevtoolsStore = { hideUntilHover: false, position: 'bottom-right', panelLocation: 'bottom', - openHotkey: ['Control', '~'], - inspectHotkey: ['Shift', 'CtrlOrMeta'], + openHotkey: 'Control+`' as Hotkey, + inspectHotkey: 'Mod+Shift', requireUrlFlag: false, urlFlag: 'tanstack-devtools', theme: diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 64f38065..27844518 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -1,5 +1,5 @@ import { Show, createEffect, createSignal, onCleanup } from 'solid-js' -import { createShortcut } from '@solid-primitives/keyboard' +import { createHotkey } from '@tanstack/solid-hotkeys' import { Portal } from 'solid-js/web' import { ThemeContextProvider } from '@tanstack/devtools-ui' import { devtoolsEventClient } from '@tanstack/devtools-client' @@ -12,7 +12,6 @@ import { } from './context/use-devtools-context' import { useDisableTabbing } from './hooks/use-disable-tabbing' import { TANSTACK_DEVTOOLS } from './utils/storage' -import { getHotkeyPermutations } from './utils/hotkey' import { Trigger } from './components/trigger' import { MainPanel } from './components/main-panel' import { ContentPanel } from './components/content-panel' @@ -164,30 +163,28 @@ export default function DevTools() { el?.style.setProperty('--tsrd-font-size', fontSize) } }) - createEffect(() => { - const isEditableTarget = (element: Element | null) => { - if (!element || !(element instanceof HTMLElement)) return false - if (element.isContentEditable) return true - const tagName = element.tagName - if ( - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - tagName === 'SELECT' - ) { - return true - } - return element.getAttribute('role') === 'textbox' + const isEditableTarget = (element: Element | null) => { + if (!element || !(element instanceof HTMLElement)) return false + if (element.isContentEditable) return true + const tagName = element.tagName + if ( + tagName === 'INPUT' || + tagName === 'TEXTAREA' || + tagName === 'SELECT' + ) { + return true } + return element.getAttribute('role') === 'textbox' + } - const permutations = getHotkeyPermutations(settings().openHotkey) - - for (const permutation of permutations) { - createShortcut(permutation, () => { - if (isEditableTarget(document.activeElement)) return - toggleOpen() - }) - } - }) + createHotkey( + () => settings().openHotkey, + () => { + if (isEditableTarget(document.activeElement)) return + toggleOpen() + }, + { preventDefault: false, stopPropagation: false }, + ) const { theme } = useTheme() diff --git a/packages/devtools/src/tabs/hotkey-config.tsx b/packages/devtools/src/tabs/hotkey-config.tsx index 461e30ed..18308807 100644 --- a/packages/devtools/src/tabs/hotkey-config.tsx +++ b/packages/devtools/src/tabs/hotkey-config.tsx @@ -1,72 +1,69 @@ import { Show } from 'solid-js' import { Button, Input } from '@tanstack/devtools-ui' -import { uppercaseFirstLetter } from '../utils/sanitize' import { useStyles } from '../styles/use-styles' -import type { KeyboardKey } from '../context/devtools-store' +import type { Key, Modifier } from '@tanstack/solid-hotkeys' -interface HotkeyConfigProps { +interface HotkeyConfigProps { title: string description: string - hotkey: Array - modifiers: Array - onHotkeyChange: (hotkey: Array) => void + hotkey: T | null | undefined + modifiers: Array + onHotkeyChange: (hotkey: T) => void } -const MODIFIER_DISPLAY_NAMES: Record = { +const MODIFIER_DISPLAY_NAMES: Partial> = { Shift: 'Shift', Alt: 'Alt', - Meta: 'Meta', + Mod: 'Ctrl Or Cmd', Control: 'Control', - CtrlOrMeta: 'Ctrl Or Meta', + Command: 'Command', } -export const HotkeyConfig = (props: HotkeyConfigProps) => { +/** Splits a hotkey string like "Mod+Shift+A" into its parts */ +const parseHotkeyParts = ( + hotkey: string | null | undefined, + modifiers: Array, +): { activeModifiers: Array; key: string } => { + if (typeof hotkey !== 'string' || !hotkey) return { activeModifiers: [], key: '' } + const parts = hotkey.split('+').map((p) => p.trim()) + const modifierStrings: Array = modifiers + const activeModifiers = parts.filter((p): p is Modifier => + modifierStrings.includes(p), + ) + const keyParts = parts.filter((p) => !modifierStrings.includes(p)) + return { activeModifiers, key: keyParts.join('+') } +} + +/** Joins modifiers and key back into a hotkey string */ +const buildHotkeyString = ( + modifiers: Array, + key: Key | string, +): string => { + const parts: Array = [...modifiers] + if (key) parts.push(key) + return parts.join('+') +} + +export const HotkeyConfig = (props: HotkeyConfigProps) => { const styles = useStyles() - const toggleModifier = (modifier: KeyboardKey) => { - if (props.hotkey.includes(modifier)) { - props.onHotkeyChange(props.hotkey.filter((key) => key !== modifier)) + const parsed = () => parseHotkeyParts(props.hotkey, props.modifiers) + + const toggleModifier = (modifier: Modifier) => { + const { activeModifiers, key } = parsed() + let newModifiers: Array + if (activeModifiers.includes(modifier)) { + newModifiers = activeModifiers.filter((m) => m !== modifier) } else { - const existingModifiers = props.hotkey.filter((key) => - props.modifiers.includes(key as any), - ) - const otherKeys = props.hotkey.filter( - (key) => !props.modifiers.includes(key as any), - ) - props.onHotkeyChange([...existingModifiers, modifier, ...otherKeys]) + newModifiers = [...activeModifiers, modifier] } - } - - const getNonModifierValue = () => { - return props.hotkey - .filter((key) => !props.modifiers.includes(key as any)) - .join('+') + props.onHotkeyChange(buildHotkeyString(newModifiers, key) as T) } const handleKeyInput = (input: string) => { - const makeModifierArray = (key: string) => { - if (key.length === 1) return [uppercaseFirstLetter(key)] - const modifiersArray: Array = [] - for (const character of key) { - const newLetter = uppercaseFirstLetter(character) - if (!modifiersArray.includes(newLetter)) modifiersArray.push(newLetter) - } - return modifiersArray - } - - const hotkeyModifiers = props.hotkey.filter((key) => - props.modifiers.includes(key as any), - ) - const newKeys = input - .split('+') - .flatMap((key) => makeModifierArray(key)) - .filter(Boolean) - props.onHotkeyChange([...hotkeyModifiers, ...newKeys]) - } - - const getDisplayHotkey = () => { - return props.hotkey.join(' + ') + const { activeModifiers } = parsed() + props.onHotkeyChange(buildHotkeyString(activeModifiers, input) as T) } return ( @@ -78,7 +75,7 @@ export const HotkeyConfig = (props: HotkeyConfigProps) => { @@ -88,10 +85,10 @@ export const HotkeyConfig = (props: HotkeyConfigProps) => { - Final shortcut is: {getDisplayHotkey()} + Final shortcut is: {props.hotkey} ) } diff --git a/packages/devtools/src/tabs/settings-tab.tsx b/packages/devtools/src/tabs/settings-tab.tsx index 60a42eea..4a025bf3 100644 --- a/packages/devtools/src/tabs/settings-tab.tsx +++ b/packages/devtools/src/tabs/settings-tab.tsx @@ -19,13 +19,13 @@ import { import { useDevtoolsSettings } from '../context/use-devtools-context' import { useStyles } from '../styles/use-styles' import { HotkeyConfig } from './hotkey-config' -import type { KeyboardKey } from '../context/devtools-store' +import type { Modifier } from '@tanstack/solid-hotkeys' export const SettingsTab = () => { const { setSettings, settings } = useDevtoolsSettings() const styles = useStyles() - const modifiers: Array = ['CtrlOrMeta', 'Alt', 'Shift'] + const modifiers: Array = ['Mod', 'Alt', 'Shift'] return ( diff --git a/packages/devtools/src/utils/hotkey.test.ts b/packages/devtools/src/utils/hotkey.test.ts index ea654e4b..fde0913d 100644 --- a/packages/devtools/src/utils/hotkey.test.ts +++ b/packages/devtools/src/utils/hotkey.test.ts @@ -1,109 +1,110 @@ -import { describe, expect, it } from 'vitest' -import { - getHotkeyPermutations, - isHotkeyCombinationPressed, - normalizeHotkey, -} from './hotkey' -import type { KeyboardKey } from '../context/devtools-store' +import { describe, expect, it, vi } from 'vitest' +import { isHotkeyHeld, resolveHotkeyKeys } from './hotkey' + +// Mock the library's resolveModifier and MODIFIER_ALIASES +vi.mock('@tanstack/solid-hotkeys', () => ({ + MODIFIER_ALIASES: { + Control: 'Control', + Ctrl: 'Control', + control: 'Control', + ctrl: 'Control', + Shift: 'Shift', + shift: 'Shift', + Alt: 'Alt', + Option: 'Alt', + alt: 'Alt', + option: 'Alt', + Command: 'Meta', + Cmd: 'Meta', + Meta: 'Meta', + command: 'Meta', + cmd: 'Meta', + meta: 'Meta', + CommandOrControl: 'Mod', + Mod: 'Mod', + commandorcontrol: 'Mod', + mod: 'Mod', + }, + resolveModifier: (modifier: string) => { + if (modifier === 'Mod') return 'Control' // simulating windows platform + return modifier + }, +})) describe('hotkey utilities', () => { - describe('normalizeHotkey', () => { - it('should return unchanged array when CtrlOrMeta is not present', () => { - const hotkey: Array = ['Shift', 'A'] - const result = normalizeHotkey(hotkey) - expect(result).toEqual([['Shift', 'A']]) + describe('resolveHotkeyKeys', () => { + it('should split a hotkey string into key parts', () => { + expect(resolveHotkeyKeys('Shift+A')).toEqual(['Shift', 'A']) }) - it('should expand CtrlOrMeta to Control and Meta variants', () => { - const hotkey: Array = ['Shift', 'CtrlOrMeta'] - const result = normalizeHotkey(hotkey) - expect(result).toHaveLength(2) - expect(result).toContainEqual(['Shift', 'Control']) - expect(result).toContainEqual(['Shift', 'Meta']) + it('should resolve Mod to Control on windows', () => { + expect(resolveHotkeyKeys('Mod+Shift')).toEqual(['Control', 'Shift']) + }) + + it('should resolve Ctrl alias to Control', () => { + expect(resolveHotkeyKeys('Ctrl+A')).toEqual(['Control', 'A']) }) - }) - describe('getHotkeyPermutations', () => { - it('should generate permutations for modifiers in any order', () => { - const hotkey: Array = ['Shift', 'Control', 'A'] - const result = getHotkeyPermutations(hotkey) - expect(result).toContainEqual(['Shift', 'Control', 'A']) - expect(result).toContainEqual(['Control', 'Shift', 'A']) + it('should handle single key', () => { + expect(resolveHotkeyKeys('Escape')).toEqual(['Escape']) }) - it('should handle CtrlOrMeta expansion with multiple permutations', () => { - const hotkey: Array = ['Shift', 'CtrlOrMeta'] - const result = getHotkeyPermutations(hotkey) - expect(result).toContainEqual(['Shift', 'Control']) - expect(result).toContainEqual(['Control', 'Shift']) - expect(result).toContainEqual(['Shift', 'Meta']) - expect(result).toContainEqual(['Meta', 'Shift']) + it('should handle multiple modifiers with a key', () => { + expect(resolveHotkeyKeys('Control+Shift+A')).toEqual([ + 'Control', + 'Shift', + 'A', + ]) }) - it('should handle single key hotkey with no modifiers', () => { - const hotkey: Array = ['A'] - const result = getHotkeyPermutations(hotkey) - expect(result).toEqual([['A']]) + it('should return empty array for undefined or null', () => { + expect(resolveHotkeyKeys(undefined)).toEqual([]) + expect(resolveHotkeyKeys(null)).toEqual([]) }) - it('should not have duplicate permutations', () => { - const hotkey: Array = ['Shift', 'Alt', 'A'] - const result = getHotkeyPermutations(hotkey) - const stringified = result.map((combo) => JSON.stringify(combo)) - const unique = new Set(stringified) - expect(unique.size).toBe(stringified.length) + it('should return empty array for empty string', () => { + expect(resolveHotkeyKeys('')).toEqual([]) }) }) - describe('isHotkeyCombinationPressed', () => { + describe('isHotkeyHeld', () => { it('should match exact key combination', () => { - expect(isHotkeyCombinationPressed(['Shift', 'A'], ['Shift', 'A'])).toBe( - true, - ) + expect(isHotkeyHeld(['Shift', 'A'], 'Shift+A')).toBe(true) }) it('should be case-insensitive', () => { - expect(isHotkeyCombinationPressed(['shift', 'a'], ['Shift', 'A'])).toBe( + expect(isHotkeyHeld(['shift', 'a'], 'Shift+A')).toBe(true) + }) + + it('should match regardless of order', () => { + expect(isHotkeyHeld(['A', 'Control', 'Shift'], 'Shift+Control+A')).toBe( true, ) }) - it('should match regardless of modifier order', () => { - expect( - isHotkeyCombinationPressed( - ['A', 'Control', 'Shift'], - ['Shift', 'Control', 'A'], - ), - ).toBe(true) + it('should resolve Mod to Control on windows', () => { + expect(isHotkeyHeld(['Shift', 'Control'], 'Mod+Shift')).toBe(true) }) - it('should handle CtrlOrMeta with Control', () => { - expect( - isHotkeyCombinationPressed( - ['Shift', 'Control'], - ['Shift', 'CtrlOrMeta'], - ), - ).toBe(true) + it('should reject incomplete key combinations', () => { + expect(isHotkeyHeld(['Shift'], 'Shift+A')).toBe(false) }) - it('should handle CtrlOrMeta with Meta', () => { - expect( - isHotkeyCombinationPressed(['Shift', 'Meta'], ['Shift', 'CtrlOrMeta']), - ).toBe(true) + it('should reject extra keys', () => { + expect(isHotkeyHeld(['Shift', 'A', 'B'], 'Shift+A')).toBe(false) }) - it('should reject incomplete key combinations', () => { - expect(isHotkeyCombinationPressed(['Shift'], ['Shift', 'A'])).toBe(false) + it('should handle single key', () => { + expect(isHotkeyHeld(['A'], 'A')).toBe(true) }) - it('should reject extra keys', () => { - expect( - isHotkeyCombinationPressed(['Shift', 'A', 'B'], ['Shift', 'A']), - ).toBe(false) + it('should return false for undefined or null hotkey', () => { + expect(isHotkeyHeld(['Shift'], undefined)).toBe(false) + expect(isHotkeyHeld(['Shift'], null)).toBe(false) }) - it('should handle single key hotkey', () => { - expect(isHotkeyCombinationPressed(['A'], ['A'])).toBe(true) + it('should return false for empty string hotkey', () => { + expect(isHotkeyHeld(['Shift'], '')).toBe(false) }) }) }) diff --git a/packages/devtools/src/utils/hotkey.ts b/packages/devtools/src/utils/hotkey.ts index fe46db8b..b0e72fdd 100644 --- a/packages/devtools/src/utils/hotkey.ts +++ b/packages/devtools/src/utils/hotkey.ts @@ -1,66 +1,38 @@ -import { keyboardModifiers } from '../context/devtools-store' -import { getAllPermutations } from './sanitize' - -import type { KeyboardKey, ModifierKey } from '../context/devtools-store' - -/** Expands CtrlOrMeta into separate Control and Meta variants */ -export const normalizeHotkey = ( - keys: Array, -): Array> => { - // no normalization needed if CtrlOrMeta not used - if (!keys.includes('CtrlOrMeta')) { - return [keys] - } - - return [ - keys.map((key) => (key === 'CtrlOrMeta' ? 'Control' : key)), - keys.map((key) => (key === 'CtrlOrMeta' ? 'Meta' : key)), - ] -} +import { MODIFIER_ALIASES, resolveModifier } from '@tanstack/solid-hotkeys' + /** - * Generates all keyboard permutations for a given hotkey configuration - * Handles CtrlOrMeta expansion and creates all possible combinations + * Resolves a hotkey string into an array of canonical key names. + * Uses the library's MODIFIER_ALIASES and resolveModifier to handle + * aliases like "Mod", "Ctrl", "Cmd", etc. */ -export const getHotkeyPermutations = ( - hotkey: Array, -): Array> => { - const normalizedHotkeys = normalizeHotkey(hotkey) - - return normalizedHotkeys.flatMap((normalizedHotkey) => { - const modifiers = normalizedHotkey.filter((key) => - keyboardModifiers.includes(key as any), - ) as Array - - const nonModifiers = normalizedHotkey.filter( - (key) => !keyboardModifiers.includes(key as any), - ) - - // handle case with no modifiers (just non-modifier keys) - if (modifiers.length === 0) { - return [nonModifiers] +export const resolveHotkeyKeys = (hotkey: string | null | undefined): Array => { + if (typeof hotkey !== 'string' || !hotkey) return [] + return hotkey.split('+').map((part) => { + const trimmed = part.trim() + const alias = MODIFIER_ALIASES[trimmed] + if (alias) { + return resolveModifier(alias ) } - - const allModifierCombinations = getAllPermutations(modifiers) - return allModifierCombinations.map((combo) => [...combo, ...nonModifiers]) + return trimmed }) } -/** Checks if the currently pressed keys match any of the hotkey permutations */ -export const isHotkeyCombinationPressed = ( - keys: Array, - hotkey: Array, +/** + * Checks if the currently held keys exactly match the resolved hotkey keys. + * This is an exact match - no extra or missing keys allowed. + */ +export const isHotkeyHeld = ( + heldKeys: Array, + hotkey: string | null | undefined, ): boolean => { - const permutations = getHotkeyPermutations(hotkey) - const pressedKeys = keys.map((key) => key.toUpperCase()) - - return permutations.some( - (combo) => - // every key in the combo must be pressed - combo.every((key) => pressedKeys.includes(String(key).toUpperCase())) && - // and no extra keys beyond the combo - pressedKeys.every((key) => - combo.map((k) => String(k).toUpperCase()).includes(key), - ), + const requiredKeys = resolveHotkeyKeys(hotkey) + const normalize = (k: string) => k.toLowerCase() + const heldNorm = heldKeys.map(normalize) + const requiredNorm = requiredKeys.map(normalize) + + return ( + heldNorm.length === requiredNorm.length && + requiredNorm.every((key) => heldNorm.includes(key)) ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a510c8e8..7cc3411d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -699,9 +699,6 @@ importers: '@solid-primitives/event-listener': specifier: ^2.4.3 version: 2.4.3(solid-js@1.9.10) - '@solid-primitives/keyboard': - specifier: ^1.3.3 - version: 1.3.3(solid-js@1.9.10) '@solid-primitives/resize-observer': specifier: ^2.1.3 version: 2.1.3(solid-js@1.9.10) @@ -714,6 +711,9 @@ importers: '@tanstack/devtools-ui': specifier: workspace:* version: link:../devtools-ui + '@tanstack/solid-hotkeys': + specifier: ^0.3.0 + version: 0.3.0(solid-js@1.9.10) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -3661,6 +3661,10 @@ packages: resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} engines: {node: '>=12'} + '@tanstack/hotkeys@0.3.0': + resolution: {integrity: sha512-y2uawGLj/GrMNDffaaC0YS7tZPwchDbWAKnU/XObjtyQ4oRGVLyg/2PPbT/hWNQ2PbbrolB098NvlYM0OR3XSw==} + engines: {node: '>=18'} + '@tanstack/match-sorter-utils@8.19.4': resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} engines: {node: '>=12'} @@ -3851,6 +3855,12 @@ packages: resolution: {integrity: sha512-a05fzK+jBGacsSAc1vE8an7lpBh4H0PyIEcivtEyHLomgSeElAJxm9E2It/0nYRZ5Lh23m0okbhzJNaYWZpAOg==} engines: {node: '>=12'} + '@tanstack/solid-hotkeys@0.3.0': + resolution: {integrity: sha512-9Mtk/zaAOj/XqOtY5WlpjKNYMOubzhMioXA+fm4PKQZe+QlZod9SuqdisMQebvlpgspUHkj5wPDhIp9AcmsV4g==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.7.0' + '@tanstack/solid-query-devtools@5.91.1': resolution: {integrity: sha512-4OnXd5AwwcdlzFqRstToz0QChmpz9CqA2csnQAdlrA9Vrf0Gjl+S7tPE65xx0s8y8ZwUgCNyB1GTFXS1Wsznyg==} peerDependencies: @@ -3884,6 +3894,11 @@ packages: peerDependencies: solid-js: ^1.6.0 + '@tanstack/solid-store@0.9.1': + resolution: {integrity: sha512-gx7ToM+Yrkui36NIj0HjAufzv1Dg8usjtVFy5H3Ll52Xjuz+eliIJL+ihAr4LRuWh3nDPBR+nCLW0ShFrbE5yw==} + peerDependencies: + solid-js: ^1.6.0 + '@tanstack/start-client-core@1.143.9': resolution: {integrity: sha512-Cvj/LIz6WMLg3XF35w0axz7zuBVgeZFhEHFXfHlsylBbYcBFA3GHQZ5KNuxgHNgwP2jYwF6hM6pRkw+1mA3/uA==} engines: {node: '>=22.12.0'} @@ -3912,6 +3927,9 @@ packages: '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/store@0.9.1': + resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/typedoc-config@0.2.1': resolution: {integrity: sha512-3miLBNiyWX54bQKBNnh7Fj6otWX8ZDiU6/ffOsNnikwBdKjFkA7ddrBtC5/JQkLCE6CBIqcJvtNIwI+DZu4y1Q==} engines: {node: '>=18'} @@ -12515,6 +12533,10 @@ snapshots: '@tanstack/history@1.141.0': {} + '@tanstack/hotkeys@0.3.0': + dependencies: + '@tanstack/store': 0.9.1 + '@tanstack/match-sorter-utils@8.19.4': dependencies: remove-accents: 0.5.0 @@ -12848,6 +12870,12 @@ snapshots: - supports-color - vite + '@tanstack/solid-hotkeys@0.3.0(solid-js@1.9.10)': + dependencies: + '@tanstack/hotkeys': 0.3.0 + '@tanstack/solid-store': 0.9.1(solid-js@1.9.10) + solid-js: 1.9.10 + '@tanstack/solid-query-devtools@5.91.1(@tanstack/solid-query@5.90.14(solid-js@1.9.10))(solid-js@1.9.10)': dependencies: '@tanstack/query-devtools': 5.91.1 @@ -12899,6 +12927,11 @@ snapshots: '@tanstack/store': 0.8.0 solid-js: 1.9.10 + '@tanstack/solid-store@0.9.1(solid-js@1.9.10)': + dependencies: + '@tanstack/store': 0.9.1 + solid-js: 1.9.10 + '@tanstack/start-client-core@1.143.9': dependencies: '@tanstack/router-core': 1.143.6 @@ -12992,6 +13025,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tanstack/store@0.9.1': {} + '@tanstack/typedoc-config@0.2.1(typescript@5.9.3)': dependencies: typedoc: 0.27.9(typescript@5.9.3) From 603175e7b2c036d6e3de531351e438afb933b1a7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:57:58 +0000 Subject: [PATCH 2/5] ci: apply automated fixes --- packages/devtools/src/components/source-inspector.tsx | 3 ++- packages/devtools/src/context/devtools-store.ts | 2 +- packages/devtools/src/devtools.tsx | 6 +----- packages/devtools/src/tabs/hotkey-config.tsx | 3 ++- packages/devtools/src/utils/hotkey.ts | 7 ++++--- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/devtools/src/components/source-inspector.tsx b/packages/devtools/src/components/source-inspector.tsx index 6a46800a..24fa7b23 100644 --- a/packages/devtools/src/components/source-inspector.tsx +++ b/packages/devtools/src/components/source-inspector.tsx @@ -32,7 +32,8 @@ export const SourceInspector = () => { const heldKeys = createHeldKeys() const isHighlightingKeysHeld = createMemo(() => { - const hotkey = settings().inspectHotkey || initialState.settings.inspectHotkey + const hotkey = + settings().inspectHotkey || initialState.settings.inspectHotkey return isHotkeyHeld(heldKeys(), hotkey) }) diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index d8b2c2e6..172f6bdb 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -93,7 +93,7 @@ export const initialState: DevtoolsStore = { hideUntilHover: false, position: 'bottom-right', panelLocation: 'bottom', - openHotkey: 'Control+`' , + openHotkey: 'Control+`', inspectHotkey: 'Mod+Alt+Shift', requireUrlFlag: false, urlFlag: 'tanstack-devtools', diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 27844518..bfd126c6 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -167,11 +167,7 @@ export default function DevTools() { if (!element || !(element instanceof HTMLElement)) return false if (element.isContentEditable) return true const tagName = element.tagName - if ( - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - tagName === 'SELECT' - ) { + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') { return true } return element.getAttribute('role') === 'textbox' diff --git a/packages/devtools/src/tabs/hotkey-config.tsx b/packages/devtools/src/tabs/hotkey-config.tsx index 18308807..5b5931b8 100644 --- a/packages/devtools/src/tabs/hotkey-config.tsx +++ b/packages/devtools/src/tabs/hotkey-config.tsx @@ -25,7 +25,8 @@ const parseHotkeyParts = ( hotkey: string | null | undefined, modifiers: Array, ): { activeModifiers: Array; key: string } => { - if (typeof hotkey !== 'string' || !hotkey) return { activeModifiers: [], key: '' } + if (typeof hotkey !== 'string' || !hotkey) + return { activeModifiers: [], key: '' } const parts = hotkey.split('+').map((p) => p.trim()) const modifierStrings: Array = modifiers const activeModifiers = parts.filter((p): p is Modifier => diff --git a/packages/devtools/src/utils/hotkey.ts b/packages/devtools/src/utils/hotkey.ts index b0e72fdd..8e6011da 100644 --- a/packages/devtools/src/utils/hotkey.ts +++ b/packages/devtools/src/utils/hotkey.ts @@ -1,18 +1,19 @@ import { MODIFIER_ALIASES, resolveModifier } from '@tanstack/solid-hotkeys' - /** * Resolves a hotkey string into an array of canonical key names. * Uses the library's MODIFIER_ALIASES and resolveModifier to handle * aliases like "Mod", "Ctrl", "Cmd", etc. */ -export const resolveHotkeyKeys = (hotkey: string | null | undefined): Array => { +export const resolveHotkeyKeys = ( + hotkey: string | null | undefined, +): Array => { if (typeof hotkey !== 'string' || !hotkey) return [] return hotkey.split('+').map((part) => { const trimmed = part.trim() const alias = MODIFIER_ALIASES[trimmed] if (alias) { - return resolveModifier(alias ) + return resolveModifier(alias) } return trimmed }) From f24684ef20970725111f163f9d02fe747729661d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 4 Mar 2026 09:58:03 +0100 Subject: [PATCH 3/5] chore: changeset --- .changeset/many-squids-watch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/many-squids-watch.md diff --git a/.changeset/many-squids-watch.md b/.changeset/many-squids-watch.md new file mode 100644 index 00000000..38223727 --- /dev/null +++ b/.changeset/many-squids-watch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': minor +--- + +Migrate to TanStack Hotkeys From 4219bc3817f38c6345beadbf38c5dee7d205f47c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 4 Mar 2026 11:19:03 +0100 Subject: [PATCH 4/5] fix: ci --- .gitignore | 3 ++- packages/devtools/package.json | 2 +- packages/devtools/src/utils/sanitize.ts | 23 +---------------------- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index b0fad0c0..837960c7 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ vite.config.ts.timestamp-* .angular .nitro .sonda -*settings.local.json \ No newline at end of file +*settings.local.json +.claude/worktrees/* \ No newline at end of file diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 0579fb0e..1206d322 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -62,9 +62,9 @@ "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "workspace:*", - "@tanstack/solid-hotkeys": "^0.3.0", "@tanstack/devtools-event-bus": "workspace:*", "@tanstack/devtools-ui": "workspace:*", + "@tanstack/solid-hotkeys": "^0.3.0", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" diff --git a/packages/devtools/src/utils/sanitize.ts b/packages/devtools/src/utils/sanitize.ts index 2a1a32ff..159d9ad9 100644 --- a/packages/devtools/src/utils/sanitize.ts +++ b/packages/devtools/src/utils/sanitize.ts @@ -6,25 +6,4 @@ export const tryParseJson = (json: string | null): T | undefined => { return undefined } } - -export const uppercaseFirstLetter = (value: string) => - value.charAt(0).toUpperCase() + value.slice(1) - -export const getAllPermutations = (arr: Array) => { - const res: Array> = [] - - function permutate(arr: Array, start: number) { - if (start === arr.length - 1) { - res.push([...arr] as any) - return - } - for (let i = start; i < arr.length; i++) { - ;[arr[start], arr[i]] = [arr[i]!, arr[start]!] - permutate(arr, start + 1) - ;[arr[start], arr[i]] = [arr[i]!, arr[start]] - } - } - permutate(arr, 0) - - return res -} + \ No newline at end of file From 8dba0eb23e416887e1b889bd7e0906c3aa8cb627 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:19:48 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/devtools/src/utils/sanitize.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/devtools/src/utils/sanitize.ts b/packages/devtools/src/utils/sanitize.ts index 159d9ad9..c7dae657 100644 --- a/packages/devtools/src/utils/sanitize.ts +++ b/packages/devtools/src/utils/sanitize.ts @@ -6,4 +6,3 @@ export const tryParseJson = (json: string | null): T | undefined => { return undefined } } - \ No newline at end of file