Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/ui/src/components/list-row/list-row.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@
transition: opacity 0s linear var(--duration-fast);
}

.luke-list-row[data-swipe="moving"] > .luke-list-row-swipe {
transition: background-color var(--duration-hover) ease;
}

.luke-list-row[data-armed="true"] > .luke-list-row-swipe {
background: color-mix(in srgb, var(--luke-color-accent) 82%, var(--luke-color-on-accent));
}
Expand Down Expand Up @@ -340,8 +344,11 @@
transition: transform var(--duration-fast) var(--spring-fast);
}

/* While the row follows the hand, its label follows a beat behind, so
the jump from the button's middle to the face's edge as the row arms
glides rather than snaps. */
.luke-list-row[data-swipe="moving"] .luke-list-row-swipe-label {
transition: none;
transition: transform var(--duration-hover) ease-out;
}

.luke-list-row-swipe-label > svg {
Expand Down
129 changes: 128 additions & 1 deletion packages/ui/src/components/list-row/list-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,9 @@ function trackpad() {
act(() => {
for (const frame of frames.splice(0)) frame(time);
});
const wheel = (item: HTMLElement, deltaX: number, deltaY = 0): boolean => {
const wheel = (item: HTMLElement, deltaX: number, deltaY = 0, at?: number): boolean => {
const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX, deltaY });
if (at !== undefined) Object.defineProperty(event, "timeStamp", { value: at });
act(() => {
item.dispatchEvent(event);
});
Expand All @@ -552,6 +553,9 @@ test("a trackpad's two-finger swipe moves the row by its sideways wheel and is l
const short = swiped();
wheel(short.item, 0, 30);
expect(standing(short.item)).toEqual(["rest", "0px", false]);
// The scroll rests before the swipe begins.
frameAt(500);
frameAt(700);
wheel(short.item, -20);
wheel(short.item, -20);
expect(standing(short.item)).toEqual(["moving", "40px", false]);
Expand Down Expand Up @@ -656,3 +660,126 @@ test("a pointer's swipe interrupted and let go off the row leaves no click to sw
act(() => press.click());
expect([acted, opened]).toEqual([[], ["open"]]);
});

/** An event stamped as the window would have stamped it, `at` milliseconds in. */
function stamped<E extends Event>(event: E, at: number): E {
Object.defineProperty(event, "timeStamp", { value: at });
return event;
}

/** A press on `target` moved through each `[dx, at]` in turn, from x 10 at time 0, then let go at the last. */
function flick(target: HTMLElement, ...moves: [number, number][]): void {
act(() => {
target.dispatchEvent(stamped(pointer("pointerdown", 10), 0));
});
for (const [dx, at] of moves) {
act(() => {
target.dispatchEvent(stamped(pointer("pointermove", 10 + dx), at));
});
}
const [dx, at] = moves[moves.length - 1] ?? [0, 0];
act(() => {
target.dispatchEvent(stamped(pointer("pointerup", 10 + dx), at + 4));
target.dispatchEvent(pointerClick());
});
}

test("a flick carries a release along its speed: let go short of the line, a fast swipe right acts; let go past where it would open, a fast swipe back closes", () => {
const thrown = swiped();
flick(thrown.press, [20, 10], [60, 20], [140, 30], [150, 32]);
expect(thrown.acted).toEqual(["Read"]);
expect(standing(thrown.item)).toEqual(["rest", "0px", false]);

const pulled = swiped();
flick(pulled.press, [100, 100], [110, 200], [60, 210], [30, 220]);
expect([pulled.acted, standing(pulled.item)]).toEqual([[], ["rest", "0px", false]]);

const slow = swiped();
flick(slow.press, [20, 100], [60, 400], [150, 900]);
expect([slow.acted, standing(slow.item)]).toEqual([[], ["open", "72px", false]]);
});

test("past 85% of the row the row gives, going on with a third of the hand's travel", () => {
const { item, press } = swiped();
drag(press, 20, 300);
expect(standing(item)).toEqual(["moving", "270.75px", true]);
});

test("a trackpad movement that begins up or down stays the list's until its wheel rests, however sideways it turns; a sideways one begins a swipe only once it leans sideways 5px, and twice as far as up or down", () => {
const { frameAt, wheel } = trackpad();
const { item } = swiped();
expect(wheel(item, 0, 20)).toBe(false);
expect(wheel(item, -60)).toBe(false);
expect(standing(item)).toEqual(["rest", "0px", false]);
frameAt(1_000);
frameAt(1_200);

expect(wheel(item, -4)).toBe(false);
expect(standing(item)[0]).toBe("rest");
expect(wheel(item, -3)).toBe(true);
expect(standing(item)).toEqual(["moving", "7px", false]);
frameAt(2_000);
frameAt(2_200);

expect(wheel(item, -6, -4)).toBe(false);
expect(standing(item)[0]).toBe("rest");
expect(wheel(item, -6)).toBe(true);
expect(standing(item)).toEqual(["moving", "12px", false]);
});

test("a trackpad's deltas shrinking five times in a row are the fingers lifted: the swipe is let go at once, a long one acting and a short one resting open, and the coast that follows moves nothing", () => {
const { frameAt, wheel } = trackpad();
const long = swiped();
for (const delta of [-60, -60, -60, -50, -40, -30, -20]) wheel(long.item, delta);
expect(long.acted).toEqual([]);
wheel(long.item, -10);
expect(long.acted).toEqual(["Read"]);
expect(standing(long.item)).toEqual(["rest", "0px", false]);
expect(wheel(long.item, -5)).toBe(true);
expect(standing(long.item)).toEqual(["rest", "0px", false]);
frameAt(1_000);
frameAt(1_200);
expect(long.acted).toEqual(["Read"]);

const short = swiped();
for (const delta of [-30, -8, -6, -4, -2, -1]) wheel(short.item, delta);
expect(standing(short.item)).toEqual(["open", "72px", false]);
expect(short.acted).toEqual([]);
});

test("a trackpad's swipe on an open row starts from where it rests, goes the hand's way past the give however it is fed, and a flick let go when the wheel rests carries as a pointer's does", () => {
const { frameAt, wheel } = trackpad();
const open = swiped();
drag(open.press, 10, 40);
release(open.press, 40);
expect(standing(open.item)).toEqual(["open", "72px", false]);
wheel(open.item, -5);
expect(standing(open.item)).toEqual(["moving", "77px", false]);

const far = swiped();
wheel(far.item, -300);
expect(standing(far.item)).toEqual(["moving", "270.75px", true]);
wheel(far.item, -10);
expect(standing(far.item)).toEqual(["moving", "274.25px", true]);

const flicked = swiped();
wheel(flicked.item, -60, 0, 100);
wheel(flicked.item, -60, 0, 110);
wheel(flicked.item, -50, 0, 120);
frameAt(1_000);
frameAt(1_200);
expect(flicked.acted).toEqual(["Read"]);
});

test("a scroll that follows a quick trackpad swipe ends its flick, so the row only opens once the wheel rests, never acting", () => {
const { frameAt, wheel } = trackpad();
const { item, acted } = swiped();
wheel(item, -60, 0, 100);
wheel(item, -60, 0, 110);
wheel(item, -50, 0, 120);
expect(wheel(item, 0, 40, 130)).toBe(false);
expect(wheel(item, 2, 60, 140)).toBe(false);
frameAt(1_000);
frameAt(1_200);
expect([acted, standing(item)]).toEqual([[], ["open", "72px", false]]);
});
Loading
Loading