@uniweb/kit 0.9.38 → 0.9.40
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/package.json +2 -2
- package/src/hooks/index.js +11 -0
- package/src/hooks/useShortcut.js +430 -0
- package/src/index.js +8 -0
- package/src/search/hooks.js +17 -12
- package/src/styled/Prose/index.jsx +26 -0
- package/src/styled/Section/Render.jsx +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.40",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"fuse.js": "^7.0.0",
|
|
41
41
|
"shiki": "^3.0.0",
|
|
42
42
|
"tailwind-merge": "^3.6.0",
|
|
43
|
-
"@uniweb/core": "0.7.
|
|
43
|
+
"@uniweb/core": "0.7.31",
|
|
44
44
|
"@uniweb/scene": "0.1.2"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
package/src/hooks/index.js
CHANGED
|
@@ -15,6 +15,17 @@ export { useInView, useIsInView } from './useInView.js'
|
|
|
15
15
|
export { usePageState } from './usePageState.js'
|
|
16
16
|
export { useWebsiteState } from './useWebsiteState.js'
|
|
17
17
|
|
|
18
|
+
// Keyboard shortcuts — the mechanism only; kit binds no keys of its own.
|
|
19
|
+
export {
|
|
20
|
+
useShortcut,
|
|
21
|
+
useShortcuts,
|
|
22
|
+
useShortcutLabel,
|
|
23
|
+
parseShortcut,
|
|
24
|
+
matchesShortcut,
|
|
25
|
+
formatShortcut,
|
|
26
|
+
isApplePlatform
|
|
27
|
+
} from './useShortcut.js'
|
|
28
|
+
|
|
18
29
|
// Theme data hooks (runtime theme access)
|
|
19
30
|
export {
|
|
20
31
|
useThemeData,
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard shortcuts — the mechanism, not the meaning.
|
|
3
|
+
*
|
|
4
|
+
* This module owns exactly one thing: making a key binding CORRECT. It has no
|
|
5
|
+
* idea what any key does, ships no default binding, and names no action. A
|
|
6
|
+
* foundation decides that a combo opens its palette or its search or anything
|
|
7
|
+
* else; kit only guarantees that the combo fires when it should, doesn't when
|
|
8
|
+
* it shouldn't, renders right on the user's platform, and is cleaned up.
|
|
9
|
+
*
|
|
10
|
+
* That split is deliberate. Blessing a default (`mod+k` means search) would be
|
|
11
|
+
* the same category error as shipping a default brand colour: a convention some
|
|
12
|
+
* foundations follow, dressed up as a framework fact.
|
|
13
|
+
*
|
|
14
|
+
* The pure functions do the work and the hooks are thin wrappers over them, so
|
|
15
|
+
* every rule below is testable without a DOM.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* function Header() {
|
|
19
|
+
* const [open, setOpen] = useState(false)
|
|
20
|
+
* useShortcut('mod+k', () => setOpen(true))
|
|
21
|
+
* return <button>Open <kbd>{useShortcutLabel('mod+k')}</kbd></button>
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
26
|
+
|
|
27
|
+
/** Modifier tokens accepted in a binding string, normalized to canonical form. */
|
|
28
|
+
const MODIFIER_ALIASES = {
|
|
29
|
+
mod: 'mod',
|
|
30
|
+
ctrl: 'ctrl',
|
|
31
|
+
control: 'ctrl',
|
|
32
|
+
alt: 'alt',
|
|
33
|
+
option: 'alt',
|
|
34
|
+
opt: 'alt',
|
|
35
|
+
shift: 'shift',
|
|
36
|
+
meta: 'meta',
|
|
37
|
+
cmd: 'meta',
|
|
38
|
+
command: 'meta',
|
|
39
|
+
super: 'meta',
|
|
40
|
+
win: 'meta',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Key-name aliases → the lowercased `KeyboardEvent.key` they stand for. */
|
|
44
|
+
const KEY_ALIASES = {
|
|
45
|
+
esc: 'escape',
|
|
46
|
+
escape: 'escape',
|
|
47
|
+
enter: 'enter',
|
|
48
|
+
return: 'enter',
|
|
49
|
+
space: ' ',
|
|
50
|
+
spacebar: ' ',
|
|
51
|
+
tab: 'tab',
|
|
52
|
+
up: 'arrowup',
|
|
53
|
+
down: 'arrowdown',
|
|
54
|
+
left: 'arrowleft',
|
|
55
|
+
right: 'arrowright',
|
|
56
|
+
arrowup: 'arrowup',
|
|
57
|
+
arrowdown: 'arrowdown',
|
|
58
|
+
arrowleft: 'arrowleft',
|
|
59
|
+
arrowright: 'arrowright',
|
|
60
|
+
del: 'delete',
|
|
61
|
+
delete: 'delete',
|
|
62
|
+
backspace: 'backspace',
|
|
63
|
+
plus: '+',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** How a named key renders, per platform family. */
|
|
67
|
+
const KEY_LABELS = {
|
|
68
|
+
escape: { apple: 'esc', other: 'Esc' },
|
|
69
|
+
enter: { apple: '↩', other: 'Enter' },
|
|
70
|
+
' ': { apple: 'Space', other: 'Space' },
|
|
71
|
+
tab: { apple: '⇥', other: 'Tab' },
|
|
72
|
+
arrowup: { apple: '↑', other: '↑' },
|
|
73
|
+
arrowdown: { apple: '↓', other: '↓' },
|
|
74
|
+
arrowleft: { apple: '←', other: '←' },
|
|
75
|
+
arrowright: { apple: '→', other: '→' },
|
|
76
|
+
backspace: { apple: '⌫', other: 'Backspace' },
|
|
77
|
+
delete: { apple: '⌦', other: 'Del' },
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a binding string into a descriptor.
|
|
82
|
+
*
|
|
83
|
+
* Accepts `mod+k`, `Shift+/`, `escape`, `mod+shift+p`. Case-insensitive; `+`
|
|
84
|
+
* separates parts, and a literal `+` key is written `plus` (or as the final
|
|
85
|
+
* part, e.g. `mod++`).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} binding
|
|
88
|
+
* @returns {{ mod: boolean, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean, key: string, id: string }|null}
|
|
89
|
+
* null when the binding is unparseable (no key, or modifiers only).
|
|
90
|
+
*/
|
|
91
|
+
export function parseShortcut(binding) {
|
|
92
|
+
if (typeof binding !== 'string') return null
|
|
93
|
+
const raw = binding.trim()
|
|
94
|
+
if (!raw) return null
|
|
95
|
+
|
|
96
|
+
// Split on '+' but keep a trailing literal '+' as the key ("mod++").
|
|
97
|
+
const parts = raw.endsWith('+') && raw.length > 1
|
|
98
|
+
? [...raw.slice(0, -1).split('+'), '+']
|
|
99
|
+
: raw.split('+')
|
|
100
|
+
|
|
101
|
+
const descriptor = { mod: false, ctrl: false, alt: false, shift: false, meta: false, key: '' }
|
|
102
|
+
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const token = part.trim().toLowerCase()
|
|
105
|
+
if (!token) continue
|
|
106
|
+
const modifier = MODIFIER_ALIASES[token]
|
|
107
|
+
if (modifier) {
|
|
108
|
+
descriptor[modifier] = true
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
// Last non-modifier token wins as the key.
|
|
112
|
+
descriptor.key = KEY_ALIASES[token] || token
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!descriptor.key) return null
|
|
116
|
+
descriptor.id = shortcutId(descriptor)
|
|
117
|
+
return descriptor
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Stable identity for a descriptor — the collision-registry key. */
|
|
121
|
+
function shortcutId(d) {
|
|
122
|
+
const mods = [
|
|
123
|
+
d.mod && 'mod', d.ctrl && 'ctrl', d.meta && 'meta', d.alt && 'alt', d.shift && 'shift',
|
|
124
|
+
].filter(Boolean)
|
|
125
|
+
return [...mods, d.key].join('+')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True for a single alphanumeric character. */
|
|
129
|
+
function isAlnumKey(key) {
|
|
130
|
+
return key.length === 1 && /[a-z0-9]/i.test(key)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Does this keyboard event satisfy the descriptor?
|
|
135
|
+
*
|
|
136
|
+
* Takes an event-LIKE object (`{ key, metaKey, ctrlKey, altKey, shiftKey }`),
|
|
137
|
+
* so the whole matching rule is unit-testable with plain objects.
|
|
138
|
+
*
|
|
139
|
+
* Three rules that are easy to get wrong, and why they are what they are:
|
|
140
|
+
*
|
|
141
|
+
* 1. **`mod` matches meta OR ctrl on every platform** — it is not resolved to
|
|
142
|
+
* one of them by sniffing the OS. Binding is the side where a wrong guess
|
|
143
|
+
* costs you a shortcut that silently never fires, so it stays permissive;
|
|
144
|
+
* the platform question is answered only where it is cosmetic, in
|
|
145
|
+
* formatShortcut(). Bind permissively, label precisely.
|
|
146
|
+
*
|
|
147
|
+
* 2. **The key compares case-insensitively** against `event.key`. Otherwise
|
|
148
|
+
* CapsLock yields 'K' and the binding quietly stops working.
|
|
149
|
+
*
|
|
150
|
+
* 3. **Shift is enforced for alphanumeric keys, ignored for punctuation.**
|
|
151
|
+
* `event.key` is the PRODUCED character, so `?` already implies shift was
|
|
152
|
+
* held — demanding `shift: false` there would make `'?'` unmatchable, while
|
|
153
|
+
* demanding it for letters is what keeps `mod+k` from also firing on
|
|
154
|
+
* `mod+shift+k`.
|
|
155
|
+
*
|
|
156
|
+
* @param {{key?: string, metaKey?: boolean, ctrlKey?: boolean, altKey?: boolean, shiftKey?: boolean}} event
|
|
157
|
+
* @param {object} descriptor - From parseShortcut.
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
export function matchesShortcut(event, descriptor) {
|
|
161
|
+
if (!event || !descriptor || typeof event.key !== 'string') return false
|
|
162
|
+
|
|
163
|
+
if (event.key.toLowerCase() !== descriptor.key.toLowerCase()) return false
|
|
164
|
+
|
|
165
|
+
const meta = !!event.metaKey
|
|
166
|
+
const ctrl = !!event.ctrlKey
|
|
167
|
+
|
|
168
|
+
if (descriptor.mod) {
|
|
169
|
+
if (!meta && !ctrl) return false
|
|
170
|
+
} else {
|
|
171
|
+
if (descriptor.meta !== meta) return false
|
|
172
|
+
if (descriptor.ctrl !== ctrl) return false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (descriptor.alt !== !!event.altKey) return false
|
|
176
|
+
|
|
177
|
+
// Rule 3 — only alphanumeric keys carry a meaningful "shift must be up".
|
|
178
|
+
if (descriptor.shift) {
|
|
179
|
+
if (!event.shiftKey) return false
|
|
180
|
+
} else if (isAlnumKey(descriptor.key) && event.shiftKey) {
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Is this platform in the Apple family (⌘ conventions)?
|
|
189
|
+
*
|
|
190
|
+
* Only ever consulted for DISPLAY — see rule 1 in matchesShortcut.
|
|
191
|
+
* `userAgentData.platform` is the modern signal; `navigator.platform` is
|
|
192
|
+
* deprecated but still the most widely accurate fallback.
|
|
193
|
+
*
|
|
194
|
+
* @returns {boolean} false when there is no navigator (SSR).
|
|
195
|
+
*/
|
|
196
|
+
export function isApplePlatform() {
|
|
197
|
+
if (typeof navigator === 'undefined') return false
|
|
198
|
+
const modern = navigator.userAgentData?.platform
|
|
199
|
+
if (typeof modern === 'string' && modern) return /mac|ios|iphone|ipad/i.test(modern)
|
|
200
|
+
return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render a binding the way the platform writes it.
|
|
205
|
+
*
|
|
206
|
+
* This is the function that fixes the bug foundations keep shipping: a
|
|
207
|
+
* hardcoded `⌘` shown to a Ctrl user. Apple order is ⌃⌥⇧⌘ then the key, with
|
|
208
|
+
* no separator; elsewhere it is `Ctrl+Alt+Shift+Key`.
|
|
209
|
+
*
|
|
210
|
+
* @param {string|object} binding - Binding string, or a parsed descriptor.
|
|
211
|
+
* @param {object} [options]
|
|
212
|
+
* @param {boolean} [options.apple] - Override platform detection (testing, or
|
|
213
|
+
* a foundation that wants both spellings side by side).
|
|
214
|
+
* @returns {string} '' when the binding is unparseable.
|
|
215
|
+
*/
|
|
216
|
+
export function formatShortcut(binding, { apple = isApplePlatform() } = {}) {
|
|
217
|
+
const d = typeof binding === 'string' ? parseShortcut(binding) : binding
|
|
218
|
+
if (!d || !d.key) return ''
|
|
219
|
+
|
|
220
|
+
const parts = []
|
|
221
|
+
if (apple) {
|
|
222
|
+
if (d.ctrl) parts.push('⌃')
|
|
223
|
+
if (d.alt) parts.push('⌥')
|
|
224
|
+
if (d.shift) parts.push('⇧')
|
|
225
|
+
if (d.mod || d.meta) parts.push('⌘')
|
|
226
|
+
} else {
|
|
227
|
+
// `mod` reads as Ctrl off Apple platforms — the same key the matcher accepts.
|
|
228
|
+
if (d.mod || d.ctrl) parts.push('Ctrl')
|
|
229
|
+
if (d.meta && !d.mod) parts.push('Meta')
|
|
230
|
+
if (d.alt) parts.push('Alt')
|
|
231
|
+
if (d.shift) parts.push('Shift')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const labelled = KEY_LABELS[d.key]
|
|
235
|
+
const key = labelled
|
|
236
|
+
? (apple ? labelled.apple : labelled.other)
|
|
237
|
+
: (d.key.length === 1 ? d.key.toUpperCase() : d.key.charAt(0).toUpperCase() + d.key.slice(1))
|
|
238
|
+
|
|
239
|
+
parts.push(key)
|
|
240
|
+
return apple ? parts.join('') : parts.join('+')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Is the event coming from somewhere the user is typing?
|
|
245
|
+
*
|
|
246
|
+
* @param {EventTarget} target
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
function isTypingTarget(target) {
|
|
250
|
+
if (!target || typeof target !== 'object') return false
|
|
251
|
+
if (target.isContentEditable) return true
|
|
252
|
+
const tag = typeof target.tagName === 'string' ? target.tagName.toUpperCase() : ''
|
|
253
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Dev-only duplicate-binding registry.
|
|
258
|
+
*
|
|
259
|
+
* Two window-level listeners on one combo both fire and both preventDefault,
|
|
260
|
+
* and nothing surfaces it: no error, no visible symptom, just two handlers
|
|
261
|
+
* running. Counting mounts per binding turns that silence into one dev warning.
|
|
262
|
+
*
|
|
263
|
+
* Guarded rather than read directly: `import.meta.env` is a Vite construct and
|
|
264
|
+
* is undefined when kit runs under plain Node (SSR, prerender, unipress).
|
|
265
|
+
*/
|
|
266
|
+
const activeBindings = new Map()
|
|
267
|
+
|
|
268
|
+
function isDevEnvironment() {
|
|
269
|
+
try {
|
|
270
|
+
if (typeof import.meta !== 'undefined' && import.meta.env) return !!import.meta.env.DEV
|
|
271
|
+
} catch { /* not a Vite graph */ }
|
|
272
|
+
return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function registerBinding(id) {
|
|
276
|
+
if (!isDevEnvironment()) return () => {}
|
|
277
|
+
const next = (activeBindings.get(id) || 0) + 1
|
|
278
|
+
activeBindings.set(id, next)
|
|
279
|
+
if (next > 1) {
|
|
280
|
+
console.warn(
|
|
281
|
+
`[useShortcut] "${id}" is bound ${next} times at once. Every handler will fire and ` +
|
|
282
|
+
`each will preventDefault — bind it in one place, or give one of them a different key.`
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
return () => {
|
|
286
|
+
const count = (activeBindings.get(id) || 1) - 1
|
|
287
|
+
if (count > 0) activeBindings.set(id, count)
|
|
288
|
+
else activeBindings.delete(id)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bind a keyboard shortcut for as long as the component is mounted.
|
|
294
|
+
*
|
|
295
|
+
* SSR-safe: everything touching `window` runs inside the effect, so this is
|
|
296
|
+
* inert during prerender rather than needing a `typeof document` guard.
|
|
297
|
+
*
|
|
298
|
+
* **`whileTyping` defaults to the right thing instead of a fixed value.** A
|
|
299
|
+
* bare key (`/`, `?`) must not hijack someone's typing; a modifier combo
|
|
300
|
+
* (`mod+k`) is normally still wanted while a field has focus — including when
|
|
301
|
+
* the focused field is the very input the shortcut opens. So the default is
|
|
302
|
+
* derived from the binding: combos fire while typing, bare keys don't. Pass
|
|
303
|
+
* the option to override either way (`escape` usually wants `true`).
|
|
304
|
+
*
|
|
305
|
+
* @param {string} binding - e.g. 'mod+k', 'shift+/', 'escape'.
|
|
306
|
+
* @param {Function} handler - Called with the KeyboardEvent.
|
|
307
|
+
* @param {object} [options]
|
|
308
|
+
* @param {boolean} [options.enabled=true] - Bind only while true.
|
|
309
|
+
* @param {boolean} [options.whileTyping] - Fire while an input/textarea/
|
|
310
|
+
* contenteditable has focus. Defaults to true for modifier combos, false
|
|
311
|
+
* for bare keys.
|
|
312
|
+
* @param {boolean} [options.preventDefault=true] - Call preventDefault when it fires.
|
|
313
|
+
* @param {EventTarget} [options.target] - Listen somewhere other than window.
|
|
314
|
+
*/
|
|
315
|
+
export function useShortcut(binding, handler, options = {}) {
|
|
316
|
+
const { enabled = true, whileTyping, preventDefault = true, target } = options
|
|
317
|
+
const descriptor = useMemo(() => parseShortcut(binding), [binding])
|
|
318
|
+
|
|
319
|
+
// The handler is read through a ref, not closed over by the effect.
|
|
320
|
+
// `useShortcut('mod+k', () => setOpen(true))` is the call style this hook
|
|
321
|
+
// exists to support, and an inline arrow has a new identity every render —
|
|
322
|
+
// as an effect dependency it would tear down and re-add the listener on
|
|
323
|
+
// every render, for no behavioural gain. The ref keeps one listener for the
|
|
324
|
+
// life of the binding while still calling the latest closure.
|
|
325
|
+
const handlerRef = useRef(handler)
|
|
326
|
+
handlerRef.current = handler
|
|
327
|
+
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (!enabled || !descriptor) return
|
|
330
|
+
if (typeof window === 'undefined') return
|
|
331
|
+
|
|
332
|
+
const node = target || window
|
|
333
|
+
if (!node?.addEventListener) return
|
|
334
|
+
|
|
335
|
+
const hasModifier = descriptor.mod || descriptor.ctrl || descriptor.meta || descriptor.alt
|
|
336
|
+
const allowWhileTyping = whileTyping === undefined ? hasModifier : whileTyping
|
|
337
|
+
|
|
338
|
+
const onKeyDown = (event) => {
|
|
339
|
+
if (typeof handlerRef.current !== 'function') return
|
|
340
|
+
if (!matchesShortcut(event, descriptor)) return
|
|
341
|
+
if (!allowWhileTyping && isTypingTarget(event.target)) return
|
|
342
|
+
if (preventDefault) event.preventDefault()
|
|
343
|
+
handlerRef.current(event)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const unregister = registerBinding(descriptor.id)
|
|
347
|
+
node.addEventListener('keydown', onKeyDown)
|
|
348
|
+
return () => {
|
|
349
|
+
node.removeEventListener('keydown', onKeyDown)
|
|
350
|
+
unregister()
|
|
351
|
+
}
|
|
352
|
+
}, [descriptor, enabled, whileTyping, preventDefault, target])
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The platform-correct label for a binding, e.g. '⌘K' or 'Ctrl+K'.
|
|
357
|
+
*
|
|
358
|
+
* Resolved in a lazy initializer rather than an effect: the runtime always
|
|
359
|
+
* `createRoot`s and never hydrates, so a client-side value computed on first
|
|
360
|
+
* render cannot cause a hydration mismatch. Prerendered HTML carries the
|
|
361
|
+
* non-Apple spelling and the client corrects it on mount.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} binding
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
export function useShortcutLabel(binding) {
|
|
367
|
+
const [apple] = useState(() => isApplePlatform())
|
|
368
|
+
return useMemo(() => formatShortcut(binding, { apple }), [binding, apple])
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Bind several shortcuts from one map: `{ 'mod+k': open, escape: close }`.
|
|
373
|
+
*
|
|
374
|
+
* Sugar over useShortcut for components with a handful of bindings. Options
|
|
375
|
+
* apply to every entry; reach for individual useShortcut calls when they need
|
|
376
|
+
* to differ.
|
|
377
|
+
*
|
|
378
|
+
* @param {Object<string, Function>} bindings
|
|
379
|
+
* @param {object} [options] - As useShortcut.
|
|
380
|
+
*/
|
|
381
|
+
export function useShortcuts(bindings, options = {}) {
|
|
382
|
+
const { enabled = true, whileTyping, preventDefault = true, target } = options
|
|
383
|
+
|
|
384
|
+
// Same ref discipline as useShortcut, and it matters more here: the natural
|
|
385
|
+
// call is an object literal (`useShortcuts({ 'mod+k': open })`), which is a
|
|
386
|
+
// fresh object every render. Keying the effect on the SET of bindings rather
|
|
387
|
+
// than the object's identity is what keeps one listener alive across
|
|
388
|
+
// renders; the handlers themselves are read fresh from the ref.
|
|
389
|
+
const bindingsRef = useRef(bindings)
|
|
390
|
+
bindingsRef.current = bindings
|
|
391
|
+
|
|
392
|
+
const keys = Object.keys(bindings || {})
|
|
393
|
+
const bindingKey = keys.join('')
|
|
394
|
+
|
|
395
|
+
const descriptors = useMemo(
|
|
396
|
+
() => keys.map(b => [b, parseShortcut(b)]).filter(([, d]) => d),
|
|
397
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
398
|
+
[bindingKey]
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
if (!enabled || descriptors.length === 0) return
|
|
403
|
+
if (typeof window === 'undefined') return
|
|
404
|
+
const node = target || window
|
|
405
|
+
if (!node?.addEventListener) return
|
|
406
|
+
|
|
407
|
+
const onKeyDown = (event) => {
|
|
408
|
+
for (const [binding, descriptor] of descriptors) {
|
|
409
|
+
if (!matchesShortcut(event, descriptor)) continue
|
|
410
|
+
const handler = bindingsRef.current?.[binding]
|
|
411
|
+
if (typeof handler !== 'function') continue
|
|
412
|
+
const hasModifier = descriptor.mod || descriptor.ctrl || descriptor.meta || descriptor.alt
|
|
413
|
+
const allowWhileTyping = whileTyping === undefined ? hasModifier : whileTyping
|
|
414
|
+
if (!allowWhileTyping && isTypingTarget(event.target)) continue
|
|
415
|
+
if (preventDefault) event.preventDefault()
|
|
416
|
+
handler(event)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const unregisters = descriptors.map(([, d]) => registerBinding(d.id))
|
|
422
|
+
node.addEventListener('keydown', onKeyDown)
|
|
423
|
+
return () => {
|
|
424
|
+
node.removeEventListener('keydown', onKeyDown)
|
|
425
|
+
unregisters.forEach(fn => fn())
|
|
426
|
+
}
|
|
427
|
+
}, [descriptors, enabled, whileTyping, preventDefault, target])
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export default useShortcut
|
package/src/index.js
CHANGED
|
@@ -77,6 +77,14 @@ export {
|
|
|
77
77
|
// Observable state bridges (page.state / website.state)
|
|
78
78
|
usePageState,
|
|
79
79
|
useWebsiteState,
|
|
80
|
+
// Keyboard shortcuts (mechanism only — foundations choose every key)
|
|
81
|
+
useShortcut,
|
|
82
|
+
useShortcuts,
|
|
83
|
+
useShortcutLabel,
|
|
84
|
+
parseShortcut,
|
|
85
|
+
matchesShortcut,
|
|
86
|
+
formatShortcut,
|
|
87
|
+
isApplePlatform,
|
|
80
88
|
// Form submission lifecycle for foundation Form components
|
|
81
89
|
useFormSubmit
|
|
82
90
|
} from './hooks/index.js'
|
package/src/search/hooks.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
8
8
|
import { createSearchClient, loadSearchIndex } from './client.js'
|
|
9
|
+
import { useShortcut } from '../hooks/useShortcut.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Hook to create and use a search client
|
|
@@ -237,7 +238,17 @@ export function useSearchIndex(website, options = {}) {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
/**
|
|
240
|
-
* Hook for Cmd/Ctrl+K keyboard shortcut to open search
|
|
241
|
+
* Hook for Cmd/Ctrl+K keyboard shortcut to open search.
|
|
242
|
+
*
|
|
243
|
+
* **Prefer `useShortcut` for new code.** This is a thin alias kept so existing
|
|
244
|
+
* foundations keep working; it is the one place in kit that names a specific
|
|
245
|
+
* key, and it does so only for back-compat. Which combo opens search is the
|
|
246
|
+
* foundation's call, not kit's — `useShortcut('mod+k', open)` says the same
|
|
247
|
+
* thing without implying the framework chose it.
|
|
248
|
+
*
|
|
249
|
+
* A wrapper rather than a second implementation, so the matching rules
|
|
250
|
+
* (case-insensitive key, shift handling, typing-target policy) can only ever
|
|
251
|
+
* have one behaviour.
|
|
241
252
|
*
|
|
242
253
|
* @param {Function|Object} callbacks - Either onOpen function, or { onOpen, onPreload }
|
|
243
254
|
*
|
|
@@ -256,18 +267,12 @@ export function useSearchShortcut(callbacks) {
|
|
|
256
267
|
? { onOpen: callbacks, onPreload: null }
|
|
257
268
|
: callbacks
|
|
258
269
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
e.preventDefault()
|
|
263
|
-
onPreload?.()
|
|
264
|
-
onOpen()
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
window.addEventListener('keydown', handleKeyDown)
|
|
269
|
-
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
270
|
+
const handler = useCallback(() => {
|
|
271
|
+
onPreload?.()
|
|
272
|
+
onOpen()
|
|
270
273
|
}, [onOpen, onPreload])
|
|
274
|
+
|
|
275
|
+
useShortcut('mod+k', handler)
|
|
271
276
|
}
|
|
272
277
|
|
|
273
278
|
/**
|
|
@@ -170,6 +170,32 @@ function SequenceElement({ element, block }) {
|
|
|
170
170
|
)
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
case 'inset_block': {
|
|
174
|
+
// A component reference that carries block content — the block form of
|
|
175
|
+
// an inset. Children recurse; flattening them to text is what the
|
|
176
|
+
// sequence's default branch used to do, and it lost the body.
|
|
177
|
+
//
|
|
178
|
+
// Reaching this case means the container was NOT lifted — `@uniweb/core`
|
|
179
|
+
// rewrites `inset_block` to `inset_placeholder` when it builds the render
|
|
180
|
+
// graph, and the `inset` case resolves that against the foundation. This
|
|
181
|
+
// is the fallback for a document rendered without a Block behind it.
|
|
182
|
+
//
|
|
183
|
+
// kit does NOT map the name to one of its own components — see the longer
|
|
184
|
+
// note in Section/Render. A visible generic box; never a drop.
|
|
185
|
+
const body = element.children?.map((el, i) => (
|
|
186
|
+
<SequenceElement key={i} element={el} block={block} />
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
data-inset-block={element.component || 'unknown'}
|
|
192
|
+
className="border border-border rounded-md p-4 my-4"
|
|
193
|
+
>
|
|
194
|
+
{body}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
173
199
|
case 'link': {
|
|
174
200
|
const { href, label, role } = element.attrs || {}
|
|
175
201
|
// Standalone links promoted from paragraphs
|
|
@@ -225,6 +225,43 @@ function RenderNode({ node, block, ...props }) {
|
|
|
225
225
|
)
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
case 'inset_block': {
|
|
229
|
+
// The block form of an inset: a fenced `@Component{params}` container
|
|
230
|
+
// whose body is real block content, recursed like a blockquote's rather
|
|
231
|
+
// than flattened to a string.
|
|
232
|
+
//
|
|
233
|
+
// NOTE: a container reaching this case means it was NOT lifted.
|
|
234
|
+
// `@uniweb/core`'s Block rewrites every `inset_block` to an
|
|
235
|
+
// `inset_placeholder` when it builds the render graph, so the normal
|
|
236
|
+
// path is the `inset_placeholder` case below — which resolves the
|
|
237
|
+
// component against the FOUNDATION. This branch is what is left for a
|
|
238
|
+
// document rendered without a Block behind it.
|
|
239
|
+
//
|
|
240
|
+
// `@Component` names foundation vocabulary, so kit must not answer for
|
|
241
|
+
// it. kit's own Details and Alert are not reachable through
|
|
242
|
+
// `getInset()` and must not become reachable here, or a foundation
|
|
243
|
+
// shipping its own Alert would be shadowed by ours. (kit still renders
|
|
244
|
+
// `details` / `alert` above — those are the editor's DOCUMENT node
|
|
245
|
+
// types, a different mechanism that happens to share a name.)
|
|
246
|
+
//
|
|
247
|
+
// So: no name dispatch. A VISIBLE generic box that keeps its body.
|
|
248
|
+
// Never a drop — an unmapped node taking its subtree with it is the
|
|
249
|
+
// failure this container exists to fix.
|
|
250
|
+
const component = attrs?.component
|
|
251
|
+
const body = content?.map((child, i) => (
|
|
252
|
+
<RenderNode key={i} node={child} block={block} />
|
|
253
|
+
))
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div
|
|
257
|
+
data-inset-block={component || 'unknown'}
|
|
258
|
+
className="border border-border rounded-md p-4 my-4"
|
|
259
|
+
>
|
|
260
|
+
{body}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
228
265
|
case 'horizontalRule':
|
|
229
266
|
case 'divider': {
|
|
230
267
|
return <Divider type={attrs?.type} className="my-6" />
|