@tanstack/hotkeys-devtools 0.4.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { For, Show, createEffect, createMemo, createSignal, on } from 'solid-js'
2
2
  import clsx from 'clsx'
3
- import { formatForDisplay, formatHotkeySequence } from '@tanstack/hotkeys'
3
+ import { formatForDisplay } from '@tanstack/hotkeys'
4
4
  import { useStyles } from '../styles/use-styles'
5
5
  import { useHotkeysDevtoolsState } from '../HotkeysContextProvider'
6
+ import { effectiveSequenceMatchedSteps } from '../sequence-progress'
6
7
  import type {
7
8
  ConflictBehavior,
8
9
  HotkeyRegistration,
@@ -112,6 +113,182 @@ function findSequenceScopeConflicts(
112
113
  )
113
114
  }
114
115
 
116
+ function SequenceListRow(props: {
117
+ reg: SequenceRegistrationView
118
+ getSequences: () => Array<SequenceRegistrationView>
119
+ sequenceProgressNow: () => number
120
+ selectedId: () => string | null
121
+ setSelectedId: (id: string | null) => void
122
+ }) {
123
+ const styles = useStyles()
124
+
125
+ const sequenceRegistrations = () => props.getSequences()
126
+
127
+ const targetConflicts = () =>
128
+ findSequenceTargetConflicts(props.reg, sequenceRegistrations())
129
+ const scopeConflicts = () =>
130
+ findSequenceScopeConflicts(props.reg, sequenceRegistrations())
131
+
132
+ const hasTargetConflict = () => targetConflicts().length > 0
133
+ const hasScopeConflict = () => scopeConflicts().length > 0
134
+
135
+ const conflictBehavior = (): ConflictBehavior =>
136
+ props.reg.options.conflictBehavior ?? 'warn'
137
+
138
+ const targetConflictBadge = () => {
139
+ const behavior = conflictBehavior()
140
+ const c = targetConflicts()
141
+ if (behavior === 'allow') {
142
+ return {
143
+ style: 'badgeAllow' as const,
144
+ label: '~',
145
+ tooltip: `Allowed: ${c.length} other binding${c.length > 1 ? 's' : ''} on same sequence and target (conflictBehavior: allow)`,
146
+ }
147
+ }
148
+ if (behavior === 'error') {
149
+ return {
150
+ style: 'badgeError' as const,
151
+ label: '!',
152
+ tooltip: `Error: ${c.length} conflicting binding${c.length > 1 ? 's' : ''} on same sequence and target (conflictBehavior: error)`,
153
+ }
154
+ }
155
+ return {
156
+ style: 'badgeConflict' as const,
157
+ label: '!',
158
+ tooltip: `Warning: ${c.length} other binding${c.length > 1 ? 's' : ''} on same sequence and target`,
159
+ }
160
+ }
161
+
162
+ const scopeConflictTooltip = () => {
163
+ const c = scopeConflicts()
164
+ return `Info: ${c.length} binding${c.length > 1 ? 's' : ''} with same sequence on different target${c.length > 1 ? 's' : ''}`
165
+ }
166
+
167
+ const enabled = () => props.reg.options.enabled !== false
168
+
169
+ const liveSequenceReg = createMemo(
170
+ () =>
171
+ sequenceRegistrations().find((r) => r.id === props.reg.id) ?? props.reg,
172
+ )
173
+
174
+ const triggerCount = () =>
175
+ sequenceRegistrations().find((r) => r.id === props.reg.id)?.triggerCount ??
176
+ props.reg.triggerCount
177
+
178
+ const matchedSteps = createMemo(() =>
179
+ effectiveSequenceMatchedSteps(
180
+ liveSequenceReg(),
181
+ props.sequenceProgressNow(),
182
+ ),
183
+ )
184
+
185
+ const [prevCount, setPrevCount] = createSignal(props.reg.triggerCount)
186
+ const [pulsing, setPulsing] = createSignal(false)
187
+
188
+ createEffect(
189
+ on(triggerCount, (current) => {
190
+ if (current > prevCount()) {
191
+ setPulsing(true)
192
+ setTimeout(() => setPulsing(false), 600)
193
+ }
194
+ setPrevCount(current)
195
+ }),
196
+ )
197
+
198
+ return (
199
+ <div
200
+ class={clsx(
201
+ styles().hotkeyRow,
202
+ props.selectedId() === props.reg.id && styles().hotkeyRowSelected,
203
+ pulsing() && styles().hotkeyRowTriggered,
204
+ )}
205
+ onClick={() => props.setSelectedId(props.reg.id)}
206
+ >
207
+ <span class={styles().hotkeyLabel}>
208
+ <For each={liveSequenceReg().sequence}>
209
+ {(step, i) => (
210
+ <span>
211
+ <Show when={i() > 0}> </Show>
212
+ <span
213
+ class={
214
+ i() < matchedSteps()
215
+ ? styles().sequenceStepMatched
216
+ : undefined
217
+ }
218
+ >
219
+ {formatForDisplay(step)}
220
+ </span>
221
+ </span>
222
+ )}
223
+ </For>
224
+ </span>
225
+ <Show when={triggerCount() > 0}>
226
+ <span class={styles().triggerCount}>x{triggerCount()}</span>
227
+ </Show>
228
+ <div class={styles().hotkeyBadges}>
229
+ {hasTargetConflict() && (
230
+ <span
231
+ class={clsx(
232
+ styles().badge,
233
+ styles()[targetConflictBadge().style],
234
+ styles().tooltip,
235
+ )}
236
+ >
237
+ {targetConflictBadge().label}
238
+ <span class={styles().tooltipText} data-tooltip>
239
+ {targetConflictBadge().tooltip}
240
+ </span>
241
+ </span>
242
+ )}
243
+ {hasScopeConflict() && (
244
+ <span
245
+ class={clsx(styles().badge, styles().badgeInfo, styles().tooltip)}
246
+ >
247
+ i
248
+ <span class={styles().tooltipText} data-tooltip>
249
+ {scopeConflictTooltip()}
250
+ </span>
251
+ </span>
252
+ )}
253
+ <span
254
+ class={clsx(
255
+ styles().badge,
256
+ enabled() ? styles().badgeEnabled : styles().badgeDisabled,
257
+ styles().tooltip,
258
+ )}
259
+ >
260
+ {enabled() ? 'on' : 'off'}
261
+ <span class={styles().tooltipText} data-tooltip>
262
+ {enabled() ? 'Sequence is enabled' : 'Sequence is disabled'}
263
+ </span>
264
+ </span>
265
+ <span
266
+ class={clsx(
267
+ styles().badge,
268
+ (props.reg.options.eventType ?? 'keydown') === 'keydown'
269
+ ? styles().badgeKeydown
270
+ : styles().badgeKeyup,
271
+ styles().tooltip,
272
+ )}
273
+ >
274
+ {props.reg.options.eventType ?? 'keydown'}
275
+ <span class={styles().tooltipText} data-tooltip>
276
+ Fires on {props.reg.options.eventType ?? 'keydown'} event
277
+ </span>
278
+ </span>
279
+ <span
280
+ class={clsx(styles().badge, styles().badgeTarget, styles().tooltip)}
281
+ >
282
+ {getTargetLabel(props.reg.target)}
283
+ <span class={styles().tooltipText} data-tooltip>
284
+ {getTargetTooltip(props.reg.target)}
285
+ </span>
286
+ </span>
287
+ </div>
288
+ </div>
289
+ )
290
+ }
291
+
115
292
  export function HotkeyList(props: HotkeyListProps) {
116
293
  const styles = useStyles()
117
294
  const state = useHotkeysDevtoolsState()
@@ -284,156 +461,15 @@ export function HotkeyList(props: HotkeyListProps) {
284
461
  </div>
285
462
  <div class={styles().hotkeyList}>
286
463
  <For each={sequenceRegistrations()}>
287
- {(reg) => {
288
- const targetConflicts = () =>
289
- findSequenceTargetConflicts(reg, sequenceRegistrations())
290
- const scopeConflicts = () =>
291
- findSequenceScopeConflicts(reg, sequenceRegistrations())
292
-
293
- const hasTargetConflict = () => targetConflicts().length > 0
294
- const hasScopeConflict = () => scopeConflicts().length > 0
295
-
296
- const conflictBehavior = (): ConflictBehavior =>
297
- reg.options.conflictBehavior ?? 'warn'
298
-
299
- const targetConflictBadge = () => {
300
- const behavior = conflictBehavior()
301
- const c = targetConflicts()
302
- if (behavior === 'allow') {
303
- return {
304
- style: 'badgeAllow' as const,
305
- label: '~',
306
- tooltip: `Allowed: ${c.length} other binding${c.length > 1 ? 's' : ''} on same sequence and target (conflictBehavior: allow)`,
307
- }
308
- }
309
- if (behavior === 'error') {
310
- return {
311
- style: 'badgeError' as const,
312
- label: '!',
313
- tooltip: `Error: ${c.length} conflicting binding${c.length > 1 ? 's' : ''} on same sequence and target (conflictBehavior: error)`,
314
- }
315
- }
316
- return {
317
- style: 'badgeConflict' as const,
318
- label: '!',
319
- tooltip: `Warning: ${c.length} other binding${c.length > 1 ? 's' : ''} on same sequence and target`,
320
- }
321
- }
322
-
323
- const scopeConflictTooltip = () => {
324
- const c = scopeConflicts()
325
- return `Info: ${c.length} binding${c.length > 1 ? 's' : ''} with same sequence on different target${c.length > 1 ? 's' : ''}`
326
- }
327
-
328
- const enabled = () => reg.options.enabled !== false
329
-
330
- const triggerCount = () =>
331
- sequenceRegistrations().find((r) => r.id === reg.id)
332
- ?.triggerCount ?? reg.triggerCount
333
-
334
- const [prevCount, setPrevCount] = createSignal(reg.triggerCount)
335
- const [pulsing, setPulsing] = createSignal(false)
336
-
337
- createEffect(
338
- on(triggerCount, (current) => {
339
- if (current > prevCount()) {
340
- setPulsing(true)
341
- setTimeout(() => setPulsing(false), 600)
342
- }
343
- setPrevCount(current)
344
- }),
345
- )
346
-
347
- return (
348
- <div
349
- class={clsx(
350
- styles().hotkeyRow,
351
- props.selectedId() === reg.id && styles().hotkeyRowSelected,
352
- pulsing() && styles().hotkeyRowTriggered,
353
- )}
354
- onClick={() => props.setSelectedId(reg.id)}
355
- >
356
- <span class={styles().hotkeyLabel}>
357
- {formatHotkeySequence(reg.sequence)}
358
- </span>
359
- <Show when={triggerCount() > 0}>
360
- <span class={styles().triggerCount}>x{triggerCount()}</span>
361
- </Show>
362
- <div class={styles().hotkeyBadges}>
363
- {hasTargetConflict() && (
364
- <span
365
- class={clsx(
366
- styles().badge,
367
- styles()[targetConflictBadge().style],
368
- styles().tooltip,
369
- )}
370
- >
371
- {targetConflictBadge().label}
372
- <span class={styles().tooltipText} data-tooltip>
373
- {targetConflictBadge().tooltip}
374
- </span>
375
- </span>
376
- )}
377
- {hasScopeConflict() && (
378
- <span
379
- class={clsx(
380
- styles().badge,
381
- styles().badgeInfo,
382
- styles().tooltip,
383
- )}
384
- >
385
- i
386
- <span class={styles().tooltipText} data-tooltip>
387
- {scopeConflictTooltip()}
388
- </span>
389
- </span>
390
- )}
391
- <span
392
- class={clsx(
393
- styles().badge,
394
- enabled()
395
- ? styles().badgeEnabled
396
- : styles().badgeDisabled,
397
- styles().tooltip,
398
- )}
399
- >
400
- {enabled() ? 'on' : 'off'}
401
- <span class={styles().tooltipText} data-tooltip>
402
- {enabled()
403
- ? 'Sequence is enabled'
404
- : 'Sequence is disabled'}
405
- </span>
406
- </span>
407
- <span
408
- class={clsx(
409
- styles().badge,
410
- (reg.options.eventType ?? 'keydown') === 'keydown'
411
- ? styles().badgeKeydown
412
- : styles().badgeKeyup,
413
- styles().tooltip,
414
- )}
415
- >
416
- {reg.options.eventType ?? 'keydown'}
417
- <span class={styles().tooltipText} data-tooltip>
418
- Fires on {reg.options.eventType ?? 'keydown'} event
419
- </span>
420
- </span>
421
- <span
422
- class={clsx(
423
- styles().badge,
424
- styles().badgeTarget,
425
- styles().tooltip,
426
- )}
427
- >
428
- {getTargetLabel(reg.target)}
429
- <span class={styles().tooltipText} data-tooltip>
430
- {getTargetTooltip(reg.target)}
431
- </span>
432
- </span>
433
- </div>
434
- </div>
435
- )
436
- }}
464
+ {(reg) => (
465
+ <SequenceListRow
466
+ reg={reg}
467
+ getSequences={sequenceRegistrations}
468
+ sequenceProgressNow={state.sequenceProgressNow}
469
+ selectedId={props.selectedId}
470
+ setSelectedId={props.setSelectedId}
471
+ />
472
+ )}
437
473
  </For>
438
474
  </div>
439
475
  </>
@@ -1,4 +1,10 @@
1
- import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'
1
+ import {
2
+ createEffect,
3
+ createMemo,
4
+ createSignal,
5
+ onCleanup,
6
+ onMount,
7
+ } from 'solid-js'
2
8
  import { Header, HeaderLogo, MainPanel } from '@tanstack/devtools-ui'
3
9
  import { useStyles } from '../styles/use-styles'
4
10
  import { useHotkeysDevtoolsState } from '../HotkeysContextProvider'
@@ -6,12 +12,33 @@ import { HeldKeysBar } from './HeldKeysTopbar'
6
12
  import { HotkeyList } from './HotkeyList'
7
13
  import { DetailsPanel } from './DetailsPanel'
8
14
 
15
+ function readResizeObserverBlockSize(entry: ResizeObserverEntry): number {
16
+ const [box] = entry.borderBoxSize
17
+ if (box !== undefined) return box.blockSize
18
+ return entry.contentRect.height
19
+ }
20
+
21
+ /** TanStack devtools plugin pane; percentage heights break without this. */
22
+ function resolvePluginHost(el: HTMLElement): HTMLElement | null {
23
+ const found = el.closest('[id^="plugin-container-"]')
24
+ if (found instanceof HTMLElement) return found
25
+ let n: HTMLElement | null = el
26
+ for (let i = 0; i < 3 && n; i++) {
27
+ n = n.parentElement
28
+ }
29
+ return n
30
+ }
31
+
9
32
  export function Shell() {
10
33
  const styles = useStyles()
11
34
  const state = useHotkeysDevtoolsState()
12
35
  const [selectedId, setSelectedId] = createSignal<string | null>(null)
13
36
  const [leftPanelWidth, setLeftPanelWidth] = createSignal(300)
14
37
  const [isDragging, setIsDragging] = createSignal(false)
38
+ const [shellRootEl, setShellRootEl] = createSignal<HTMLElement | null>(null)
39
+ const [slotHeightPx, setSlotHeightPx] = createSignal<number | undefined>(
40
+ undefined,
41
+ )
15
42
 
16
43
  const selectedRegistration = createMemo(() => {
17
44
  const id = selectedId()
@@ -60,45 +87,85 @@ export function Shell() {
60
87
  document.removeEventListener('mouseup', handleMouseUp)
61
88
  })
62
89
 
90
+ createEffect(() => {
91
+ const el = shellRootEl()
92
+ if (!el || typeof ResizeObserver === 'undefined') return
93
+ const pluginHost = resolvePluginHost(el)
94
+ if (!pluginHost) return
95
+
96
+ const applyHeight = (entry?: ResizeObserverEntry) => {
97
+ const clientH = pluginHost.clientHeight
98
+ const fromObserver =
99
+ entry !== undefined ? readResizeObserverBlockSize(entry) : 0
100
+ const h = clientH > 0 ? clientH : fromObserver
101
+ if (h > 0) setSlotHeightPx(Math.round(h))
102
+ }
103
+
104
+ applyHeight()
105
+ const ro = new ResizeObserver((entries) => {
106
+ applyHeight(entries[0])
107
+ })
108
+ ro.observe(pluginHost)
109
+ onCleanup(() => ro.disconnect())
110
+ })
111
+
63
112
  return (
64
- <MainPanel>
65
- <Header>
66
- <HeaderLogo
67
- flavor={{
68
- light: 'oklch(51.4% 0.222 16.935)',
69
- dark: 'oklch(58.6% 0.253 17.585)',
70
- }}
71
- >
72
- TanStack Hotkeys
73
- </HeaderLogo>
74
- </Header>
75
-
76
- <div class={styles().mainContainer}>
77
- <HeldKeysBar />
78
-
79
- <div class={styles().panelsContainer}>
80
- <div
81
- class={styles().leftPanel}
82
- style={{
83
- width: `${leftPanelWidth()}px`,
84
- 'min-width': '150px',
85
- 'max-width': '800px',
113
+ <div
114
+ ref={(el) => setShellRootEl(el)}
115
+ class={styles().shellRoot}
116
+ style={{
117
+ ...(slotHeightPx() !== undefined
118
+ ? {
119
+ height: `${slotHeightPx()}px`,
120
+ 'max-height': `${slotHeightPx()}px`,
121
+ }
122
+ : {}),
123
+ }}
124
+ >
125
+ <MainPanel class={styles().mainPanelShell}>
126
+ <Header>
127
+ <HeaderLogo
128
+ flavor={{
129
+ light: 'oklch(51.4% 0.222 16.935)',
130
+ dark: 'oklch(58.6% 0.253 17.585)',
86
131
  }}
87
132
  >
88
- <HotkeyList selectedId={selectedId} setSelectedId={setSelectedId} />
89
- </div>
133
+ TanStack Hotkeys
134
+ </HeaderLogo>
135
+ </Header>
136
+
137
+ <div class={styles().mainContainer}>
138
+ <HeldKeysBar />
139
+
140
+ <div class={styles().panelsContainer}>
141
+ <div
142
+ class={styles().leftPanel}
143
+ style={{
144
+ width: `${leftPanelWidth()}px`,
145
+ 'min-width': '150px',
146
+ 'max-width': '800px',
147
+ }}
148
+ >
149
+ <div class={styles().leftPanelScroll}>
150
+ <HotkeyList
151
+ selectedId={selectedId}
152
+ setSelectedId={setSelectedId}
153
+ />
154
+ </div>
155
+ </div>
90
156
 
91
- <div
92
- class={`${styles().dragHandle} ${isDragging() ? 'dragging' : ''}`}
93
- onMouseDown={handleMouseDown}
94
- />
157
+ <div
158
+ class={`${styles().dragHandle} ${isDragging() ? 'dragging' : ''}`}
159
+ onMouseDown={handleMouseDown}
160
+ />
95
161
 
96
- <div class={styles().rightPanel} style={{ flex: 1 }}>
97
- <div class={styles().panelHeader}>Details</div>
98
- <DetailsPanel selectedRegistration={selectedRegistration} />
162
+ <div class={styles().rightPanel} style={{ flex: 1 }}>
163
+ <div class={styles().panelHeader}>Details</div>
164
+ <DetailsPanel selectedRegistration={selectedRegistration} />
165
+ </div>
99
166
  </div>
100
167
  </div>
101
- </div>
102
- </MainPanel>
168
+ </MainPanel>
169
+ </div>
103
170
  )
104
171
  }
@@ -0,0 +1,29 @@
1
+ import { DEFAULT_SEQUENCE_TIMEOUT } from '@tanstack/hotkeys'
2
+ import type { SequenceRegistrationView } from '@tanstack/hotkeys'
3
+
4
+ /**
5
+ * Matched step count for UI, accounting for inter-key timeout even if the
6
+ * manager has not processed another event yet.
7
+ */
8
+ export function effectiveSequenceMatchedSteps(
9
+ reg: SequenceRegistrationView,
10
+ now: number,
11
+ ): number {
12
+ const timeout = reg.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
13
+ if (
14
+ reg.matchedStepCount <= 0 ||
15
+ reg.partialMatchLastKeyTime <= 0 ||
16
+ now - reg.partialMatchLastKeyTime > timeout
17
+ ) {
18
+ return 0
19
+ }
20
+ return reg.matchedStepCount
21
+ }
22
+
23
+ /** Whether the devtools should run a short interval to refresh sequence progress UI. */
24
+ export function needsSequenceProgressClock(
25
+ regs: Array<SequenceRegistrationView>,
26
+ now: number,
27
+ ): boolean {
28
+ return regs.some((reg) => effectiveSequenceMatchedSteps(reg, now) > 0)
29
+ }