@uniweb/kit 0.9.39 → 0.9.41

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.41",
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,442 @@
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
+ * **A `navigator` check alone is not enough, and the failure is nasty.**
195
+ * Node 21+ defines `globalThis.navigator`, and on macOS its `platform` reads
196
+ * `'MacIntel'` — so during prerender this returned true and baked ⌘ into
197
+ * static HTML based on the machine that ran the BUILD. Every visitor then got
198
+ * the build box's platform, and the same source produced different output on a
199
+ * Mac laptop and a Linux CI runner.
200
+ *
201
+ * `document` is the discriminator: Node defines a navigator but never a DOM.
202
+ * Prerender therefore emits the non-Apple spelling, and the client corrects it
203
+ * on mount — which is safe here because the runtime always createRoots and
204
+ * never hydrates, so there is no mismatch to reconcile.
205
+ *
206
+ * @returns {boolean} false anywhere that is not a real browser.
207
+ */
208
+ export function isApplePlatform() {
209
+ if (typeof document === 'undefined' || typeof navigator === 'undefined') return false
210
+ const modern = navigator.userAgentData?.platform
211
+ if (typeof modern === 'string' && modern) return /mac|ios|iphone|ipad/i.test(modern)
212
+ return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '')
213
+ }
214
+
215
+ /**
216
+ * Render a binding the way the platform writes it.
217
+ *
218
+ * This is the function that fixes the bug foundations keep shipping: a
219
+ * hardcoded `⌘` shown to a Ctrl user. Apple order is ⌃⌥⇧⌘ then the key, with
220
+ * no separator; elsewhere it is `Ctrl+Alt+Shift+Key`.
221
+ *
222
+ * @param {string|object} binding - Binding string, or a parsed descriptor.
223
+ * @param {object} [options]
224
+ * @param {boolean} [options.apple] - Override platform detection (testing, or
225
+ * a foundation that wants both spellings side by side).
226
+ * @returns {string} '' when the binding is unparseable.
227
+ */
228
+ export function formatShortcut(binding, { apple = isApplePlatform() } = {}) {
229
+ const d = typeof binding === 'string' ? parseShortcut(binding) : binding
230
+ if (!d || !d.key) return ''
231
+
232
+ const parts = []
233
+ if (apple) {
234
+ if (d.ctrl) parts.push('⌃')
235
+ if (d.alt) parts.push('⌥')
236
+ if (d.shift) parts.push('⇧')
237
+ if (d.mod || d.meta) parts.push('⌘')
238
+ } else {
239
+ // `mod` reads as Ctrl off Apple platforms — the same key the matcher accepts.
240
+ if (d.mod || d.ctrl) parts.push('Ctrl')
241
+ if (d.meta && !d.mod) parts.push('Meta')
242
+ if (d.alt) parts.push('Alt')
243
+ if (d.shift) parts.push('Shift')
244
+ }
245
+
246
+ const labelled = KEY_LABELS[d.key]
247
+ const key = labelled
248
+ ? (apple ? labelled.apple : labelled.other)
249
+ : (d.key.length === 1 ? d.key.toUpperCase() : d.key.charAt(0).toUpperCase() + d.key.slice(1))
250
+
251
+ parts.push(key)
252
+ return apple ? parts.join('') : parts.join('+')
253
+ }
254
+
255
+ /**
256
+ * Is the event coming from somewhere the user is typing?
257
+ *
258
+ * @param {EventTarget} target
259
+ * @returns {boolean}
260
+ */
261
+ function isTypingTarget(target) {
262
+ if (!target || typeof target !== 'object') return false
263
+ if (target.isContentEditable) return true
264
+ const tag = typeof target.tagName === 'string' ? target.tagName.toUpperCase() : ''
265
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
266
+ }
267
+
268
+ /**
269
+ * Dev-only duplicate-binding registry.
270
+ *
271
+ * Two window-level listeners on one combo both fire and both preventDefault,
272
+ * and nothing surfaces it: no error, no visible symptom, just two handlers
273
+ * running. Counting mounts per binding turns that silence into one dev warning.
274
+ *
275
+ * Guarded rather than read directly: `import.meta.env` is a Vite construct and
276
+ * is undefined when kit runs under plain Node (SSR, prerender, unipress).
277
+ */
278
+ const activeBindings = new Map()
279
+
280
+ function isDevEnvironment() {
281
+ try {
282
+ if (typeof import.meta !== 'undefined' && import.meta.env) return !!import.meta.env.DEV
283
+ } catch { /* not a Vite graph */ }
284
+ return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'
285
+ }
286
+
287
+ function registerBinding(id) {
288
+ if (!isDevEnvironment()) return () => {}
289
+ const next = (activeBindings.get(id) || 0) + 1
290
+ activeBindings.set(id, next)
291
+ if (next > 1) {
292
+ console.warn(
293
+ `[useShortcut] "${id}" is bound ${next} times at once. Every handler will fire and ` +
294
+ `each will preventDefault — bind it in one place, or give one of them a different key.`
295
+ )
296
+ }
297
+ return () => {
298
+ const count = (activeBindings.get(id) || 1) - 1
299
+ if (count > 0) activeBindings.set(id, count)
300
+ else activeBindings.delete(id)
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Bind a keyboard shortcut for as long as the component is mounted.
306
+ *
307
+ * SSR-safe: everything touching `window` runs inside the effect, so this is
308
+ * inert during prerender rather than needing a `typeof document` guard.
309
+ *
310
+ * **`whileTyping` defaults to the right thing instead of a fixed value.** A
311
+ * bare key (`/`, `?`) must not hijack someone's typing; a modifier combo
312
+ * (`mod+k`) is normally still wanted while a field has focus — including when
313
+ * the focused field is the very input the shortcut opens. So the default is
314
+ * derived from the binding: combos fire while typing, bare keys don't. Pass
315
+ * the option to override either way (`escape` usually wants `true`).
316
+ *
317
+ * @param {string} binding - e.g. 'mod+k', 'shift+/', 'escape'.
318
+ * @param {Function} handler - Called with the KeyboardEvent.
319
+ * @param {object} [options]
320
+ * @param {boolean} [options.enabled=true] - Bind only while true.
321
+ * @param {boolean} [options.whileTyping] - Fire while an input/textarea/
322
+ * contenteditable has focus. Defaults to true for modifier combos, false
323
+ * for bare keys.
324
+ * @param {boolean} [options.preventDefault=true] - Call preventDefault when it fires.
325
+ * @param {EventTarget} [options.target] - Listen somewhere other than window.
326
+ */
327
+ export function useShortcut(binding, handler, options = {}) {
328
+ const { enabled = true, whileTyping, preventDefault = true, target } = options
329
+ const descriptor = useMemo(() => parseShortcut(binding), [binding])
330
+
331
+ // The handler is read through a ref, not closed over by the effect.
332
+ // `useShortcut('mod+k', () => setOpen(true))` is the call style this hook
333
+ // exists to support, and an inline arrow has a new identity every render —
334
+ // as an effect dependency it would tear down and re-add the listener on
335
+ // every render, for no behavioural gain. The ref keeps one listener for the
336
+ // life of the binding while still calling the latest closure.
337
+ const handlerRef = useRef(handler)
338
+ handlerRef.current = handler
339
+
340
+ useEffect(() => {
341
+ if (!enabled || !descriptor) return
342
+ if (typeof window === 'undefined') return
343
+
344
+ const node = target || window
345
+ if (!node?.addEventListener) return
346
+
347
+ const hasModifier = descriptor.mod || descriptor.ctrl || descriptor.meta || descriptor.alt
348
+ const allowWhileTyping = whileTyping === undefined ? hasModifier : whileTyping
349
+
350
+ const onKeyDown = (event) => {
351
+ if (typeof handlerRef.current !== 'function') return
352
+ if (!matchesShortcut(event, descriptor)) return
353
+ if (!allowWhileTyping && isTypingTarget(event.target)) return
354
+ if (preventDefault) event.preventDefault()
355
+ handlerRef.current(event)
356
+ }
357
+
358
+ const unregister = registerBinding(descriptor.id)
359
+ node.addEventListener('keydown', onKeyDown)
360
+ return () => {
361
+ node.removeEventListener('keydown', onKeyDown)
362
+ unregister()
363
+ }
364
+ }, [descriptor, enabled, whileTyping, preventDefault, target])
365
+ }
366
+
367
+ /**
368
+ * The platform-correct label for a binding, e.g. '⌘K' or 'Ctrl+K'.
369
+ *
370
+ * Resolved in a lazy initializer rather than an effect: the runtime always
371
+ * `createRoot`s and never hydrates, so a client-side value computed on first
372
+ * render cannot cause a hydration mismatch. Prerendered HTML carries the
373
+ * non-Apple spelling and the client corrects it on mount.
374
+ *
375
+ * @param {string} binding
376
+ * @returns {string}
377
+ */
378
+ export function useShortcutLabel(binding) {
379
+ const [apple] = useState(() => isApplePlatform())
380
+ return useMemo(() => formatShortcut(binding, { apple }), [binding, apple])
381
+ }
382
+
383
+ /**
384
+ * Bind several shortcuts from one map: `{ 'mod+k': open, escape: close }`.
385
+ *
386
+ * Sugar over useShortcut for components with a handful of bindings. Options
387
+ * apply to every entry; reach for individual useShortcut calls when they need
388
+ * to differ.
389
+ *
390
+ * @param {Object<string, Function>} bindings
391
+ * @param {object} [options] - As useShortcut.
392
+ */
393
+ export function useShortcuts(bindings, options = {}) {
394
+ const { enabled = true, whileTyping, preventDefault = true, target } = options
395
+
396
+ // Same ref discipline as useShortcut, and it matters more here: the natural
397
+ // call is an object literal (`useShortcuts({ 'mod+k': open })`), which is a
398
+ // fresh object every render. Keying the effect on the SET of bindings rather
399
+ // than the object's identity is what keeps one listener alive across
400
+ // renders; the handlers themselves are read fresh from the ref.
401
+ const bindingsRef = useRef(bindings)
402
+ bindingsRef.current = bindings
403
+
404
+ const keys = Object.keys(bindings || {})
405
+ const bindingKey = keys.join('')
406
+
407
+ const descriptors = useMemo(
408
+ () => keys.map(b => [b, parseShortcut(b)]).filter(([, d]) => d),
409
+ // eslint-disable-next-line react-hooks/exhaustive-deps
410
+ [bindingKey]
411
+ )
412
+
413
+ useEffect(() => {
414
+ if (!enabled || descriptors.length === 0) return
415
+ if (typeof window === 'undefined') return
416
+ const node = target || window
417
+ if (!node?.addEventListener) return
418
+
419
+ const onKeyDown = (event) => {
420
+ for (const [binding, descriptor] of descriptors) {
421
+ if (!matchesShortcut(event, descriptor)) continue
422
+ const handler = bindingsRef.current?.[binding]
423
+ if (typeof handler !== 'function') continue
424
+ const hasModifier = descriptor.mod || descriptor.ctrl || descriptor.meta || descriptor.alt
425
+ const allowWhileTyping = whileTyping === undefined ? hasModifier : whileTyping
426
+ if (!allowWhileTyping && isTypingTarget(event.target)) continue
427
+ if (preventDefault) event.preventDefault()
428
+ handler(event)
429
+ return
430
+ }
431
+ }
432
+
433
+ const unregisters = descriptors.map(([, d]) => registerBinding(d.id))
434
+ node.addEventListener('keydown', onKeyDown)
435
+ return () => {
436
+ node.removeEventListener('keydown', onKeyDown)
437
+ unregisters.forEach(fn => fn())
438
+ }
439
+ }, [descriptors, enabled, whileTyping, preventDefault, target])
440
+ }
441
+
442
+ 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
  /**