@proteos/ui 0.31.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.
- package/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { isEditableTarget, matches } from './match-event'
|
|
4
|
+
import { parseCombo } from './parse-combo'
|
|
5
|
+
import { _setPlatformForTests } from './platform'
|
|
6
|
+
|
|
7
|
+
function event(init: KeyboardEventInit): KeyboardEvent {
|
|
8
|
+
return new KeyboardEvent('keydown', init)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('matches', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
_setPlatformForTests('mac')
|
|
14
|
+
})
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
_setPlatformForTests(undefined)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('fires mod+s for Cmd+S on macOS', () => {
|
|
20
|
+
expect(matches(event({ metaKey: true, code: 'KeyS', key: 's' }), parseCombo('mod+s'))).toBe(
|
|
21
|
+
true,
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('fires mod+s when Shift is also held (Cmd+Shift+S)', () => {
|
|
26
|
+
expect(
|
|
27
|
+
matches(
|
|
28
|
+
event({ metaKey: true, shiftKey: true, code: 'KeyS', key: 'S' }),
|
|
29
|
+
parseCombo('mod+s'),
|
|
30
|
+
),
|
|
31
|
+
).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('fires mod+s when Alt is also held (Cmd+Alt+S)', () => {
|
|
35
|
+
expect(
|
|
36
|
+
matches(
|
|
37
|
+
event({ metaKey: true, altKey: true, code: 'KeyS', key: 's' }),
|
|
38
|
+
parseCombo('mod+s'),
|
|
39
|
+
),
|
|
40
|
+
).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not fire for Shift+S alone', () => {
|
|
44
|
+
expect(
|
|
45
|
+
matches(event({ shiftKey: true, code: 'KeyS', key: 'S' }), parseCombo('mod+s')),
|
|
46
|
+
).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not fire for the wrong key', () => {
|
|
50
|
+
expect(matches(event({ metaKey: true, code: 'KeyD', key: 'd' }), parseCombo('mod+s'))).toBe(
|
|
51
|
+
false,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('uses ctrlKey on non-mac platforms', () => {
|
|
56
|
+
_setPlatformForTests('other')
|
|
57
|
+
expect(matches(event({ ctrlKey: true, code: 'KeyS', key: 's' }), parseCombo('mod+s'))).toBe(
|
|
58
|
+
true,
|
|
59
|
+
)
|
|
60
|
+
expect(matches(event({ metaKey: true, code: 'KeyS', key: 's' }), parseCombo('mod+s'))).toBe(
|
|
61
|
+
false,
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('matches named keys via event.key', () => {
|
|
66
|
+
expect(
|
|
67
|
+
matches(event({ metaKey: true, code: 'Enter', key: 'Enter' }), parseCombo('mod+enter')),
|
|
68
|
+
).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('matches digit keys via event.key', () => {
|
|
72
|
+
expect(matches(event({ metaKey: true, code: 'Digit0', key: '0' }), parseCombo('mod+0'))).toBe(
|
|
73
|
+
true,
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('isEditableTarget', () => {
|
|
79
|
+
it('treats input/textarea/select as editable', () => {
|
|
80
|
+
expect(isEditableTarget(document.createElement('input'))).toBe(true)
|
|
81
|
+
expect(isEditableTarget(document.createElement('textarea'))).toBe(true)
|
|
82
|
+
expect(isEditableTarget(document.createElement('select'))).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('treats contenteditable as editable', () => {
|
|
86
|
+
const div = document.createElement('div')
|
|
87
|
+
div.setAttribute('contenteditable', 'true')
|
|
88
|
+
expect(isEditableTarget(div)).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('treats buttons and plain divs as not editable', () => {
|
|
92
|
+
expect(isEditableTarget(document.createElement('button'))).toBe(false)
|
|
93
|
+
expect(isEditableTarget(document.createElement('div'))).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns false for null', () => {
|
|
97
|
+
expect(isEditableTarget(null)).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Event matcher + editable-target guard. Letter combos match the physical
|
|
2
|
+
key via `event.code` so QWERTY position is preserved across layouts;
|
|
3
|
+
non-letter combos match `event.key`. Shift and Alt state are intentionally
|
|
4
|
+
ignored so `Cmd+S`, `Cmd+Shift+S`, and `Cmd+Alt+S` all fire `mod+s` —
|
|
5
|
+
this is what makes the discovery-hold-then-press flow work without dual
|
|
6
|
+
registration. */
|
|
7
|
+
|
|
8
|
+
import type { KeyCombo } from './parse-combo'
|
|
9
|
+
import { getPlatform } from './platform'
|
|
10
|
+
|
|
11
|
+
export function isEditableTarget(target: EventTarget | null): boolean {
|
|
12
|
+
if (!(target instanceof HTMLElement)) return false
|
|
13
|
+
const tag = target.tagName
|
|
14
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
15
|
+
if (target.isContentEditable) return true
|
|
16
|
+
// jsdom doesn't always reflect contenteditable in isContentEditable; fall back
|
|
17
|
+
// to the attribute so tests (and any quirky host) behave consistently.
|
|
18
|
+
const attr = target.getAttribute('contenteditable')
|
|
19
|
+
if (attr === '' || attr === 'true' || attr === 'plaintext-only') return true
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function matches(event: KeyboardEvent, combo: KeyCombo): boolean {
|
|
24
|
+
const platform = getPlatform()
|
|
25
|
+
const modPressed = platform === 'mac' ? event.metaKey : event.ctrlKey
|
|
26
|
+
if (!modPressed) return false
|
|
27
|
+
if (combo.isLetter) {
|
|
28
|
+
return event.code === `Key${combo.key.toUpperCase()}`
|
|
29
|
+
}
|
|
30
|
+
return event.key.toLowerCase() === combo.key.toLowerCase()
|
|
31
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { parseCombo } from './parse-combo'
|
|
4
|
+
|
|
5
|
+
describe('parseCombo', () => {
|
|
6
|
+
it('parses mod+<letter>', () => {
|
|
7
|
+
const combo = parseCombo('mod+k')
|
|
8
|
+
expect(combo).toEqual({ raw: 'mod+k', key: 'k', isLetter: true })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('parses mod+<named-key>', () => {
|
|
12
|
+
const combo = parseCombo('mod+enter')
|
|
13
|
+
expect(combo).toEqual({ raw: 'mod+enter', key: 'enter', isLetter: false })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('accepts "cmd" and "meta" as aliases for mod', () => {
|
|
17
|
+
expect(parseCombo('cmd+k').raw).toBe('mod+k')
|
|
18
|
+
expect(parseCombo('meta+k').raw).toBe('mod+k')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is case-insensitive', () => {
|
|
22
|
+
expect(parseCombo('MOD+K').raw).toBe('mod+k')
|
|
23
|
+
expect(parseCombo('Cmd+K').raw).toBe('mod+k')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('trims whitespace around parts', () => {
|
|
27
|
+
expect(parseCombo(' mod + k ').raw).toBe('mod+k')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('rejects empty input', () => {
|
|
31
|
+
expect(() => parseCombo('')).toThrow(/empty/i)
|
|
32
|
+
expect(() => parseCombo(' ')).toThrow(/empty/i)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejects bare keys', () => {
|
|
36
|
+
expect(() => parseCombo('s')).toThrow(/Expected "mod\+<key>"/)
|
|
37
|
+
expect(() => parseCombo('Escape')).toThrow(/Expected "mod\+<key>"/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('rejects non-mod modifiers', () => {
|
|
41
|
+
expect(() => parseCombo('alt+s')).toThrow(/Modifier must be "mod"/)
|
|
42
|
+
expect(() => parseCombo('shift+s')).toThrow(/Modifier must be "mod"/)
|
|
43
|
+
expect(() => parseCombo('ctrl+s')).toThrow(/Modifier must be "mod"/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('rejects multi-modifier combos', () => {
|
|
47
|
+
expect(() => parseCombo('mod+shift+k')).toThrow(/Expected "mod\+<key>"/)
|
|
48
|
+
expect(() => parseCombo('mod+alt+k')).toThrow(/Expected "mod\+<key>"/)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rejects combos that look like extra modifiers as the key', () => {
|
|
52
|
+
expect(() => parseCombo('mod+shift')).toThrow(/Multi-modifier/)
|
|
53
|
+
expect(() => parseCombo('mod+alt')).toThrow(/Multi-modifier/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('rejects missing key', () => {
|
|
57
|
+
expect(() => parseCombo('mod+')).toThrow(/Missing key/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('marks single-letter keys with isLetter', () => {
|
|
61
|
+
expect(parseCombo('mod+a').isLetter).toBe(true)
|
|
62
|
+
expect(parseCombo('mod+enter').isLetter).toBe(false)
|
|
63
|
+
expect(parseCombo('mod+1').isLetter).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Parse a textual combo like "mod+k" into a typed KeyCombo. v1 supports
|
|
2
|
+
exactly `mod+<key>` where the modifier resolves to Cmd on macOS and Ctrl
|
|
3
|
+
elsewhere. Aliases `cmd` and `meta` normalize to `mod`. Any other shape
|
|
4
|
+
(`alt+s`, `mod+shift+k`, bare `s`) throws with a clear message so misuse
|
|
5
|
+
is caught at registration time, not at the next key press. */
|
|
6
|
+
|
|
7
|
+
export type KeyCombo = {
|
|
8
|
+
/** Normalized form for registry keying. Always `mod+<key>`. */
|
|
9
|
+
raw: string
|
|
10
|
+
/** Key part, lowercased. Letters use `event.code` (`KeyS`); others match `event.key`. */
|
|
11
|
+
key: string
|
|
12
|
+
/** True when `key` is a single A–Z letter — match against `event.code` so the
|
|
13
|
+
* binding stays on the physical key regardless of keyboard layout. */
|
|
14
|
+
isLetter: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MOD_ALIASES: ReadonlySet<string> = new Set(['mod', 'cmd', 'meta'])
|
|
18
|
+
const FORBIDDEN_EXTRA_MODS: ReadonlySet<string> = new Set(['shift', 'alt', 'option'])
|
|
19
|
+
|
|
20
|
+
export function parseCombo(input: string): KeyCombo {
|
|
21
|
+
const trimmed = input.trim()
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
throw new Error('Invalid key combo: empty string. Expected "mod+<key>".')
|
|
24
|
+
}
|
|
25
|
+
const parts = trimmed.toLowerCase().split('+').map((p) => p.trim())
|
|
26
|
+
if (parts.length !== 2) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Invalid key combo: "${input}". Expected "mod+<key>" — exactly one modifier and one key. ` +
|
|
29
|
+
`v1 does not support bare keys, multiple modifiers, or sequences.`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const [modPart, keyPart] = parts as [string, string]
|
|
33
|
+
if (!MOD_ALIASES.has(modPart)) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Invalid key combo: "${input}". Modifier must be "mod" (or alias "cmd"/"meta"); got "${modPart}".`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
if (!keyPart) {
|
|
39
|
+
throw new Error(`Invalid key combo: "${input}". Missing key after modifier.`)
|
|
40
|
+
}
|
|
41
|
+
if (FORBIDDEN_EXTRA_MODS.has(keyPart)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Invalid key combo: "${input}". Multi-modifier combos (mod+shift+…, mod+alt+…) are not supported in v1.`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
const isLetter = /^[a-z]$/.test(keyPart)
|
|
47
|
+
return { raw: `mod+${keyPart}`, key: keyPart, isLetter }
|
|
48
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Platform detection for the command system. Resolves once at module load
|
|
2
|
+
so `mod` always means the same thing in a given session. */
|
|
3
|
+
|
|
4
|
+
export type Platform = 'mac' | 'other'
|
|
5
|
+
|
|
6
|
+
let cached: Platform | undefined
|
|
7
|
+
|
|
8
|
+
export function getPlatform(): Platform {
|
|
9
|
+
if (cached !== undefined) return cached
|
|
10
|
+
cached = detect()
|
|
11
|
+
return cached
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function detect(): Platform {
|
|
15
|
+
if (typeof navigator === 'undefined') return 'other'
|
|
16
|
+
const ua = (navigator as { userAgentData?: { platform?: string } }).userAgentData
|
|
17
|
+
if (ua?.platform) {
|
|
18
|
+
return /mac/i.test(ua.platform) ? 'mac' : 'other'
|
|
19
|
+
}
|
|
20
|
+
const legacy = (navigator as { platform?: string }).platform ?? ''
|
|
21
|
+
return /mac/i.test(legacy) ? 'mac' : 'other'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Test-only: reset or force the cached platform.
|
|
25
|
+
export function _setPlatformForTests(p: Platform | undefined): void {
|
|
26
|
+
cached = p
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { parseCombo } from './parse-combo'
|
|
4
|
+
import { assertNotProtected, PROTECTED_COMBOS } from './protected-combos'
|
|
5
|
+
|
|
6
|
+
describe('protected combos', () => {
|
|
7
|
+
it('each protected combo throws on registration', () => {
|
|
8
|
+
for (const raw of PROTECTED_COMBOS) {
|
|
9
|
+
const combo = parseCombo(raw)
|
|
10
|
+
expect(() => assertNotProtected(combo)).toThrow(/protected/i)
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('does not throw for non-protected combos', () => {
|
|
15
|
+
const combo = parseCombo('mod+k')
|
|
16
|
+
expect(() => assertNotProtected(combo)).not.toThrow()
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* Browser/OS combos we refuse to bind. Catching this at registration time
|
|
2
|
+
gives a clear error instead of silently shipping a shortcut the browser
|
|
3
|
+
intercepts. `mod+shift+t` is listed for documentation; it can't be expressed
|
|
4
|
+
in v1 anyway (the parser rejects multi-modifier combos). */
|
|
5
|
+
|
|
6
|
+
import type { KeyCombo } from './parse-combo'
|
|
7
|
+
|
|
8
|
+
/* `mod+n` is intentionally NOT in this list. Chrome/Safari/Firefox on macOS
|
|
9
|
+
intercept it at the OS/browser level for "new window" so the page's
|
|
10
|
+
handler can't fire in those environments — but we still allow registration
|
|
11
|
+
so it works on platforms/configurations where the browser does deliver the
|
|
12
|
+
event, and so the discovery hint can advertise it. */
|
|
13
|
+
|
|
14
|
+
export const PROTECTED_COMBOS: ReadonlySet<string> = new Set([
|
|
15
|
+
'mod+w',
|
|
16
|
+
'mod+t',
|
|
17
|
+
'mod+q',
|
|
18
|
+
'mod+r',
|
|
19
|
+
'mod+l',
|
|
20
|
+
'mod+1',
|
|
21
|
+
'mod+2',
|
|
22
|
+
'mod+3',
|
|
23
|
+
'mod+4',
|
|
24
|
+
'mod+5',
|
|
25
|
+
'mod+6',
|
|
26
|
+
'mod+7',
|
|
27
|
+
'mod+8',
|
|
28
|
+
'mod+9',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
export function assertNotProtected(combo: KeyCombo): void {
|
|
32
|
+
if (PROTECTED_COMBOS.has(combo.raw)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Cannot bind "${combo.raw}" — it is a protected browser/OS shortcut. ` +
|
|
35
|
+
`Pick a different key.`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { parseCombo } from './parse-combo'
|
|
4
|
+
import { _setPlatformForTests } from './platform'
|
|
5
|
+
import { _resetRegistry, register } from './registry'
|
|
6
|
+
|
|
7
|
+
function dispatchKey(init: KeyboardEventInit & { cancelable?: boolean }): KeyboardEvent {
|
|
8
|
+
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
|
|
9
|
+
window.dispatchEvent(ev)
|
|
10
|
+
return ev
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('registry', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
_setPlatformForTests('mac')
|
|
16
|
+
_resetRegistry()
|
|
17
|
+
})
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
_setPlatformForTests(undefined)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('fires the registered handler on matching keydown', () => {
|
|
23
|
+
const fn = vi.fn()
|
|
24
|
+
register(parseCombo('mod+k'), fn)
|
|
25
|
+
dispatchKey({ metaKey: true, code: 'KeyK', key: 'k' })
|
|
26
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('newest-mounted handler wins for the same combo', () => {
|
|
30
|
+
const first = vi.fn()
|
|
31
|
+
const second = vi.fn()
|
|
32
|
+
register(parseCombo('mod+k'), first)
|
|
33
|
+
register(parseCombo('mod+k'), second)
|
|
34
|
+
dispatchKey({ metaKey: true, code: 'KeyK', key: 'k' })
|
|
35
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(first).not.toHaveBeenCalled()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('falls back to the previous handler after the newest unmounts', () => {
|
|
40
|
+
const first = vi.fn()
|
|
41
|
+
const second = vi.fn()
|
|
42
|
+
register(parseCombo('mod+k'), first)
|
|
43
|
+
const unregisterSecond = register(parseCombo('mod+k'), second)
|
|
44
|
+
unregisterSecond()
|
|
45
|
+
dispatchKey({ metaKey: true, code: 'KeyK', key: 'k' })
|
|
46
|
+
expect(first).toHaveBeenCalledTimes(1)
|
|
47
|
+
expect(second).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('does not fire after unregister', () => {
|
|
51
|
+
const fn = vi.fn()
|
|
52
|
+
const unregister = register(parseCombo('mod+k'), fn)
|
|
53
|
+
unregister()
|
|
54
|
+
dispatchKey({ metaKey: true, code: 'KeyK', key: 'k' })
|
|
55
|
+
expect(fn).not.toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('fires even when the focus is in a text input (mod+ combos use a modifier, so they cannot be confused with typing)', () => {
|
|
59
|
+
const fn = vi.fn()
|
|
60
|
+
register(parseCombo('mod+k'), fn)
|
|
61
|
+
const input = document.createElement('input')
|
|
62
|
+
document.body.appendChild(input)
|
|
63
|
+
const ev = new KeyboardEvent('keydown', {
|
|
64
|
+
bubbles: true,
|
|
65
|
+
cancelable: true,
|
|
66
|
+
metaKey: true,
|
|
67
|
+
code: 'KeyK',
|
|
68
|
+
key: 'k',
|
|
69
|
+
})
|
|
70
|
+
input.dispatchEvent(ev)
|
|
71
|
+
document.body.removeChild(input)
|
|
72
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('calls preventDefault and stopImmediatePropagation on match', () => {
|
|
76
|
+
register(parseCombo('mod+k'), () => {})
|
|
77
|
+
const ev = new KeyboardEvent('keydown', {
|
|
78
|
+
bubbles: true,
|
|
79
|
+
cancelable: true,
|
|
80
|
+
metaKey: true,
|
|
81
|
+
code: 'KeyK',
|
|
82
|
+
key: 'k',
|
|
83
|
+
})
|
|
84
|
+
const preventSpy = vi.spyOn(ev, 'preventDefault')
|
|
85
|
+
const stopSpy = vi.spyOn(ev, 'stopImmediatePropagation')
|
|
86
|
+
window.dispatchEvent(ev)
|
|
87
|
+
expect(preventSpy).toHaveBeenCalled()
|
|
88
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not fire for unrelated keys', () => {
|
|
92
|
+
const fn = vi.fn()
|
|
93
|
+
register(parseCombo('mod+k'), fn)
|
|
94
|
+
dispatchKey({ metaKey: true, code: 'KeyJ', key: 'j' })
|
|
95
|
+
expect(fn).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* Module-singleton registry. One window listener handles every command.
|
|
2
|
+
Handlers for the same combo stack on push; the newest wins
|
|
3
|
+
deterministically. preventDefault + stopImmediatePropagation on match so
|
|
4
|
+
the page's own keydown listeners (and other registry entries) don't also
|
|
5
|
+
fire.
|
|
6
|
+
|
|
7
|
+
Editable targets are NOT skipped here — `mod+<key>` combos use a real
|
|
8
|
+
modifier, so they can't be confused with text input the way bare keys
|
|
9
|
+
can. Cmd+S in a form field should still save. (The `useEscapeKey` path
|
|
10
|
+
keeps its editable-target skip because Escape is bare.) */
|
|
11
|
+
|
|
12
|
+
import { matches } from './match-event'
|
|
13
|
+
import type { KeyCombo } from './parse-combo'
|
|
14
|
+
|
|
15
|
+
type Handler = (event: KeyboardEvent) => void
|
|
16
|
+
type Entry = { combo: KeyCombo; handler: Handler }
|
|
17
|
+
|
|
18
|
+
const stacks = new Map<string, Entry[]>()
|
|
19
|
+
let listenerInstalled = false
|
|
20
|
+
|
|
21
|
+
function onWindowKeyDown(event: KeyboardEvent): void {
|
|
22
|
+
for (const entries of stacks.values()) {
|
|
23
|
+
const top = entries[entries.length - 1]
|
|
24
|
+
if (!top) continue
|
|
25
|
+
if (matches(event, top.combo)) {
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
event.stopImmediatePropagation()
|
|
28
|
+
top.handler(event)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ensureListener(): void {
|
|
35
|
+
if (listenerInstalled) return
|
|
36
|
+
if (typeof window === 'undefined') return
|
|
37
|
+
window.addEventListener('keydown', onWindowKeyDown, { capture: true })
|
|
38
|
+
listenerInstalled = true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function register(combo: KeyCombo, handler: Handler): () => void {
|
|
42
|
+
ensureListener()
|
|
43
|
+
const list = stacks.get(combo.raw) ?? []
|
|
44
|
+
const entry: Entry = { combo, handler }
|
|
45
|
+
list.push(entry)
|
|
46
|
+
stacks.set(combo.raw, list)
|
|
47
|
+
return () => {
|
|
48
|
+
const current = stacks.get(combo.raw)
|
|
49
|
+
if (!current) return
|
|
50
|
+
const idx = current.lastIndexOf(entry)
|
|
51
|
+
if (idx >= 0) current.splice(idx, 1)
|
|
52
|
+
if (current.length === 0) stacks.delete(combo.raw)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Test-only: clear the registry between cases.
|
|
57
|
+
export function _resetRegistry(): void {
|
|
58
|
+
stacks.clear()
|
|
59
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { _setPlatformForTests } from './platform'
|
|
5
|
+
import { _resetRegistry } from './registry'
|
|
6
|
+
import { useCommand } from './use-command'
|
|
7
|
+
|
|
8
|
+
function Bind({
|
|
9
|
+
combo,
|
|
10
|
+
handler,
|
|
11
|
+
enabled,
|
|
12
|
+
}: {
|
|
13
|
+
combo: string
|
|
14
|
+
handler: (e: KeyboardEvent) => void
|
|
15
|
+
enabled?: boolean
|
|
16
|
+
}) {
|
|
17
|
+
useCommand(combo, handler, enabled !== undefined ? { enabled } : undefined)
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dispatchModK() {
|
|
22
|
+
window.dispatchEvent(
|
|
23
|
+
new KeyboardEvent('keydown', {
|
|
24
|
+
bubbles: true,
|
|
25
|
+
cancelable: true,
|
|
26
|
+
metaKey: true,
|
|
27
|
+
code: 'KeyK',
|
|
28
|
+
key: 'k',
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('useCommand', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
_setPlatformForTests('mac')
|
|
36
|
+
_resetRegistry()
|
|
37
|
+
})
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
_setPlatformForTests(undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('fires the handler when the combo is pressed', () => {
|
|
43
|
+
const fn = vi.fn()
|
|
44
|
+
render(<Bind combo="mod+k" handler={fn} />)
|
|
45
|
+
dispatchModK()
|
|
46
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('respects enabled=false', () => {
|
|
50
|
+
const fn = vi.fn()
|
|
51
|
+
render(<Bind combo="mod+k" handler={fn} enabled={false} />)
|
|
52
|
+
dispatchModK()
|
|
53
|
+
expect(fn).not.toHaveBeenCalled()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('cleans up on unmount', () => {
|
|
57
|
+
const fn = vi.fn()
|
|
58
|
+
const { unmount } = render(<Bind combo="mod+k" handler={fn} />)
|
|
59
|
+
unmount()
|
|
60
|
+
dispatchModK()
|
|
61
|
+
expect(fn).not.toHaveBeenCalled()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('fires from inside a text input (Cmd+S while typing should still save)', () => {
|
|
65
|
+
const fn = vi.fn()
|
|
66
|
+
render(<Bind combo="mod+k" handler={fn} />)
|
|
67
|
+
const input = document.createElement('input')
|
|
68
|
+
document.body.appendChild(input)
|
|
69
|
+
input.dispatchEvent(
|
|
70
|
+
new KeyboardEvent('keydown', {
|
|
71
|
+
bubbles: true,
|
|
72
|
+
cancelable: true,
|
|
73
|
+
metaKey: true,
|
|
74
|
+
code: 'KeyK',
|
|
75
|
+
key: 'k',
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
document.body.removeChild(input)
|
|
79
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('always calls the latest handler', () => {
|
|
83
|
+
const first = vi.fn()
|
|
84
|
+
const second = vi.fn()
|
|
85
|
+
const { rerender } = render(<Bind combo="mod+k" handler={first} />)
|
|
86
|
+
rerender(<Bind combo="mod+k" handler={second} />)
|
|
87
|
+
dispatchModK()
|
|
88
|
+
expect(first).not.toHaveBeenCalled()
|
|
89
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* Bind a `mod+<key>` command. The handler is wrapped in a ref so changing
|
|
2
|
+
handler identity each render does not re-register; only `combo` and
|
|
3
|
+
`enabled` are real dependencies. */
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
|
|
7
|
+
import { parseCombo } from './parse-combo'
|
|
8
|
+
import { assertNotProtected } from './protected-combos'
|
|
9
|
+
import { register } from './registry'
|
|
10
|
+
|
|
11
|
+
export type UseCommandOptions = {
|
|
12
|
+
/** Defaults to `true`. Set to `false` to temporarily disable without unmounting. */
|
|
13
|
+
enabled?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useCommand(
|
|
17
|
+
combo: string,
|
|
18
|
+
handler: (event: KeyboardEvent) => void,
|
|
19
|
+
options?: UseCommandOptions,
|
|
20
|
+
): void {
|
|
21
|
+
const enabled = options?.enabled ?? true
|
|
22
|
+
const handlerRef = useRef(handler)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
handlerRef.current = handler
|
|
25
|
+
})
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled) return undefined
|
|
28
|
+
const parsed = parseCombo(combo)
|
|
29
|
+
assertNotProtected(parsed)
|
|
30
|
+
return register(parsed, (event) => handlerRef.current(event))
|
|
31
|
+
}, [combo, enabled])
|
|
32
|
+
}
|