@uniweb/kit 0.9.40 → 0.9.42

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.40",
3
+ "version": "0.9.42",
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.31",
43
+ "@uniweb/core": "0.7.32",
44
44
  "@uniweb/scene": "0.1.2"
45
45
  },
46
46
  "peerDependencies": {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Overlay — render above the page, from anywhere in the tree.
3
+ *
4
+ * ## Why this exists (it is not "a portal wrapper")
5
+ *
6
+ * The runtime gives every layout area its own `view-transition-name` so the
7
+ * browser can animate header, sidebars and body independently instead of
8
+ * crossfading the whole page. That is on by default.
9
+ *
10
+ * A `view-transition-name` makes the element **a stacking context** and **a
11
+ * containing block for fixed-position descendants**. Both consequences bite
12
+ * the most ordinary thing a foundation builds: a modal opened from the header.
13
+ *
14
+ * div[view-transition-name: uw-header] ← area wrapper, z-index: auto
15
+ * └ div.fixed.inset-0.z-[100] ← your modal
16
+ * div[view-transition-name: uw-body] ← sibling area, z-index: auto
17
+ *
18
+ * The modal's `z-index: 100` is scoped *inside* `uw-header`. Both wrappers sit
19
+ * at `auto` in the root stacking context, so they paint in DOM order — and the
20
+ * body comes second. No z-index a foundation can write will lift the modal
21
+ * above the page content, because the two are not competing in the same
22
+ * stacking context. Raising the number looks like it should work and never
23
+ * does, which is what makes this worth a component rather than a doc note.
24
+ *
25
+ * Rendering into `document.body` leaves every area wrapper behind, so the
26
+ * overlay competes in the root stacking context where its z-index means what
27
+ * the author expects.
28
+ *
29
+ * ## Usage
30
+ *
31
+ * {isOpen && (
32
+ * <Overlay onClose={close}>
33
+ * <div role="dialog" aria-modal="true">…</div>
34
+ * </Overlay>
35
+ * )}
36
+ *
37
+ * Owns only what every overlay needs and nothing about how it looks: the
38
+ * portal, the scrim, Escape, a click outside, and the page-scroll lock. The
39
+ * dialog itself — its shape, its focus handling, its content — is the
40
+ * foundation's design, the same way kit ships no layout.
41
+ */
42
+
43
+ import { useEffect } from 'react'
44
+ import { createPortal } from 'react-dom'
45
+ import { cn } from '../../utils/index.js'
46
+
47
+ /**
48
+ * @param {object} props
49
+ * @param {React.ReactNode} props.children - The overlay content (your dialog).
50
+ * @param {Function} [props.onClose] - Called on Escape and on a scrim click.
51
+ * Omit for a non-dismissible overlay.
52
+ * @param {boolean} [props.lockScroll=true] - Prevent the page behind from
53
+ * scrolling while open.
54
+ * @param {boolean} [props.closeOnEscape=true]
55
+ * @param {boolean} [props.closeOnScrimClick=true]
56
+ * @param {string} [props.className] - Classes for the scrim/positioning layer.
57
+ * Defaults place the content centred near the top, the usual command-palette
58
+ * position; pass your own to change it.
59
+ * @param {number|string} [props.zIndex=100]
60
+ */
61
+ export function Overlay({
62
+ children,
63
+ onClose,
64
+ lockScroll = true,
65
+ closeOnEscape = true,
66
+ closeOnScrimClick = true,
67
+ className,
68
+ zIndex = 100,
69
+ ...rest
70
+ }) {
71
+ useEffect(() => {
72
+ if (!closeOnEscape || !onClose) return
73
+ const onKeyDown = (event) => {
74
+ if (event.key === 'Escape') {
75
+ event.preventDefault()
76
+ onClose(event)
77
+ }
78
+ }
79
+ // Capture phase: a dialog's own input may stop propagation on keydown,
80
+ // and Escape still has to close the thing it is typed into.
81
+ document.addEventListener('keydown', onKeyDown, true)
82
+ return () => document.removeEventListener('keydown', onKeyDown, true)
83
+ }, [closeOnEscape, onClose])
84
+
85
+ useEffect(() => {
86
+ if (!lockScroll) return
87
+ // Restore the previous value rather than clearing: two stacked overlays
88
+ // would otherwise have the inner one unlock the page on close.
89
+ const previous = document.body.style.overflow
90
+ document.body.style.overflow = 'hidden'
91
+ return () => {
92
+ document.body.style.overflow = previous
93
+ }
94
+ }, [lockScroll])
95
+
96
+ // No DOM: prerender and any Node render. An overlay is by definition
97
+ // interactive, so there is nothing meaningful to emit into static HTML —
98
+ // and emitting it would put a scrim over the prerendered page.
99
+ if (typeof document === 'undefined') return null
100
+
101
+ return createPortal(
102
+ <div
103
+ className={cn('fixed inset-0 flex items-start justify-center', className)}
104
+ style={{ zIndex }}
105
+ onClick={closeOnScrimClick && onClose ? (e) => {
106
+ // Only a click on the scrim itself, never one that bubbled out of the
107
+ // dialog — otherwise selecting text and releasing outside closes it.
108
+ if (e.target === e.currentTarget) onClose(e)
109
+ } : undefined}
110
+ {...rest}
111
+ >
112
+ {children}
113
+ </div>,
114
+ document.body
115
+ )
116
+ }
117
+
118
+ export default Overlay
@@ -0,0 +1 @@
1
+ export { Overlay, default } from './Overlay.jsx'
@@ -191,10 +191,22 @@ export function matchesShortcut(event, descriptor) {
191
191
  * `userAgentData.platform` is the modern signal; `navigator.platform` is
192
192
  * deprecated but still the most widely accurate fallback.
193
193
  *
194
- * @returns {boolean} false when there is no navigator (SSR).
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.
195
207
  */
196
208
  export function isApplePlatform() {
197
- if (typeof navigator === 'undefined') return false
209
+ if (typeof document === 'undefined' || typeof navigator === 'undefined') return false
198
210
  const modern = navigator.userAgentData?.platform
199
211
  if (typeof modern === 'string' && modern) return /mac|ios|iphone|ipad/i.test(modern)
200
212
  return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '')
package/src/index.js CHANGED
@@ -15,6 +15,9 @@
15
15
  // Navigation
16
16
  export { Link } from './components/Link/index.js'
17
17
  export { Image } from './components/Image/index.js'
18
+ // Renders above the page from anywhere in the tree — see Overlay.jsx for why
19
+ // a plain fixed+z-index element cannot escape a layout area.
20
+ export { Overlay } from './components/Overlay/index.js'
18
21
  export { SafeHtml } from './components/SafeHtml/index.js'
19
22
  export { Icon } from './components/Icon/index.js'
20
23