fix(studio): stop lane-header clicks from reaching the track row

The prev/next keyframe chevrons and the group toggle diamond let their
click bubble to the ancestor track row, so seeking to a keyframe also
reselected the track. The disclosure caret and the eye already stop it;
these now match.

Truncated labels (layer name, track label, group label, value readout)
also carry a title so the full text is reachable on hover.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:06 +02:00
parent 8bb31b3949
commit c264cf266f
3 changed files with 58 additions and 7 deletions
@@ -50,7 +50,9 @@ export function LayerDisclosureRow({
>
</span>
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
{name}
</span>
</div>
);
}
@@ -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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
@@ -197,7 +197,11 @@ function LegacyTrackHeader({
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && <span className="min-w-0 flex-1 truncate text-[11px]">{trackLabel}</span>}
{showTrackLabel && (
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
{trackLabel}
</span>
)}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
@@ -343,7 +347,10 @@ function PropertyGroupNavigation({
aria-label={`Previous ${label} keyframe`}
disabled={!navigation.prevKeyframe}
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15"
onClick={() => seekTo(navigation.prevKeyframe)}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.prevKeyframe);
}}
>
</button>
@@ -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);
}}
>
</button>
@@ -427,7 +437,9 @@ function PropertyGroupHeaderRow({
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
<span className="w-[46px] shrink-0 truncate text-white">{label}</span>
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
{label}
</span>
<PropertyGroupNavigation
navigation={navigation}
label={label}
@@ -439,7 +451,10 @@ function PropertyGroupHeaderRow({
aria-label={`Toggle ${label} keyframe`}
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
className="flex h-5 w-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onClick={() => {
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 ? "◆" : "◇"}
</button>
</PropertyGroupNavigation>
<span className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45">
<span
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
title={valueReadout(lane.group, values)}
>
{valueReadout(lane.group, values)}
</span>
<VisibilityButton