@uniweb/kit 0.9.39 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.39",
3
+ "version": "0.9.40",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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'
@@ -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
- useEffect(() => {
260
- const handleKeyDown = (e) => {
261
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
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
  /**