Skip to content

Commit 165f5f9

Browse files
karngyanclaude
andauthored
feat: let the pointer reach and click a marker's preview (#58)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 11bf1bd commit 165f5f9

4 files changed

Lines changed: 97 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@karnstack/kino": minor
3+
---
4+
5+
A marker's rich preview can now be reached and clicked. It stays open while the pointer moves from the marker up into the card, and a click on the card does what a click on the marker does.

‎src/styles/kino.css‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,20 @@
376376
.kino .kino-preview-content {
377377
min-width: 0;
378378
}
379+
/* A clickable rich preview takes the pointer. Its ::after fills the gap down
380+
to the markers, so moving from a marker up into the card never leaves both. */
381+
.kino .kino-preview.kino-preview-clickable {
382+
pointer-events: auto;
383+
cursor: pointer;
384+
}
385+
.kino .kino-preview.kino-preview-clickable::after {
386+
content: "";
387+
position: absolute;
388+
left: 0;
389+
right: 0;
390+
top: 100%;
391+
height: 24px;
392+
}
379393
.kino .kino-preview-img {
380394
border-radius: 6px;
381395
background-repeat: no-repeat;

‎src/ui/scrubber.test.tsx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,45 @@ test("a rich preview above an icon marker still rises above the icon", () => {
285285
expect(cls.contains("kino-preview-rich")).toBe(true)
286286
expect(cls.contains("kino-preview-above-icon")).toBe(true)
287287
})
288+
289+
test("a rich preview stays open when the pointer moves from the marker into it, and a click opens the marker", () => {
290+
const clicks: string[] = []
291+
const { provider } = withMarkers(
292+
[{ id: "a", time: 50, label: "Lee: gzip", preview: <b>rich</b> }],
293+
(id) => clicks.push(id),
294+
)
295+
stubRect(screen.getByTestId("kino-track"))
296+
const marker = screen.getByTestId("kino-marker")
297+
hoverMarker(marker)
298+
const preview = document.querySelector(".kino-preview")!
299+
expect(preview.classList.contains("kino-preview-clickable")).toBe(true)
300+
pointerOut(marker, preview)
301+
expect(document.querySelector(".kino-preview-rich")).toBeTruthy()
302+
fireEvent.pointerDown(preview)
303+
fireEvent.click(preview)
304+
expect(clicks).toEqual(["a"])
305+
expect(provider.getState().currentTime).toBe(0)
306+
})
307+
308+
test("leaving a rich preview for somewhere else closes it", () => {
309+
withMarkers(
310+
[{ id: "a", time: 50, label: "Lee: gzip", preview: <b>rich</b> }],
311+
() => {},
312+
)
313+
stubRect(screen.getByTestId("kino-track"))
314+
const marker = screen.getByTestId("kino-marker")
315+
hoverMarker(marker)
316+
const preview = document.querySelector(".kino-preview")!
317+
pointerOut(marker, preview)
318+
pointerOut(preview, screen.getByTestId("kino-track"))
319+
expect(document.querySelector(".kino-preview-rich")).toBeNull()
320+
})
321+
322+
// jsdom has no PointerEvent, so fireEvent drops relatedTarget. A MouseEvent keeps it.
323+
function pointerOut(from: Element, to: Element) {
324+
act(() => {
325+
from.dispatchEvent(
326+
new MouseEvent("pointerout", { bubbles: true, relatedTarget: to }),
327+
)
328+
})
329+
}

‎src/ui/scrubber.tsx‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export function Scrubber() {
7575
}
7676

7777
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
78+
// Over a rich preview the card holds still, so the pointer can reach it.
79+
if (previewRef.current?.contains(e.target as Node)) return
7880
const rect = trackRef.current?.getBoundingClientRect()
7981
if (!rect) return
8082
setHover({ x: e.clientX - rect.left, time: timeFromClientX(e.clientX) })
@@ -105,6 +107,7 @@ export function Scrubber() {
105107
const previewClass = [
106108
"kino-preview kino-glass",
107109
rich != null && "kino-preview-rich",
110+
rich != null && onMarkerClick && "kino-preview-clickable",
108111
shown.some((m) => m.icon) && "kino-preview-above-icon",
109112
]
110113
.filter(Boolean)
@@ -125,6 +128,24 @@ export function Scrubber() {
125128
ref={previewRef}
126129
className={previewClass}
127130
style={{ left: previewLeft }}
131+
// A rich preview can be clicked like its marker. It stays open while
132+
// the pointer moves from the marker up into it.
133+
onPointerDown={rich != null ? (e) => e.stopPropagation() : undefined}
134+
onClick={
135+
rich != null && hoverMarker && onMarkerClick
136+
? (e) => {
137+
e.stopPropagation()
138+
onMarkerClick(hoverMarker.id)
139+
}
140+
: undefined
141+
}
142+
onPointerLeave={
143+
rich != null
144+
? (e) => {
145+
if (!isMarker(e.relatedTarget)) setHoverMarker(null)
146+
}
147+
: undefined
148+
}
128149
>
129150
{tile && (
130151
<div
@@ -204,7 +225,17 @@ export function Scrubber() {
204225
onMarkerClick?.(m.id)
205226
}}
206227
onPointerEnter={() => setHoverMarker(m)}
207-
onPointerLeave={() => setHoverMarker(null)}
228+
onPointerLeave={(e) => {
229+
// Moving up into a rich preview keeps it open.
230+
const to = e.relatedTarget
231+
if (
232+
m.preview != null &&
233+
to instanceof Node &&
234+
previewRef.current?.contains(to)
235+
)
236+
return
237+
setHoverMarker(null)
238+
}}
208239
>
209240
{m.icon && <span aria-hidden="true">{m.icon}</span>}
210241
</button>
@@ -214,3 +245,7 @@ export function Scrubber() {
214245
</div>
215246
)
216247
}
248+
249+
function isMarker(el: EventTarget | null): boolean {
250+
return el instanceof Element && el.closest(".kino-marker") != null
251+
}

0 commit comments

Comments
 (0)