Skip to content

Commit 15cfe31

Browse files
xerialclaude
andauthored
feature: Add WindowScroll, WindowVisibility, and WindowDimensions to uni-dom (#400)
## Summary - Add `WindowScroll` for reactive scroll position tracking with 16ms throttling (~60fps) - Add `WindowVisibility` for document visibility state (tab switching, minimizing) - Add `WindowDimensions` for viewport and window size tracking on resize All components follow the established `NetworkStatus` pattern with: - Lazy singleton instances - Proper cleanup via `stop()` - Reactive `Rx` streams for integration with uni-dom's reactive system ## Test plan - [x] `./sbt compile` passes - [x] `./sbt "domTest/test"` passes (172 tests) - [x] `./sbt scalafmtAll` applied 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 96c4699 commit 15cfe31

8 files changed

Lines changed: 824 additions & 0 deletions

File tree

plans/wobbly-knitting-diffie.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# uni-dom Window Events Implementation Plan
2+
3+
## Overview
4+
5+
Add reactive window event bindings for scroll tracking, visibility state, and window dimensions to uni-dom. Follows the established patterns from MediaQuery and NetworkStatus.
6+
7+
## Components
8+
9+
### 1. WindowScroll - Scroll Position Tracking
10+
11+
Reactive scroll position with built-in throttling for performance.
12+
13+
**API:**
14+
```scala
15+
object WindowScroll:
16+
def x: Double // Current horizontal scroll
17+
def y: Double // Current vertical scroll
18+
def scrollX: Rx[Double] // Reactive horizontal position
19+
def scrollY: Rx[Double] // Reactive vertical position
20+
def scroll: Rx[(Double, Double)] // Combined (x, y) stream
21+
def stop(): Unit // Cleanup
22+
```
23+
24+
**Usage:**
25+
```scala
26+
// Show "back to top" button when scrolled
27+
div(
28+
WindowScroll.scrollY.map { y =>
29+
if y > 500 then button("Back to Top") else DomNode.empty
30+
}
31+
)
32+
```
33+
34+
**Implementation notes:**
35+
- Uses 16ms throttle (~60fps) for performance
36+
- Leading + trailing edge throttling ensures final position is captured
37+
- Private inner class with lazy val singleton
38+
39+
### 2. WindowVisibility - Document Visibility State
40+
41+
Track whether the page is visible or hidden (tab switching, minimizing).
42+
43+
**API:**
44+
```scala
45+
object WindowVisibility:
46+
def isVisible: Boolean // Current visibility
47+
def state: String // "visible", "hidden", etc.
48+
def visible: Rx[Boolean] // Reactive visibility
49+
def hidden: Rx[Boolean] // Inverse of visible
50+
def visibilityState: Rx[String] // Reactive state string
51+
def stop(): Unit // Cleanup
52+
```
53+
54+
**Usage:**
55+
```scala
56+
// Pause video when tab is hidden
57+
WindowVisibility.visible.run { visible =>
58+
if visible then video.play() else video.pause()
59+
}
60+
```
61+
62+
### 3. WindowDimensions - Window Size Tracking
63+
64+
Reactive window dimensions with resize tracking.
65+
66+
**API:**
67+
```scala
68+
object WindowDimensions:
69+
def innerWidth: Int // Viewport width
70+
def innerHeight: Int // Viewport height
71+
def outerWidth: Int // Full window width
72+
def outerHeight: Int // Full window height
73+
def rxInnerWidth: Rx[Int] // Reactive viewport width
74+
def rxInnerHeight: Rx[Int] // Reactive viewport height
75+
def rxOuterWidth: Rx[Int] // Reactive window width
76+
def rxOuterHeight: Rx[Int] // Reactive window height
77+
def dimensions: Rx[(Int, Int)] // Combined (width, height)
78+
def stop(): Unit // Cleanup
79+
```
80+
81+
**Usage:**
82+
```scala
83+
// Responsive layout without MediaQuery
84+
div(
85+
WindowDimensions.rxInnerWidth.map { width =>
86+
if width < 768 then "mobile-layout" else "desktop-layout"
87+
}
88+
)
89+
```
90+
91+
## Files to Create
92+
93+
| File | Description |
94+
|------|-------------|
95+
| `uni/.js/src/main/scala/wvlet/uni/dom/WindowScroll.scala` | Scroll position tracking |
96+
| `uni/.js/src/main/scala/wvlet/uni/dom/WindowVisibility.scala` | Document visibility state |
97+
| `uni/.js/src/main/scala/wvlet/uni/dom/WindowDimensions.scala` | Window dimensions tracking |
98+
| `uni-dom-test/src/test/scala/wvlet/uni/dom/WindowScrollTest.scala` | Scroll tests |
99+
| `uni-dom-test/src/test/scala/wvlet/uni/dom/WindowVisibilityTest.scala` | Visibility tests |
100+
| `uni-dom-test/src/test/scala/wvlet/uni/dom/WindowDimensionsTest.scala` | Dimensions tests |
101+
102+
## Files to Modify
103+
104+
| File | Changes |
105+
|------|---------|
106+
| `uni/.js/src/main/scala/wvlet/uni/dom/all.scala` | Add exports for WindowScroll, WindowVisibility, WindowDimensions |
107+
108+
## Implementation Pattern
109+
110+
Follow the NetworkStatus pattern:
111+
112+
```scala
113+
object WindowXxx:
114+
private class XxxVar extends Cancelable:
115+
private val underlying = Rx.variable(initialValue)
116+
private val listener: js.Function1[dom.Event, Unit] = (_: dom.Event) =>
117+
underlying := newValue
118+
119+
dom.window.addEventListener("event", listener)
120+
121+
def get: T = underlying.get
122+
def rx: Rx[T] = underlying
123+
124+
override def cancel: Unit =
125+
dom.window.removeEventListener("event", listener)
126+
127+
private lazy val instance: XxxVar = XxxVar()
128+
129+
def value: T = instance.get
130+
def rxValue: Rx[T] = instance.rx
131+
def stop(): Unit = instance.cancel
132+
```
133+
134+
## Scroll Throttling Strategy
135+
136+
```scala
137+
private var lastUpdateTime: Double = 0
138+
private var scheduledUpdate: js.UndefOr[Int] = js.undefined
139+
140+
private val listener: js.Function1[dom.Event, Unit] = (_: dom.Event) =>
141+
val now = js.Date.now()
142+
if now - lastUpdateTime >= throttleMs then
143+
// Leading edge: update immediately
144+
lastUpdateTime = now
145+
updateValues()
146+
else if scheduledUpdate.isEmpty then
147+
// Trailing edge: schedule final update
148+
scheduledUpdate = dom.window.setTimeout(
149+
() => {
150+
scheduledUpdate = js.undefined
151+
lastUpdateTime = js.Date.now()
152+
updateValues()
153+
},
154+
throttleMs - (now - lastUpdateTime).toInt
155+
)
156+
```
157+
158+
## Implementation Order
159+
160+
1. WindowVisibility (simplest, follows NetworkStatus exactly)
161+
2. WindowDimensions (similar, just more RxVars)
162+
3. WindowScroll (most complex due to throttling)
163+
4. Update all.scala exports
164+
5. Create tests
165+
166+
## Verification
167+
168+
```bash
169+
# In worktree
170+
cd /Users/leo/work/uni/.worktree/feature-window-events
171+
172+
# Compile
173+
./sbt compile
174+
175+
# Run tests
176+
./sbt "domTestJS/test"
177+
178+
# Format
179+
./sbt scalafmtAll
180+
```
181+
182+
## References
183+
184+
Pattern files to follow:
185+
- `uni/.js/src/main/scala/wvlet/uni/dom/NetworkStatus.scala` - Primary pattern
186+
- `uni/.js/src/main/scala/wvlet/uni/dom/MediaQuery.scala` - Secondary pattern
187+
- `uni-dom-test/src/test/scala/wvlet/uni/dom/NetworkStatusTest.scala` - Test pattern
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package wvlet.uni.dom
15+
16+
import wvlet.uni.test.UniTest
17+
import wvlet.uni.dom.all.*
18+
import wvlet.uni.rx.Rx
19+
20+
class WindowDimensionsTest extends UniTest:
21+
22+
test("WindowDimensions.innerWidth returns current viewport width"):
23+
val width = WindowDimensions.innerWidth
24+
width shouldMatch { case _: Int =>
25+
}
26+
27+
test("WindowDimensions.innerHeight returns current viewport height"):
28+
val height = WindowDimensions.innerHeight
29+
height shouldMatch { case _: Int =>
30+
}
31+
32+
test("WindowDimensions.outerWidth returns current window width"):
33+
val width = WindowDimensions.outerWidth
34+
width shouldMatch { case _: Int =>
35+
}
36+
37+
test("WindowDimensions.outerHeight returns current window height"):
38+
val height = WindowDimensions.outerHeight
39+
height shouldMatch { case _: Int =>
40+
}
41+
42+
test("WindowDimensions.rxInnerWidth returns Rx[Int]"):
43+
val rx = WindowDimensions.rxInnerWidth
44+
rx shouldMatch { case _: Rx[?] =>
45+
}
46+
47+
test("WindowDimensions.rxInnerHeight returns Rx[Int]"):
48+
val rx = WindowDimensions.rxInnerHeight
49+
rx shouldMatch { case _: Rx[?] =>
50+
}
51+
52+
test("WindowDimensions.rxOuterWidth returns Rx[Int]"):
53+
val rx = WindowDimensions.rxOuterWidth
54+
rx shouldMatch { case _: Rx[?] =>
55+
}
56+
57+
test("WindowDimensions.rxOuterHeight returns Rx[Int]"):
58+
val rx = WindowDimensions.rxOuterHeight
59+
rx shouldMatch { case _: Rx[?] =>
60+
}
61+
62+
test("WindowDimensions.dimensions returns Rx[(Int, Int)]"):
63+
val rx = WindowDimensions.dimensions
64+
rx shouldMatch { case _: Rx[?] =>
65+
}
66+
67+
test("WindowDimensions can be used in reactive expressions"):
68+
val sizeText = WindowDimensions
69+
.rxInnerWidth
70+
.map { width =>
71+
if width < 768 then
72+
"mobile"
73+
else
74+
"desktop"
75+
}
76+
77+
var result = ""
78+
sizeText.run { v =>
79+
result = v
80+
}
81+
82+
(result == "mobile" || result == "desktop") shouldBe true
83+
84+
test("WindowDimensions.dimensions provides both width and height"):
85+
var widthResult = 0
86+
var heightResult = 0
87+
88+
WindowDimensions
89+
.dimensions
90+
.run { case (w, h) =>
91+
widthResult = w
92+
heightResult = h
93+
}
94+
95+
(widthResult >= 0) shouldBe true
96+
(heightResult >= 0) shouldBe true
97+
98+
end WindowDimensionsTest
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package wvlet.uni.dom
15+
16+
import wvlet.uni.test.UniTest
17+
import wvlet.uni.dom.all.*
18+
import wvlet.uni.rx.Rx
19+
20+
class WindowScrollTest extends UniTest:
21+
22+
test("WindowScroll.x returns current horizontal scroll position"):
23+
val x = WindowScroll.x
24+
x shouldMatch { case _: Double =>
25+
}
26+
27+
test("WindowScroll.y returns current vertical scroll position"):
28+
val y = WindowScroll.y
29+
y shouldMatch { case _: Double =>
30+
}
31+
32+
test("WindowScroll.scrollX returns Rx[Double]"):
33+
val rx = WindowScroll.scrollX
34+
rx shouldMatch { case _: Rx[?] =>
35+
}
36+
37+
test("WindowScroll.scrollY returns Rx[Double]"):
38+
val rx = WindowScroll.scrollY
39+
rx shouldMatch { case _: Rx[?] =>
40+
}
41+
42+
test("WindowScroll.scroll returns Rx[(Double, Double)]"):
43+
val rx = WindowScroll.scroll
44+
rx shouldMatch { case _: Rx[?] =>
45+
}
46+
47+
test("WindowScroll can be used in reactive expressions"):
48+
val showBackToTop = WindowScroll
49+
.scrollY
50+
.map { y =>
51+
y > 500
52+
}
53+
54+
var result = false
55+
showBackToTop.run { v =>
56+
result = v
57+
}
58+
59+
result shouldMatch { case _: Boolean =>
60+
}
61+
62+
test("WindowScroll.scroll provides both x and y positions"):
63+
var xResult = 0.0
64+
var yResult = 0.0
65+
66+
WindowScroll
67+
.scroll
68+
.run { case (x, y) =>
69+
xResult = x
70+
yResult = y
71+
}
72+
73+
// In jsdom, scroll positions start at 0
74+
(xResult >= 0) shouldBe true
75+
(yResult >= 0) shouldBe true
76+
77+
end WindowScrollTest

0 commit comments

Comments
 (0)