@pyreon/elements 0.12.13 → 0.12.15
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/lib/index.d.ts +3 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +83 -53
- package/lib/index.js.map +1 -1
- package/package.json +12 -6
- package/src/List/component.tsx +5 -7
- package/src/Overlay/positioning.ts +191 -0
- package/src/Overlay/useOverlay.tsx +12 -175
- package/src/__tests__/elements.browser.test.tsx +59 -0
- package/src/__tests__/positioning.test.ts +90 -0
- package/src/helpers/Wrapper/component.tsx +58 -46
- package/src/utils.ts +8 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure positioning helpers for the Overlay component. Split out from
|
|
3
|
+
* `useOverlay.tsx` so the SSR-fallback branches (`typeof window === 'undefined'`)
|
|
4
|
+
* can be exercised directly by tests that stub `globalThis.window` — the
|
|
5
|
+
* `useOverlay` hook itself runs these via event handlers registered inside
|
|
6
|
+
* `onMount`, which are unreachable during module-level test imports in
|
|
7
|
+
* happy-dom (where `window` is always defined).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type OverlayPosition = Partial<{
|
|
11
|
+
top: number | string
|
|
12
|
+
bottom: number | string
|
|
13
|
+
left: number | string
|
|
14
|
+
right: number | string
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
export type Align = 'bottom' | 'top' | 'left' | 'right'
|
|
18
|
+
export type AlignX = 'left' | 'center' | 'right'
|
|
19
|
+
export type AlignY = 'bottom' | 'top' | 'center'
|
|
20
|
+
|
|
21
|
+
export type PositionResult = {
|
|
22
|
+
pos: OverlayPosition
|
|
23
|
+
resolvedAlignX: AlignX
|
|
24
|
+
resolvedAlignY: AlignY
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sel = <T,>(cond: boolean, a: T, b: T): T => (cond ? a : b)
|
|
28
|
+
|
|
29
|
+
export const calcDropdownVertical = (
|
|
30
|
+
c: DOMRect,
|
|
31
|
+
t: DOMRect,
|
|
32
|
+
align: 'top' | 'bottom',
|
|
33
|
+
alignX: AlignX,
|
|
34
|
+
offsetX: number,
|
|
35
|
+
offsetY: number,
|
|
36
|
+
): PositionResult => {
|
|
37
|
+
// SSR-fallback: positioning only runs in the mounted browser context, but
|
|
38
|
+
// the explicit guard documents the SSR-safety contract at the callsite
|
|
39
|
+
// and lets `no-window-in-ssr` prove it locally. Return shape mirrors the
|
|
40
|
+
// "no element" path below (empty `pos`, alignment preserved).
|
|
41
|
+
if (typeof window === 'undefined') return { pos: {}, resolvedAlignX: alignX, resolvedAlignY: align }
|
|
42
|
+
const pos: OverlayPosition = {}
|
|
43
|
+
|
|
44
|
+
const topPos = t.top - offsetY - c.height
|
|
45
|
+
const bottomPos = t.bottom + offsetY
|
|
46
|
+
const leftPos = t.left + offsetX
|
|
47
|
+
const rightPos = t.right - offsetX - c.width
|
|
48
|
+
|
|
49
|
+
const fitsTop = topPos >= 0
|
|
50
|
+
const fitsBottom = bottomPos + c.height <= window.innerHeight
|
|
51
|
+
const fitsLeft = leftPos + c.width <= window.innerWidth
|
|
52
|
+
const fitsRight = rightPos >= 0
|
|
53
|
+
|
|
54
|
+
const useTop = sel(align === 'top', fitsTop, !fitsBottom)
|
|
55
|
+
pos.top = sel(useTop, topPos, bottomPos)
|
|
56
|
+
const resolvedAlignY: AlignY = sel(useTop, 'top', 'bottom')
|
|
57
|
+
|
|
58
|
+
let resolvedAlignX: AlignX = alignX
|
|
59
|
+
if (alignX === 'left') {
|
|
60
|
+
pos.left = sel(fitsLeft, leftPos, rightPos)
|
|
61
|
+
resolvedAlignX = sel(fitsLeft, 'left', 'right')
|
|
62
|
+
} else if (alignX === 'right') {
|
|
63
|
+
pos.left = sel(fitsRight, rightPos, leftPos)
|
|
64
|
+
resolvedAlignX = sel(fitsRight, 'right', 'left')
|
|
65
|
+
} else {
|
|
66
|
+
const center = t.left + (t.right - t.left) / 2 - c.width / 2
|
|
67
|
+
const fitsCL = center >= 0
|
|
68
|
+
const fitsCR = center + c.width <= window.innerWidth
|
|
69
|
+
|
|
70
|
+
if (fitsCL && fitsCR) {
|
|
71
|
+
resolvedAlignX = 'center'
|
|
72
|
+
pos.left = center
|
|
73
|
+
} else if (fitsCL) {
|
|
74
|
+
resolvedAlignX = 'left'
|
|
75
|
+
pos.left = leftPos
|
|
76
|
+
} else if (fitsCR) {
|
|
77
|
+
resolvedAlignX = 'right'
|
|
78
|
+
pos.left = rightPos
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { pos, resolvedAlignX, resolvedAlignY }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const calcDropdownHorizontal = (
|
|
86
|
+
c: DOMRect,
|
|
87
|
+
t: DOMRect,
|
|
88
|
+
align: 'left' | 'right',
|
|
89
|
+
alignY: AlignY,
|
|
90
|
+
offsetX: number,
|
|
91
|
+
offsetY: number,
|
|
92
|
+
): PositionResult => {
|
|
93
|
+
if (typeof window === 'undefined') return { pos: {}, resolvedAlignX: align, resolvedAlignY: alignY }
|
|
94
|
+
const pos: OverlayPosition = {}
|
|
95
|
+
|
|
96
|
+
const leftPos = t.left - offsetX - c.width
|
|
97
|
+
const rightPos = t.right + offsetX
|
|
98
|
+
const topPos = t.top + offsetY
|
|
99
|
+
const bottomPos = t.bottom - offsetY - c.height
|
|
100
|
+
|
|
101
|
+
const fitsLeft = leftPos >= 0
|
|
102
|
+
const fitsRight = rightPos + c.width <= window.innerWidth
|
|
103
|
+
const fitsTop = topPos + c.height <= window.innerHeight
|
|
104
|
+
const fitsBottom = bottomPos >= 0
|
|
105
|
+
|
|
106
|
+
const useLeft = sel(align === 'left', fitsLeft, !fitsRight)
|
|
107
|
+
pos.left = sel(useLeft, leftPos, rightPos)
|
|
108
|
+
const resolvedAlignX: AlignX = sel(useLeft, 'left', 'right')
|
|
109
|
+
|
|
110
|
+
let resolvedAlignY: AlignY = alignY
|
|
111
|
+
if (alignY === 'top') {
|
|
112
|
+
pos.top = sel(fitsTop, topPos, bottomPos)
|
|
113
|
+
resolvedAlignY = sel(fitsTop, 'top', 'bottom')
|
|
114
|
+
} else if (alignY === 'bottom') {
|
|
115
|
+
pos.top = sel(fitsBottom, bottomPos, topPos)
|
|
116
|
+
resolvedAlignY = sel(fitsBottom, 'bottom', 'top')
|
|
117
|
+
} else {
|
|
118
|
+
const center = t.top + (t.bottom - t.top) / 2 - c.height / 2
|
|
119
|
+
const fitsCT = center >= 0
|
|
120
|
+
const fitsCB = center + c.height <= window.innerHeight
|
|
121
|
+
|
|
122
|
+
if (fitsCT && fitsCB) {
|
|
123
|
+
resolvedAlignY = 'center'
|
|
124
|
+
pos.top = center
|
|
125
|
+
} else if (fitsCT) {
|
|
126
|
+
resolvedAlignY = 'top'
|
|
127
|
+
pos.top = topPos
|
|
128
|
+
} else if (fitsCB) {
|
|
129
|
+
resolvedAlignY = 'bottom'
|
|
130
|
+
pos.top = bottomPos
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { pos, resolvedAlignX, resolvedAlignY }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const calcModalPos = (
|
|
138
|
+
c: DOMRect,
|
|
139
|
+
alignX: AlignX,
|
|
140
|
+
alignY: AlignY,
|
|
141
|
+
offsetX: number,
|
|
142
|
+
offsetY: number,
|
|
143
|
+
): OverlayPosition => {
|
|
144
|
+
if (typeof window === 'undefined') return {}
|
|
145
|
+
const pos: OverlayPosition = {}
|
|
146
|
+
|
|
147
|
+
switch (alignX) {
|
|
148
|
+
case 'right':
|
|
149
|
+
pos.right = offsetX
|
|
150
|
+
break
|
|
151
|
+
case 'left':
|
|
152
|
+
pos.left = offsetX
|
|
153
|
+
break
|
|
154
|
+
case 'center':
|
|
155
|
+
pos.left = window.innerWidth / 2 - c.width / 2
|
|
156
|
+
break
|
|
157
|
+
default:
|
|
158
|
+
pos.right = offsetX
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
switch (alignY) {
|
|
162
|
+
case 'top':
|
|
163
|
+
pos.top = offsetY
|
|
164
|
+
break
|
|
165
|
+
case 'center':
|
|
166
|
+
pos.top = window.innerHeight / 2 - c.height / 2
|
|
167
|
+
break
|
|
168
|
+
case 'bottom':
|
|
169
|
+
pos.bottom = offsetY
|
|
170
|
+
break
|
|
171
|
+
default:
|
|
172
|
+
pos.top = offsetY
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return pos
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const adjustForAncestor = (
|
|
179
|
+
pos: OverlayPosition,
|
|
180
|
+
ancestor: { top: number; left: number },
|
|
181
|
+
): OverlayPosition => {
|
|
182
|
+
if (ancestor.top === 0 && ancestor.left === 0) return pos
|
|
183
|
+
|
|
184
|
+
const result = { ...pos }
|
|
185
|
+
if (typeof result.top === 'number') result.top -= ancestor.top
|
|
186
|
+
if (typeof result.bottom === 'number') result.bottom += ancestor.top
|
|
187
|
+
if (typeof result.left === 'number') result.left -= ancestor.left
|
|
188
|
+
if (typeof result.right === 'number') result.right += ancestor.left
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
}
|
|
@@ -12,17 +12,16 @@ import { throttle } from '@pyreon/ui-core'
|
|
|
12
12
|
import { value } from '@pyreon/unistyle'
|
|
13
13
|
import { IS_DEVELOPMENT } from '../utils'
|
|
14
14
|
import Provider, { useOverlayContext } from './context'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type
|
|
24
|
-
|
|
25
|
-
type AlignY = 'bottom' | 'top' | 'center'
|
|
15
|
+
import {
|
|
16
|
+
adjustForAncestor,
|
|
17
|
+
calcDropdownHorizontal,
|
|
18
|
+
calcDropdownVertical,
|
|
19
|
+
calcModalPos,
|
|
20
|
+
type Align,
|
|
21
|
+
type AlignX,
|
|
22
|
+
type AlignY,
|
|
23
|
+
type OverlayPosition,
|
|
24
|
+
} from './positioning'
|
|
26
25
|
|
|
27
26
|
export type UseOverlayProps = Partial<{
|
|
28
27
|
isOpen: boolean
|
|
@@ -44,179 +43,15 @@ export type UseOverlayProps = Partial<{
|
|
|
44
43
|
onClose: () => void
|
|
45
44
|
}>
|
|
46
45
|
|
|
47
|
-
type PositionResult = {
|
|
48
|
-
pos: OverlayPosition
|
|
49
|
-
resolvedAlignX: AlignX
|
|
50
|
-
resolvedAlignY: AlignY
|
|
51
|
-
}
|
|
52
|
-
|
|
53
46
|
// Reference counter for nested modals sharing document.body overflow lock.
|
|
54
47
|
let modalOverflowCount = 0
|
|
55
48
|
|
|
56
|
-
const sel = <T,>(cond: boolean, a: T, b: T): T => (cond ? a : b)
|
|
57
|
-
|
|
58
49
|
const devWarn = (msg: string) => {
|
|
59
50
|
if (!IS_DEVELOPMENT) return
|
|
60
51
|
// oxlint-disable-next-line no-console
|
|
61
52
|
console.warn(msg)
|
|
62
53
|
}
|
|
63
54
|
|
|
64
|
-
const calcDropdownVertical = (
|
|
65
|
-
c: DOMRect,
|
|
66
|
-
t: DOMRect,
|
|
67
|
-
align: 'top' | 'bottom',
|
|
68
|
-
alignX: AlignX,
|
|
69
|
-
offsetX: number,
|
|
70
|
-
offsetY: number,
|
|
71
|
-
): PositionResult => {
|
|
72
|
-
const pos: OverlayPosition = {}
|
|
73
|
-
|
|
74
|
-
const topPos = t.top - offsetY - c.height
|
|
75
|
-
const bottomPos = t.bottom + offsetY
|
|
76
|
-
const leftPos = t.left + offsetX
|
|
77
|
-
const rightPos = t.right - offsetX - c.width
|
|
78
|
-
|
|
79
|
-
const fitsTop = topPos >= 0
|
|
80
|
-
const fitsBottom = bottomPos + c.height <= window.innerHeight
|
|
81
|
-
const fitsLeft = leftPos + c.width <= window.innerWidth
|
|
82
|
-
const fitsRight = rightPos >= 0
|
|
83
|
-
|
|
84
|
-
const useTop = sel(align === 'top', fitsTop, !fitsBottom)
|
|
85
|
-
pos.top = sel(useTop, topPos, bottomPos)
|
|
86
|
-
const resolvedAlignY: AlignY = sel(useTop, 'top', 'bottom')
|
|
87
|
-
|
|
88
|
-
let resolvedAlignX: AlignX = alignX
|
|
89
|
-
if (alignX === 'left') {
|
|
90
|
-
pos.left = sel(fitsLeft, leftPos, rightPos)
|
|
91
|
-
resolvedAlignX = sel(fitsLeft, 'left', 'right')
|
|
92
|
-
} else if (alignX === 'right') {
|
|
93
|
-
pos.left = sel(fitsRight, rightPos, leftPos)
|
|
94
|
-
resolvedAlignX = sel(fitsRight, 'right', 'left')
|
|
95
|
-
} else {
|
|
96
|
-
const center = t.left + (t.right - t.left) / 2 - c.width / 2
|
|
97
|
-
const fitsCL = center >= 0
|
|
98
|
-
const fitsCR = center + c.width <= window.innerWidth
|
|
99
|
-
|
|
100
|
-
if (fitsCL && fitsCR) {
|
|
101
|
-
resolvedAlignX = 'center'
|
|
102
|
-
pos.left = center
|
|
103
|
-
} else if (fitsCL) {
|
|
104
|
-
resolvedAlignX = 'left'
|
|
105
|
-
pos.left = leftPos
|
|
106
|
-
} else if (fitsCR) {
|
|
107
|
-
resolvedAlignX = 'right'
|
|
108
|
-
pos.left = rightPos
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return { pos, resolvedAlignX, resolvedAlignY }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const calcDropdownHorizontal = (
|
|
116
|
-
c: DOMRect,
|
|
117
|
-
t: DOMRect,
|
|
118
|
-
align: 'left' | 'right',
|
|
119
|
-
alignY: AlignY,
|
|
120
|
-
offsetX: number,
|
|
121
|
-
offsetY: number,
|
|
122
|
-
): PositionResult => {
|
|
123
|
-
const pos: OverlayPosition = {}
|
|
124
|
-
|
|
125
|
-
const leftPos = t.left - offsetX - c.width
|
|
126
|
-
const rightPos = t.right + offsetX
|
|
127
|
-
const topPos = t.top + offsetY
|
|
128
|
-
const bottomPos = t.bottom - offsetY - c.height
|
|
129
|
-
|
|
130
|
-
const fitsLeft = leftPos >= 0
|
|
131
|
-
const fitsRight = rightPos + c.width <= window.innerWidth
|
|
132
|
-
const fitsTop = topPos + c.height <= window.innerHeight
|
|
133
|
-
const fitsBottom = bottomPos >= 0
|
|
134
|
-
|
|
135
|
-
const useLeft = sel(align === 'left', fitsLeft, !fitsRight)
|
|
136
|
-
pos.left = sel(useLeft, leftPos, rightPos)
|
|
137
|
-
const resolvedAlignX: AlignX = sel(useLeft, 'left', 'right')
|
|
138
|
-
|
|
139
|
-
let resolvedAlignY: AlignY = alignY
|
|
140
|
-
if (alignY === 'top') {
|
|
141
|
-
pos.top = sel(fitsTop, topPos, bottomPos)
|
|
142
|
-
resolvedAlignY = sel(fitsTop, 'top', 'bottom')
|
|
143
|
-
} else if (alignY === 'bottom') {
|
|
144
|
-
pos.top = sel(fitsBottom, bottomPos, topPos)
|
|
145
|
-
resolvedAlignY = sel(fitsBottom, 'bottom', 'top')
|
|
146
|
-
} else {
|
|
147
|
-
const center = t.top + (t.bottom - t.top) / 2 - c.height / 2
|
|
148
|
-
const fitsCT = center >= 0
|
|
149
|
-
const fitsCB = center + c.height <= window.innerHeight
|
|
150
|
-
|
|
151
|
-
if (fitsCT && fitsCB) {
|
|
152
|
-
resolvedAlignY = 'center'
|
|
153
|
-
pos.top = center
|
|
154
|
-
} else if (fitsCT) {
|
|
155
|
-
resolvedAlignY = 'top'
|
|
156
|
-
pos.top = topPos
|
|
157
|
-
} else if (fitsCB) {
|
|
158
|
-
resolvedAlignY = 'bottom'
|
|
159
|
-
pos.top = bottomPos
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return { pos, resolvedAlignX, resolvedAlignY }
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const calcModalPos = (
|
|
167
|
-
c: DOMRect,
|
|
168
|
-
alignX: AlignX,
|
|
169
|
-
alignY: AlignY,
|
|
170
|
-
offsetX: number,
|
|
171
|
-
offsetY: number,
|
|
172
|
-
): OverlayPosition => {
|
|
173
|
-
const pos: OverlayPosition = {}
|
|
174
|
-
|
|
175
|
-
switch (alignX) {
|
|
176
|
-
case 'right':
|
|
177
|
-
pos.right = offsetX
|
|
178
|
-
break
|
|
179
|
-
case 'left':
|
|
180
|
-
pos.left = offsetX
|
|
181
|
-
break
|
|
182
|
-
case 'center':
|
|
183
|
-
pos.left = window.innerWidth / 2 - c.width / 2
|
|
184
|
-
break
|
|
185
|
-
default:
|
|
186
|
-
pos.right = offsetX
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
switch (alignY) {
|
|
190
|
-
case 'top':
|
|
191
|
-
pos.top = offsetY
|
|
192
|
-
break
|
|
193
|
-
case 'center':
|
|
194
|
-
pos.top = window.innerHeight / 2 - c.height / 2
|
|
195
|
-
break
|
|
196
|
-
case 'bottom':
|
|
197
|
-
pos.bottom = offsetY
|
|
198
|
-
break
|
|
199
|
-
default:
|
|
200
|
-
pos.top = offsetY
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return pos
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const adjustForAncestor = (
|
|
207
|
-
pos: OverlayPosition,
|
|
208
|
-
ancestor: { top: number; left: number },
|
|
209
|
-
): OverlayPosition => {
|
|
210
|
-
if (ancestor.top === 0 && ancestor.left === 0) return pos
|
|
211
|
-
|
|
212
|
-
const result = { ...pos }
|
|
213
|
-
if (typeof result.top === 'number') result.top -= ancestor.top
|
|
214
|
-
if (typeof result.bottom === 'number') result.bottom += ancestor.top
|
|
215
|
-
if (typeof result.left === 'number') result.left -= ancestor.left
|
|
216
|
-
if (typeof result.right === 'number') result.right += ancestor.left
|
|
217
|
-
|
|
218
|
-
return result
|
|
219
|
-
}
|
|
220
55
|
|
|
221
56
|
type ComputeResult = {
|
|
222
57
|
pos: OverlayPosition
|
|
@@ -374,6 +209,7 @@ const useOverlay = ({
|
|
|
374
209
|
|
|
375
210
|
// Position calculation helpers
|
|
376
211
|
const getAncestorOffset = () => {
|
|
212
|
+
if (typeof document === 'undefined') return { top: 0, left: 0 }
|
|
377
213
|
if (position !== 'absolute' || !contentEl) {
|
|
378
214
|
return { top: 0, left: 0 }
|
|
379
215
|
}
|
|
@@ -460,6 +296,7 @@ const useOverlay = ({
|
|
|
460
296
|
// Set up all event listeners on mount, clean up on unmount
|
|
461
297
|
// --------------------------------------------------------------------------
|
|
462
298
|
const setupListeners = () => {
|
|
299
|
+
if (typeof window === 'undefined') return () => {}
|
|
463
300
|
const cleanups: (() => void)[] = []
|
|
464
301
|
|
|
465
302
|
// Click-based open/close
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** @jsxImportSource @pyreon/core */
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { signal } from '@pyreon/reactivity'
|
|
4
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
5
|
+
import { Element } from '../Element'
|
|
6
|
+
import { Portal } from '../Portal'
|
|
7
|
+
import { Text } from '../Text'
|
|
8
|
+
|
|
9
|
+
describe('@pyreon/elements browser smoke', () => {
|
|
10
|
+
it('Element mounts into real DOM with structural rendering', () => {
|
|
11
|
+
const { container, unmount } = mountInBrowser(
|
|
12
|
+
<Element tag="div" data-id="el"><span>hello</span></Element>,
|
|
13
|
+
)
|
|
14
|
+
const el = container.querySelector('[data-id="el"]')
|
|
15
|
+
expect(el?.tagName.toLowerCase()).toBe('div')
|
|
16
|
+
expect(el?.querySelector('span')?.textContent).toBe('hello')
|
|
17
|
+
unmount()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('Element forwards a reactive text child to the DOM', async () => {
|
|
21
|
+
const label = signal('hello')
|
|
22
|
+
const { container, unmount } = mountInBrowser(
|
|
23
|
+
<Element tag="div">
|
|
24
|
+
<span data-id="lbl">{() => label()}</span>
|
|
25
|
+
</Element>,
|
|
26
|
+
)
|
|
27
|
+
const lbl = container.querySelector('[data-id="lbl"]')
|
|
28
|
+
expect(lbl?.textContent).toBe('hello')
|
|
29
|
+
label.set('world')
|
|
30
|
+
await flush()
|
|
31
|
+
expect(lbl?.textContent).toBe('world')
|
|
32
|
+
unmount()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('Text renders as inline element', () => {
|
|
36
|
+
const { container, unmount } = mountInBrowser(<Text tag="span" data-id="t">hi</Text>)
|
|
37
|
+
const el = container.querySelector('[data-id="t"]')
|
|
38
|
+
expect(el?.tagName.toLowerCase()).toBe('span')
|
|
39
|
+
expect(el?.textContent).toBe('hi')
|
|
40
|
+
unmount()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('Portal projects children to document.body by default', () => {
|
|
44
|
+
const { unmount } = mountInBrowser(
|
|
45
|
+
<Portal>
|
|
46
|
+
<div data-portal-id="p">portal-content</div>
|
|
47
|
+
</Portal>,
|
|
48
|
+
)
|
|
49
|
+
const projected = document.querySelector('[data-portal-id="p"]')
|
|
50
|
+
expect(projected?.textContent).toBe('portal-content')
|
|
51
|
+
unmount()
|
|
52
|
+
expect(document.querySelector('[data-portal-id="p"]')).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('runs in a real browser — `typeof process` is undefined, `import.meta.env.DEV` is true', () => {
|
|
56
|
+
expect(typeof process).toBe('undefined')
|
|
57
|
+
expect(import.meta.env.DEV).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
adjustForAncestor,
|
|
4
|
+
calcDropdownHorizontal,
|
|
5
|
+
calcDropdownVertical,
|
|
6
|
+
calcModalPos,
|
|
7
|
+
} from '../Overlay/positioning'
|
|
8
|
+
|
|
9
|
+
const rect = (top: number, left: number, width: number, height: number): DOMRect => ({
|
|
10
|
+
top,
|
|
11
|
+
left,
|
|
12
|
+
right: left + width,
|
|
13
|
+
bottom: top + height,
|
|
14
|
+
width,
|
|
15
|
+
height,
|
|
16
|
+
x: left,
|
|
17
|
+
y: top,
|
|
18
|
+
toJSON: () => ({ top, left, width, height }),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('positioning helpers — browser path (happy-dom)', () => {
|
|
22
|
+
it('calcDropdownVertical: fits below → positions under the trigger', () => {
|
|
23
|
+
const trigger = rect(100, 200, 80, 30) // top=100
|
|
24
|
+
const content = rect(0, 0, 60, 40)
|
|
25
|
+
const result = calcDropdownVertical(content, trigger, 'bottom', 'left', 0, 4)
|
|
26
|
+
expect(result.resolvedAlignY).toBe('bottom')
|
|
27
|
+
expect(result.pos.top).toBe(trigger.bottom + 4)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('calcDropdownHorizontal: flips to right when left does not fit', () => {
|
|
31
|
+
const trigger = rect(100, 10, 80, 30) // near left edge
|
|
32
|
+
const content = rect(0, 0, 120, 40) // wider than trigger.left
|
|
33
|
+
const result = calcDropdownHorizontal(content, trigger, 'left', 'top', 0, 0)
|
|
34
|
+
expect(result.resolvedAlignX).toBe('right')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('calcModalPos: centers horizontally and vertically', () => {
|
|
38
|
+
const content = rect(0, 0, 400, 300)
|
|
39
|
+
const pos = calcModalPos(content, 'center', 'center', 0, 0)
|
|
40
|
+
expect(typeof pos.left).toBe('number')
|
|
41
|
+
expect(typeof pos.top).toBe('number')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('adjustForAncestor: subtracts ancestor offset from absolute positions', () => {
|
|
45
|
+
const adjusted = adjustForAncestor({ top: 100, left: 200 }, { top: 30, left: 50 })
|
|
46
|
+
expect(adjusted.top).toBe(70)
|
|
47
|
+
expect(adjusted.left).toBe(150)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('adjustForAncestor: returns input unchanged when ancestor is origin', () => {
|
|
51
|
+
const input = { top: 10, left: 20 }
|
|
52
|
+
expect(adjustForAncestor(input, { top: 0, left: 0 })).toBe(input)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// SSR-fallback path: the positioning helpers are only reachable via mounted
|
|
57
|
+
// event handlers in production, so `typeof window === 'undefined'` never
|
|
58
|
+
// fires in browser tests. We force the fallback by stubbing `globalThis.window`
|
|
59
|
+
// to `undefined` for the duration of a single test. happy-dom resets on
|
|
60
|
+
// teardown; we restore the original window afterwards.
|
|
61
|
+
describe('positioning helpers — SSR fallback (window undefined)', () => {
|
|
62
|
+
const realWindow = globalThis.window
|
|
63
|
+
const realWindowDesc = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
if (realWindowDesc) Object.defineProperty(globalThis, 'window', realWindowDesc)
|
|
67
|
+
else (globalThis as { window?: unknown }).window = realWindow
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('calcDropdownVertical returns alignment-preserving empty fallback', () => {
|
|
71
|
+
Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true, writable: true })
|
|
72
|
+
const result = calcDropdownVertical(rect(0, 0, 60, 40), rect(100, 200, 80, 30), 'bottom', 'left', 0, 0)
|
|
73
|
+
expect(result.pos).toEqual({})
|
|
74
|
+
expect(result.resolvedAlignX).toBe('left')
|
|
75
|
+
expect(result.resolvedAlignY).toBe('bottom')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('calcDropdownHorizontal returns alignment-preserving empty fallback', () => {
|
|
79
|
+
Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true, writable: true })
|
|
80
|
+
const result = calcDropdownHorizontal(rect(0, 0, 60, 40), rect(100, 200, 80, 30), 'right', 'top', 0, 0)
|
|
81
|
+
expect(result.pos).toEqual({})
|
|
82
|
+
expect(result.resolvedAlignX).toBe('right')
|
|
83
|
+
expect(result.resolvedAlignY).toBe('top')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('calcModalPos returns empty object', () => {
|
|
87
|
+
Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true, writable: true })
|
|
88
|
+
expect(calcModalPos(rect(0, 0, 400, 300), 'center', 'center', 0, 0)).toEqual({})
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* fix (parent + child Styled) because these HTML elements do not natively
|
|
5
5
|
* support `display: flex` consistently across browsers.
|
|
6
6
|
*/
|
|
7
|
+
import { splitProps } from '@pyreon/core'
|
|
7
8
|
import { IS_DEVELOPMENT } from '../../utils'
|
|
8
9
|
import Styled from './styled'
|
|
9
10
|
import type { Props } from './types'
|
|
@@ -11,65 +12,76 @@ import { isWebFixNeeded } from './utils'
|
|
|
11
12
|
|
|
12
13
|
const DEV_PROPS: Record<string, string> = IS_DEVELOPMENT ? { 'data-pyr-element': 'Element' } : {}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ref,
|
|
31
|
-
as: tag,
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const needsFix = !props.dangerouslySetInnerHTML && isWebFixNeeded(tag)
|
|
15
|
+
// Layout / ref keys consumed by Wrapper itself. Everything else is forwarded
|
|
16
|
+
// onto the underlying DOM node. Listed as a tuple so `splitProps` narrows
|
|
17
|
+
// `own` correctly while preserving reactive prop tracking on both halves.
|
|
18
|
+
const OWN_KEYS: Array<keyof Props | 'ref'> = [
|
|
19
|
+
'children',
|
|
20
|
+
'tag',
|
|
21
|
+
'block',
|
|
22
|
+
'extendCss',
|
|
23
|
+
'direction',
|
|
24
|
+
'alignX',
|
|
25
|
+
'alignY',
|
|
26
|
+
'equalCols',
|
|
27
|
+
'isInline',
|
|
28
|
+
'ref',
|
|
29
|
+
'dangerouslySetInnerHTML',
|
|
30
|
+
]
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
direction,
|
|
39
|
-
alignX,
|
|
40
|
-
alignY,
|
|
41
|
-
equalCols,
|
|
42
|
-
extraStyles: extendCss,
|
|
43
|
-
}
|
|
32
|
+
const Component = (props: Partial<Props> & { ref?: unknown }) => {
|
|
33
|
+
const [own, rest] = splitProps(props, OWN_KEYS)
|
|
44
34
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
const commonProps = {
|
|
36
|
+
...rest,
|
|
37
|
+
...DEV_PROPS,
|
|
38
|
+
ref: own.ref,
|
|
39
|
+
as: own.tag,
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
const
|
|
52
|
-
childFix: true as const,
|
|
53
|
-
direction,
|
|
54
|
-
alignX,
|
|
55
|
-
alignY,
|
|
56
|
-
equalCols,
|
|
57
|
-
}
|
|
42
|
+
const needsFix = !own.dangerouslySetInnerHTML && isWebFixNeeded(own.tag)
|
|
58
43
|
|
|
59
44
|
if (!needsFix) {
|
|
60
45
|
return (
|
|
61
|
-
<Styled
|
|
62
|
-
{
|
|
46
|
+
<Styled
|
|
47
|
+
{...commonProps}
|
|
48
|
+
$element={{
|
|
49
|
+
block: own.block,
|
|
50
|
+
direction: own.direction,
|
|
51
|
+
alignX: own.alignX,
|
|
52
|
+
alignY: own.alignY,
|
|
53
|
+
equalCols: own.equalCols,
|
|
54
|
+
extraStyles: own.extendCss,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{own.children}
|
|
63
58
|
</Styled>
|
|
64
59
|
)
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
const asTag = isInline ? 'span' : 'div'
|
|
62
|
+
const asTag = own.isInline ? 'span' : 'div'
|
|
68
63
|
|
|
69
64
|
return (
|
|
70
|
-
<Styled
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
<Styled
|
|
66
|
+
{...commonProps}
|
|
67
|
+
$element={{
|
|
68
|
+
parentFix: true as const,
|
|
69
|
+
block: own.block,
|
|
70
|
+
extraStyles: own.extendCss,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Styled
|
|
74
|
+
as={asTag}
|
|
75
|
+
$childFix
|
|
76
|
+
$element={{
|
|
77
|
+
childFix: true as const,
|
|
78
|
+
direction: own.direction,
|
|
79
|
+
alignX: own.alignX,
|
|
80
|
+
alignY: own.alignY,
|
|
81
|
+
equalCols: own.equalCols,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{own.children}
|
|
73
85
|
</Styled>
|
|
74
86
|
</Styled>
|
|
75
87
|
)
|