diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx
index b630482d6..c061da300 100644
--- a/packages/studio/src/player/components/LayerDisclosureRow.tsx
+++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx
@@ -50,7 +50,9 @@ export function LayerDisclosureRow({
>
◇
- {name}
+
+ {name}
+
);
}
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
index 57d6fb127..094b54a5f 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
@@ -167,6 +167,37 @@ describe("TimelineTrackHeader", () => {
act(() => view.root.unmount());
});
+ // The lane header sits inside the track row, whose own click handler selects
+ // the track. Every control in the label column has to own its click, or
+ // seeking to a keyframe also reselects whatever is behind the header.
+ it("keeps lane-header control clicks off the ancestor track row", () => {
+ const onAncestorClick = vi.fn();
+ const view = renderHeader({
+ currentTime: 1,
+ onSeek: vi.fn(),
+ onTogglePropertyGroupKeyframe: vi.fn(),
+ });
+ // React 18 delegates from the root container, so an ancestor of it is where
+ // a leaked click actually shows up.
+ document.body.addEventListener("click", onAncestorClick);
+
+ // Every control in the lane's label column, found by row rather than by
+ // label, so a wording change to one button can't silently drop it here.
+ const controls = view.host.querySelectorAll(
+ '[data-property-group="position"] button',
+ );
+ expect(controls.length).toBeGreaterThanOrEqual(3);
+ for (const button of controls) {
+ act(() => {
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ }
+
+ document.body.removeEventListener("click", onAncestorClick);
+ expect(onAncestorClick).not.toHaveBeenCalled();
+ act(() => view.root.unmount());
+ });
+
it("fills the toggle diamond exactly at that group's keyframe", () => {
const view = renderHeader({ currentTime: 0.5 });
const positionToggle = view.host.querySelector(
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx
index e27812aab..c0fac5b49 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx
@@ -197,7 +197,11 @@ function LegacyTrackHeader({
{isAudioTrack && (
)}
- {showTrackLabel && {trackLabel}}
+ {showTrackLabel && (
+
+ {trackLabel}
+
+ )}
seekTo(navigation.prevKeyframe)}
+ onClick={(event) => {
+ event.stopPropagation();
+ seekTo(navigation.prevKeyframe);
+ }}
>
‹
@@ -353,7 +360,10 @@ function PropertyGroupNavigation({
aria-label={`Next ${label} keyframe`}
disabled={!navigation.nextKeyframe}
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15"
- onClick={() => seekTo(navigation.nextKeyframe)}
+ onClick={(event) => {
+ event.stopPropagation();
+ seekTo(navigation.nextKeyframe);
+ }}
>
›
@@ -427,7 +437,9 @@ function PropertyGroupHeaderRow({
/>
- {label}
+
+ {label}
+
{
+ onClick={(event) => {
+ // Same as the disclosure caret and the eye: a control in the label
+ // column owns its click, it does not also hit the track row behind it.
+ event.stopPropagation();
if (expandedElement && toggleTarget) {
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
}
@@ -448,7 +463,10 @@ function PropertyGroupHeaderRow({
{navigation.currentKeyframe ? "◆" : "◇"}
-
+
{valueReadout(lane.group, values)}