From eb2258e2e2060f51acfa54f05c571e571218ce45 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:57:06 +0000 Subject: [PATCH 1/4] feat(ui,desktop): swipe an Inbox row right to mark it read or unread A ListRow may offer one swipe right (`swipe`), two-stage as Mail's: past 24px a release leaves the row open on the act's button, which a click presses; past 60% of the row it arms, the button brighter and its label riding the row's edge, and a release acts with no click. A pointer drag and a trackpad's two-finger swipe (horizontal wheel deltas, let go once the wheel rests on the frames' own clock) both drive it. A press or a wheel elsewhere, a click on the open row, or swiping it back closes it; swiping left does nothing. A cancelled pointer never acts, and nor does a swipe whose act changed under it as the list redrew the row. The face moves on transform and the button shows on opacity, timed from the motion tokens, so reduced motion holds the settling still. The Inbox's rows offer it as Read or Unread, marking the item through the same act as the hover button, the menu, and U. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../renderer/desktop/desktop-inbox.test.ts | 50 ++- .../renderer/desktop/desktop-tasks.test.ts | 6 +- .../desktop/src/renderer/tasks/inbox-list.tsx | 16 +- .../ui/src/components/list-row/list-row.css | 117 ++++++- .../src/components/list-row/list-row.test.ts | 243 +++++++++++++- .../ui/src/components/list-row/list-row.tsx | 86 ++++- .../src/components/list-row/use-row-swipe.ts | 310 ++++++++++++++++++ 7 files changed, 805 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/components/list-row/use-row-swipe.ts diff --git a/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts b/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts index ca8f9d2b8c..5012af1ff7 100644 --- a/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts @@ -450,7 +450,7 @@ function listed(page: HTMLElement): string[] { const list = page.querySelector(".inbox-tasks"); assert.ok(list, "the list is drawn"); const fold = ".sidebar-repository-header button[aria-expanded]:not([aria-haspopup])"; - return [...list.querySelectorAll(`${fold}, li > button`)].map((node) => { + return [...list.querySelectorAll(`${fold}, li button.luke-list-row-main`)].map((node) => { if (node.matches(fold) || !(node instanceof HTMLButtonElement)) return `# ${heardName(node)}`; const name = node.querySelector(ROW_TITLE)?.textContent ?? ""; return `${node.getAttribute("aria-current") === "page" ? "▸ " : ""}${needsYouMark(node)}${name}`; @@ -467,9 +467,9 @@ function wordsOf(element: Element | null | undefined): string | null { } function rowNamed(page: HTMLElement, title: string): HTMLButtonElement { - const row = [...page.querySelectorAll(".inbox-tasks li > button")].find( - (each) => each.querySelector(ROW_TITLE)?.textContent === title, - ); + const row = [ + ...page.querySelectorAll(".inbox-tasks li button.luke-list-row-main"), + ].find((each) => each.querySelector(ROW_TITLE)?.textContent === title); assert.ok(row, `no row for ${title}`); return row; } @@ -3476,3 +3476,45 @@ test("Mark all as read, once the service took it, reads every listed task and ev ); assert.deepEqual(agentReads.slice(agentsBefore), [SWEEP_AGENT]); }); + +test("a row swiped right marks its item: a long swipe marks an unread item read as it is let go, a short one leaves the row open on an Unread button for a read one, and neither opens the item", async () => { + inboxes.set(BILLING.id, [THRESHOLD, { ...RETRY, unread: true }]); + const { page } = mount(); + await settle(); + readMarks = []; + const retry = "How is a failed email retried?"; + const item = rowNamed(page, retry).closest("li") ?? assert.fail("the row's item"); + vi.spyOn(item, "getBoundingClientRect").mockReturnValue( + DOMRect.fromRect({ width: 300, height: 56 }), + ); + Object.assign(item, { setPointerCapture: () => undefined }); + const at = (type: string, x: number) => + new PointerEvent(type, { bubbles: true, button: 0, clientX: x, clientY: 10, pointerId: 1 }); + const swipe = (to: number) => + act(() => { + const press = rowNamed(page, retry); + press.dispatchEvent(at("pointerdown", 10)); + press.dispatchEvent(at("pointermove", 30)); + press.dispatchEvent(at("pointermove", 10 + to)); + press.dispatchEvent(at("pointerup", 10 + to)); + press.click(); + }); + swipe(220); + await settle(); + assert.deepEqual(readMarks, [`read:${RETRY.id}`]); + assert.equal(unreadOf(page, retry), false); + + swipe(40); + await settle(); + const button = item.querySelector("button[data-swipe-act]"); + assert.equal(button?.textContent, "Unread"); + act(() => button?.click()); + await settle(); + assert.deepEqual(readMarks, [`read:${RETRY.id}`, `unread:${RETRY.id}`]); + assert.equal(unreadOf(page, retry), true); + assert.equal( + page.querySelector('[aria-current="page"] .luke-list-row-title')?.textContent, + "Alert at 80%?", + "a swipe opens nothing", + ); +}); diff --git a/apps/desktop/src/renderer/desktop/desktop-tasks.test.ts b/apps/desktop/src/renderer/desktop/desktop-tasks.test.ts index 2e850a6ad3..5ec8a39905 100644 --- a/apps/desktop/src/renderer/desktop/desktop-tasks.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-tasks.test.ts @@ -387,9 +387,9 @@ async function narrowTaskInbox( ...tab.querySelectorAll(".inbox-view-items > .desktop-toolbar button"), ].find((button) => button.textContent === "Inbox") ?? null, row: (title: string) => - [...tab.querySelectorAll(".inbox-tasks li > button")].find((each) => - each.textContent?.includes(title), - ), + [ + ...tab.querySelectorAll(".inbox-tasks li button.luke-list-row-main"), + ].find((each) => each.textContent?.includes(title)), unmount: () => { act(() => root.unmount()); container.remove(); diff --git a/apps/desktop/src/renderer/tasks/inbox-list.tsx b/apps/desktop/src/renderer/tasks/inbox-list.tsx index 4ae133c306..dc7c044805 100644 --- a/apps/desktop/src/renderer/tasks/inbox-list.tsx +++ b/apps/desktop/src/renderer/tasks/inbox-list.tsx @@ -139,6 +139,9 @@ const ITEM_ACTIONS = "Inbox item actions"; /** What an unread row says to a reader, its dot being drawn for the eye alone. */ const UNREAD = "Unread"; +/** What a row's swipe button says it does: marks the item read, or unread. */ +const SWIPE_WORDS = { READ: "Read", UNREAD: "Unread" } as const; + /** What the view's menu offers to clear every dot with. */ const MARK_ALL_READ = "Mark all as read"; @@ -267,7 +270,9 @@ export function ItemMark({ * a press over its time while the pointer rests on it or it holds the * keyboard, the time and its marks stepping aside, and the first item of * its right-click menu, which names the key that does the same to the - * chosen item (`APP_COMMAND.TOGGLE_INBOX_ITEM_READ`). The home page's Inbox + * chosen item (`APP_COMMAND.TOGGLE_INBOX_ITEM_READ`); and a swipe right, a + * short one leaving the row open on a Read or Unread button, a long one + * marking it as it is let go (`@luke/ui`'s `ListRow` swipe). The home page's Inbox * names the item's plan at the row's end, in `context`, since its rows stand * under no plan's header, as a person's group does. Its right-click offers * Copy link, the item's link under the task or the agent that holds it, where @@ -392,6 +397,15 @@ export function InboxItemRow({ ) } actionsAt={LIST_ROW_ACTIONS.HOVER} + swipe={ + onMark === undefined + ? undefined + : { + label: unread ? SWIPE_WORDS.READ : SWIPE_WORDS.UNREAD, + icon: unread ? : , + onAct: () => onMark(listed, !unread), + } + } current={chosen ? LIST_ROW_CURRENT.PAGE : undefined} unread={unread} hint={{ Hint: Tooltip, label: title }} diff --git a/packages/ui/src/components/list-row/list-row.css b/packages/ui/src/components/list-row/list-row.css index 52d732a186..de1ea79ed4 100644 --- a/packages/ui/src/components/list-row/list-row.css +++ b/packages/ui/src/components/list-row/list-row.css @@ -213,11 +213,11 @@ position: relative; } - .luke-list-row[data-actions="hover"] > .luke-list-row-main { + .luke-list-row[data-actions="hover"] .luke-list-row-main { padding-inline-end: var(--luke-space-12); } - .luke-list-row[data-actions="hover"] > .luke-list-row-actions { + .luke-list-row[data-actions="hover"] .luke-list-row-actions { position: absolute; inset-block: 0; inset-inline-end: 0; @@ -226,12 +226,12 @@ transition: opacity var(--duration-hover) ease; } - .luke-list-row[data-actions="hover"][data-meta="title"] > .luke-list-row-actions { + .luke-list-row[data-actions="hover"][data-meta="title"] .luke-list-row-actions { inset-block: var(--luke-space-8) auto; height: var(--luke-type-label-leading); } - .luke-list-row[data-actions="hover"]:is(:hover, :has(:focus-visible)) > .luke-list-row-actions { + .luke-list-row[data-actions="hover"]:is(:hover, :has(:focus-visible)) .luke-list-row-actions { opacity: 1; pointer-events: auto; } @@ -239,4 +239,113 @@ .luke-list-row[data-actions="hover"]:is(:hover, :has(:focus-visible)) .luke-list-row-meta { visibility: hidden; } + + /* A row swiped right to its one act (`use-row-swipe.ts`): its face slides + on its `transform` over the act's button in the accent, which a short + swipe leaves showing and a long one arms, brighter, its label riding + the face's edge. The face is drawn on the row's own ground, its fill + over it, so it hides the button wherever it stands; it settles on the + motion tokens, so reduced motion and a capture run hold it still, and + follows the pointer or the wheel with no lag while it moves. */ + .luke-list-row[data-swipe] { + --luke-list-row-fill: transparent; + + position: relative; + overflow: clip; + touch-action: pan-y; + } + + .luke-list-row[data-swipe]:has(button.luke-list-row-main):hover:not([data-current="true"]) { + --luke-list-row-fill: var(--luke-color-fill-hover); + + background: none; + } + + .luke-list-row[data-swipe][data-current="true"] { + --luke-list-row-fill: var(--luke-color-fill-strong); + + background: none; + } + + .luke-list-row-slide { + position: relative; + display: flex; + flex: 1; + align-items: center; + gap: var(--luke-control-gap); + min-width: 0; + border-radius: inherit; + background: + linear-gradient(var(--luke-list-row-fill), var(--luke-list-row-fill)), + var(--luke-list-row-ground, var(--luke-color-ground)); + transform: translateX(var(--luke-list-row-swipe, 0px)); + transition: transform var(--duration-fast) var(--spring-fast); + } + + .luke-list-row[data-swipe="moving"] > .luke-list-row-slide { + transition: none; + } + + .luke-list-row:not([data-actions="hover"]):has(> .luke-list-row-slide > .luke-list-row-actions) + > .luke-list-row-slide + > .luke-list-row-main { + padding-inline-end: 0; + } + + /* The button stands hidden at rest, so its edge never shows round the + face's corners; it comes in at once as a swipe begins, and goes only + once the face has slid home over it. */ + .luke-list-row-swipe { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--luke-color-accent); + opacity: 1; + } + + .luke-list-row[data-swipe="rest"] > .luke-list-row-swipe { + opacity: 0; + transition: opacity 0s linear var(--duration-fast); + } + + .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)); + } + + .luke-list-row-swipe-act { + position: absolute; + inset: 0; + margin: 0; + padding: 0; + border: 0; + background: none; + color: var(--luke-color-on-accent); + font: inherit; + } + + .luke-list-row-swipe-label { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + display: flex; + width: 56px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--luke-space-2); + font-size: var(--luke-type-caption-size); + font-weight: var(--luke-weight-medium); + opacity: var(--luke-list-row-swipe-shown, 0); + transform: translateX(var(--luke-list-row-swipe-label, 0px)); + transition: transform var(--duration-fast) var(--spring-fast); + } + + .luke-list-row[data-swipe="moving"] .luke-list-row-swipe-label { + transition: none; + } + + .luke-list-row-swipe-label > svg { + width: var(--luke-control-icon); + height: var(--luke-control-icon); + } } diff --git a/packages/ui/src/components/list-row/list-row.test.ts b/packages/ui/src/components/list-row/list-row.test.ts index 1d164529b6..4f2f3aefec 100644 --- a/packages/ui/src/components/list-row/list-row.test.ts +++ b/packages/ui/src/components/list-row/list-row.test.ts @@ -2,7 +2,7 @@ import { act, cloneElement, createElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { IconButton } from "../button/button.js"; import { LIST_ROW_CURRENT, LIST_ROW_META, List, ListRow, type ListRowHint } from "./list-row.js"; @@ -17,7 +17,11 @@ import { LIST_ROW_CURRENT, LIST_ROW_META, List, ListRow, type ListRowHint } from * current as the page beside the list is announced so, and the app's hint * hung from a row's press raises its words for the press. A row whose * metadata stands on its title's line reads it after the title and before - * the line under it. Synthetic members. + * the line under it. A row swiped right rests open on its act's button + * after a short swipe and does the act on a long one's release, by pointer + * or by a trackpad's wheel; a cancelled pointer, a swipe whose act changed + * under it, a swipe left, and a press up or down never act. Synthetic + * members. */ declare global { @@ -36,6 +40,7 @@ const roots: Root[] = []; afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); document.body.replaceChildren(); + vi.restoreAllMocks(); }); /** Draws `element` into a container in the document and hands the container back. */ @@ -324,3 +329,237 @@ test("the app's hint hung from a row's press raises its words while the press ho press(row); expect(heard).toEqual(["open"]); }); + +/** How wide the swiped row stands, in CSS pixels: its act's line at 60% is 180px. */ +const ROW_WIDTH = 300; + +/** A row offering one swipe, its act's words `label`, each act counted in `acted`; and the row's press, counted in `opened`. */ +function SwipedRow({ + label, + acted, + opened, +}: { + label: string; + acted: string[]; + opened: string[]; +}): React.JSX.Element { + return createElement(List, { + label: "Inbox", + children: createElement(ListRow, { + title: "Alert at 80%?", + onPress: () => opened.push("open"), + swipe: { label, icon: glyph(), onAct: () => acted.push(label) }, + }), + }); +} + +/** A swiped row drawn, its item measured `ROW_WIDTH` wide and its pointer captured as the window would; and how to draw it again with another act. */ +function swiped(label = "Read") { + const acted: string[] = []; + const opened: string[] = []; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + roots.push(root); + const draw = (words: string) => + act(() => root.render(createElement(SwipedRow, { label: words, acted, opened }))); + draw(label); + const item = container.querySelector("li") ?? expect.fail("the row stands"); + vi.spyOn(item, "getBoundingClientRect").mockReturnValue( + DOMRect.fromRect({ width: ROW_WIDTH, height: 56 }), + ); + Object.assign(item, { setPointerCapture: () => undefined }); + const press = + item.querySelector("button.luke-list-row-main") ?? expect.fail("press"); + return { container, item, press, acted, opened, redraw: draw }; +} + +/** A pointer event of the first pointer at `x`, `y`. */ +function pointer(type: string, x: number, y = 10): PointerEvent { + return new PointerEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y, pointerId: 1 }); +} + +/** A press on `target` dragged right by each of `steps` in turn, from x 10, left held down. */ +function drag(target: HTMLElement, ...steps: number[]): void { + act(() => { + target.dispatchEvent(pointer("pointerdown", 10)); + }); + for (const step of steps) { + act(() => { + target.dispatchEvent(pointer("pointermove", 10 + step)); + }); + } +} + +/** The pointer let go at `dx` right of where it pressed, and the click its release makes on `target`. */ +function release(target: HTMLElement, dx: number): void { + act(() => { + target.dispatchEvent(pointer("pointerup", 10 + dx)); + target.click(); + }); +} + +/** Where a swiped row stands, how far it is out, and whether it is armed. */ +function standing(item: HTMLElement): [string | undefined, string, boolean] { + return [ + item.dataset.swipe, + item.style.getPropertyValue("--luke-list-row-swipe"), + item.dataset.armed === "true", + ]; +} + +test("a short swipe right leaves the row open on its act's button, which a click presses once, closing the row; the release opens nothing", () => { + const { item, press, acted, opened } = swiped(); + drag(press, 10, 40); + expect(standing(item)).toEqual(["moving", "40px", false]); + release(press, 40); + expect(standing(item)).toEqual(["open", "72px", false]); + expect([acted, opened]).toEqual([[], []]); + const button = + item.querySelector("button[data-swipe-act]") ?? expect.fail("button"); + expect(button.textContent).toBe("Read"); + act(() => { + button.dispatchEvent(pointer("pointerdown", 30)); + button.click(); + }); + expect([acted, opened]).toEqual([["Read"], []]); + expect(standing(item)).toEqual(["rest", "0px", false]); +}); + +test("a long swipe, past 60% of the row, arms it and acts on release with no click; pulled back under that line first, it rests open instead", () => { + const long = swiped(); + drag(long.press, 20, 120, 200); + expect(standing(long.item)).toEqual(["moving", "200px", true]); + release(long.press, 200); + expect([long.acted, long.opened]).toEqual([["Read"], []]); + expect(standing(long.item)).toEqual(["rest", "0px", false]); + + const back = swiped(); + drag(back.press, 20, 200, 120); + expect(standing(back.item)).toEqual(["moving", "120px", false]); + release(back.press, 120); + expect([back.acted, back.opened]).toEqual([[], []]); + expect(standing(back.item)).toEqual(["open", "72px", false]); +}); + +test("a swipe let go short of 24px, swiped left, or pressed up or down closes without acting, and a press that never moved is a click on the row", () => { + const short = swiped(); + drag(short.press, 10, 20); + release(short.press, 20); + expect(standing(short.item)).toEqual(["rest", "0px", false]); + + const left = swiped(); + drag(left.press, -10, -80); + expect(standing(left.item)).toEqual(["moving", "0px", false]); + release(left.press, -80); + expect(standing(left.item)).toEqual(["rest", "0px", false]); + + const down = swiped(); + act(() => { + down.press.dispatchEvent(pointer("pointerdown", 10, 10)); + down.press.dispatchEvent(pointer("pointermove", 16, 60)); + down.press.dispatchEvent(pointer("pointermove", 100, 60)); + }); + expect(standing(down.item)).toEqual(["rest", "0px", false]); + + const still = swiped(); + act(() => { + still.press.dispatchEvent(pointer("pointerdown", 10)); + }); + release(still.press, 0); + expect([short.acted, left.acted, down.acted, still.acted]).toEqual([[], [], [], []]); + expect(still.opened).toEqual(["open"]); +}); + +test("a pointer the system takes away never acts, however far it swiped, and the row closes", () => { + const { item, press, acted } = swiped(); + drag(press, 20, 250); + act(() => { + press.dispatchEvent(pointer("pointercancel", 260)); + }); + expect(standing(item)).toEqual(["rest", "0px", false]); + release(press, 260); + expect(acted).toEqual([]); +}); + +test("a row redrawn with another act mid-swipe does nothing on release, and an open row whose act changes closes", () => { + const moving = swiped("Read"); + drag(moving.press, 20, 250); + moving.redraw("Unread"); + release(moving.press, 250); + expect(moving.acted).toEqual([]); + expect(standing(moving.item)).toEqual(["rest", "0px", false]); + + const open = swiped("Read"); + drag(open.press, 10, 40); + release(open.press, 40); + expect(standing(open.item)[0]).toBe("open"); + open.redraw("Unread"); + expect(standing(open.item)).toEqual(["rest", "0px", false]); +}); + +test("an open row closes on a press anywhere else, or on a click on the row itself, which opens nothing", () => { + const elsewhere = swiped(); + drag(elsewhere.press, 10, 40); + release(elsewhere.press, 40); + act(() => { + document.body.dispatchEvent(pointer("pointerdown", 400)); + }); + expect(standing(elsewhere.item)).toEqual(["rest", "0px", false]); + + const itself = swiped(); + drag(itself.press, 10, 40); + release(itself.press, 40); + act(() => { + itself.press.dispatchEvent(pointer("pointerdown", 10)); + }); + release(itself.press, 0); + expect(standing(itself.item)).toEqual(["rest", "0px", false]); + expect([itself.acted, itself.opened]).toEqual([[], []]); +}); + +test("a trackpad's two-finger swipe moves the row by its sideways wheel and is let go once the wheel rests: a short one rests open, a long one acts; a wheel up or down is the list's", () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => frames.push(frame)); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + /** Runs the frames asked for at `time`, as the window draws one. */ + const frameAt = (time: number) => + act(() => { + for (const frame of frames.splice(0)) frame(time); + }); + const wheel = (item: HTMLElement, deltaX: number, deltaY = 0) => + act(() => { + item.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX, deltaY }), + ); + }); + + const short = swiped(); + wheel(short.item, 0, 30); + expect(standing(short.item)).toEqual(["rest", "0px", false]); + wheel(short.item, -20); + wheel(short.item, -20); + expect(standing(short.item)).toEqual(["moving", "40px", false]); + frameAt(1_000); + frameAt(1_100); + expect(standing(short.item)[0]).toBe("moving"); + frameAt(1_140); + expect(standing(short.item)).toEqual(["open", "72px", false]); + + const long = swiped(); + wheel(long.item, -120); + frameAt(2_000); + wheel(long.item, -100); + expect(standing(long.item)).toEqual(["moving", "220px", true]); + frameAt(2_100); + frameAt(2_200); + expect(long.acted).toEqual([]); + frameAt(2_240); + expect(long.acted).toEqual(["Read"]); + expect(standing(long.item)).toEqual(["rest", "0px", false]); +}); + +test("a row that offers no swipe draws none and hears a press as a click", () => { + const container = draw(createElement(Members)); + expect(container.querySelector("li[data-swipe], [data-swipe-act]")).toBeNull(); +}); diff --git a/packages/ui/src/components/list-row/list-row.tsx b/packages/ui/src/components/list-row/list-row.tsx index a4fc044f46..37e49a62ea 100644 --- a/packages/ui/src/components/list-row/list-row.tsx +++ b/packages/ui/src/components/list-row/list-row.tsx @@ -8,6 +8,8 @@ import type { ReactNode, RefCallback, } from "react"; +import { cssCustomProperties } from "../../css.js"; +import { useRowSwipe } from "./use-row-swipe.js"; /** * list-row.tsx -- a List of rows, each a thing a collection holds: its title, a line under it, its metadata at the end, and the acts it offers beside it. @@ -53,6 +55,12 @@ import type { * keyboard, the metadata stepping aside as a sidebar row's time does for * Pin and Archive. * + * A row may be swiped right to one act (`swipe`), as a mail app's thread + * is swiped to mark it read: a short swipe leaves it open on the act's + * button, a long one does the act on release (`use-row-swipe.ts`). The + * swipe is the pointer's and the trackpad's alone; the act it does is one + * the row offers another way too, so the button takes no Tab stop. + * * A list stands plain, its rows apart on the ground, each lit on its own; * or as a card (`LIST_VARIANT.CARD`), its rows edge to edge on the card's * ground with a hairline between each and the next, as a settings section's @@ -101,6 +109,13 @@ export const LIST_ROW_ACTIONS = { export type ListRowActions = (typeof LIST_ROW_ACTIONS)[keyof typeof LIST_ROW_ACTIONS]; +/** A row's one swipe: what its act is called, its glyph, and the act. */ +export interface ListRowSwipe { + readonly label: string; + readonly icon: ReactNode; + readonly onAct: () => void; +} + /** What a hint hands the press it wraps: when to show and go, and what it says of the press. */ interface ListRowHintProps { "aria-describedby"?: string | undefined; @@ -118,6 +133,9 @@ export type ListRowHint = ComponentType<{ children: ReactElement; }>; +/** The act of a row that offers no swipe. */ +const NO_ACT = (): void => undefined; + /** Whether a slot says anything: nothing, `null`, `false`, and an empty string say nothing. */ function said(words: ReactNode): boolean { return words !== undefined && words !== null && words !== false && words !== ""; @@ -158,6 +176,7 @@ export function ListRow({ ref, onKeyDown, onContextMenu, + swipe, }: { /** A mark before the title (an avatar, a status's glyph), drawn as decoration. */ leading?: ReactNode; @@ -186,7 +205,10 @@ export function ListRow({ onKeyDown?: ((event: KeyboardEvent) => void) | undefined; /** A right-click, or the menu key, anywhere on the row. */ onContextMenu?: ((event: MouseEvent) => void) | undefined; + /** The row's one swipe right; none where it offers none. */ + swipe?: ListRowSwipe | undefined; }): React.JSX.Element { + const swiped = useRowSwipe(swipe?.label, swipe?.onAct ?? NO_ACT); const heading = {title}; const metadata = said(meta) ? {meta} : null; const face = ( @@ -224,26 +246,72 @@ export function ListRow({ {face} ) : undefined; + const row = ( + <> + {press === undefined ? ( +
+ {face} +
+ ) : hint === undefined ? ( + press + ) : ( + {press} + )} + {said(actions) ?
{actions}
: null} + + ); + const { view, item } = swiped; + const hold = (element: HTMLLIElement | null) => { + item.ref.current = element; + if (!onPress) ref?.(element); + }; return (
  • - {press === undefined ? ( -
    - {face} -
    - ) : hint === undefined ? ( - press + {swipe === undefined ? ( + row ) : ( - {press} + <> +
    + +
    +
    {row}
    + )} - {said(actions) ?
    {actions}
    : null}
  • ); } diff --git a/packages/ui/src/components/list-row/use-row-swipe.ts b/packages/ui/src/components/list-row/use-row-swipe.ts new file mode 100644 index 0000000000..6838337aee --- /dev/null +++ b/packages/ui/src/components/list-row/use-row-swipe.ts @@ -0,0 +1,310 @@ +import type React from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +/** + * use-row-swipe.ts -- a row swiped right to its one act, in two stages, as a mail app's thread is: a short swipe leaves it resting open on the act's button, a long one does the act on release. + * + * A press on the row is a click until the pointer has gone a few pixels; + * gone further sideways than up or down, it is a swipe, the pointer + * captured by the row's item, and the row follows it to the right, never + * left of where it rests. A two-finger swipe on a trackpad moves it the + * same way, by its horizontal wheel deltas, and is let go once they stop. + * Let go past `SWIPE.OPEN`, the row rests open on the act's button, which a + * click presses; let go past `SWIPE.ACT_SHARE` of the row, the act is done + * with no click, and while the row stands past that line it is armed, the + * button brighter and its label riding the row's edge. Let go short of + * both, it closes. A press anywhere but the open row, a wheel elsewhere, a + * click on the row itself, or swiping it back closes it. Swiping left from + * rest does nothing. + * + * A pointer the system took away never acts, and nor does a swipe whose + * act changed under it, since the list redrew the row with something else + * to do than what the reader saw; a row the list dropped takes its swipe + * with it. The click a captured pointer's release makes is swallowed, so a + * swipe opens nothing. The row moves on its `transform` alone, as the + * stylesheet times it from the motion tokens, so reduced motion and a + * capture run hold its settling still. + */ + +/** The swipe's lengths, in CSS pixels, and the share of the row past which a release acts. */ +const SWIPE = { + /** How far a press goes before it is a swipe rather than a click. */ + START: 5, + /** Past this, a release leaves the row resting open on the act's button. */ + OPEN: 24, + /** Where an open row rests: the button's width. */ + REST: 72, + /** Past this share of the row's width, a release does the act. */ + ACT_SHARE: 0.6, + /** The width of the act's label inside its button. */ + LABEL: 56, + /** How far inside the row's edge an armed label rides. */ + LABEL_INSET: 8, + /** How long a trackpad's wheel stays still, in milliseconds, before its swipe is let go. */ + WHEEL_REST_MS: 140, +} as const; + +/** Where a swiped row stands. */ +const SWIPE_STATE = { + REST: "rest", + /** Following the pointer or the wheel. */ + MOVING: "moving", + OPEN: "open", +} as const; + +type SwipeState = (typeof SWIPE_STATE)[keyof typeof SWIPE_STATE]; + +/** What the row draws: where it stands, how far it is out, whether a release would act, and where the act's label stands. */ +interface RowSwipeView { + readonly state: SwipeState; + readonly offset: number; + readonly armed: boolean; + readonly label: { readonly offset: number; readonly opacity: number }; +} + +/** What the hook hands the row's item and its act's button. */ +interface RowSwipe { + readonly view: RowSwipeView; + readonly item: { + readonly ref: React.RefObject; + readonly onPointerDown: (event: React.PointerEvent) => void; + readonly onPointerMove: (event: React.PointerEvent) => void; + readonly onPointerUp: (event: React.PointerEvent) => void; + readonly onPointerCancel: (event: React.PointerEvent) => void; + readonly onClickCapture: (event: React.MouseEvent) => void; + }; + /** The open row's button pressed: the act, and the row closed. */ + readonly onPress: () => void; +} + +/** A press under way: where it began, where the row stood, the act it began on, and whether it is a swipe yet. */ +interface Press { + readonly pointerId: number; + readonly x: number; + readonly y: number; + readonly from: number; + readonly act: string; + swiping: boolean; +} + +/** A trackpad swipe under way: the act it began on, and the frame time its wheel last moved at, unknown until the next frame. */ +interface Wheel { + readonly act: string; + movedAt: number | undefined; + frame: number; +} + +/** Whether a release at `offset` on a row `width` wide acts. */ +function acts(offset: number, width: number): boolean { + return width > 0 && offset >= width * SWIPE.ACT_SHARE; +} + +/** Where the act's label stands: centred in the button while it shows, riding the row's edge once armed; and how much of it shows. */ +function labelAt(offset: number, armed: boolean): RowSwipeView["label"] { + return { + offset: armed + ? offset - SWIPE.LABEL - SWIPE.LABEL_INSET + : (Math.min(offset, SWIPE.REST) - SWIPE.LABEL) / 2, + opacity: Math.min(1, offset / SWIPE.REST), + }; +} + +/** + * A row swiped to its one act. `act` names what the act does now, its + * label, so a swipe whose act changed before its release does nothing; + * none where the row offers no swipe, which leaves every press a click. + */ +export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwipe { + const ref = useRef(null); + const [state, setState] = useState(SWIPE_STATE.REST); + const [offset, setOffsetDrawn] = useState(0); + // Note that the offset and the row's width are held in refs too, so a release reads them as the last move left them rather than as the last render drew them. + const at = useRef(0); + const width = useRef(0); + const setOffset = (next: number) => { + at.current = next; + setOffsetDrawn(next); + }; + const press = useRef(undefined); + const wheel = useRef(undefined); + const swallow = useRef(false); + /** The act the row was left open on. */ + const openedOn = useRef(undefined); + const latest = useRef({ act, onAct }); + useLayoutEffect(() => { + latest.current = { act, onAct }; + }); + + const close = () => { + setOffset(0); + setState(SWIPE_STATE.REST); + }; + // Note that the act's own words are read at release, so a row the list redrew with another act does nothing. + const settle = (began: string) => { + if (latest.current.act !== began) return close(); + if (acts(at.current, width.current)) { + latest.current.onAct(); + return close(); + } + if (at.current >= SWIPE.OPEN) { + openedOn.current = began; + setOffset(SWIPE.REST); + setState(SWIPE_STATE.OPEN); + return; + } + close(); + }; + const measure = () => { + width.current = ref.current?.getBoundingClientRect().width ?? 0; + }; + + // An open row closes on a press or a wheel anywhere else, as another row's swipe begins. + const open = state === SWIPE_STATE.OPEN; + useEffect(() => { + if (!open) return; + const elsewhere = (event: Event) => { + if (event.target instanceof Node && ref.current?.contains(event.target)) return; + close(); + }; + document.addEventListener("pointerdown", elsewhere, true); + document.addEventListener("wheel", elsewhere, true); + return () => { + document.removeEventListener("pointerdown", elsewhere, true); + document.removeEventListener("wheel", elsewhere, true); + }; + }, [open]); + // An open row whose act changed under it closes, so its button never offers what the row no longer does. + useEffect(() => { + if (open && openedOn.current !== act) close(); + }); + // The wheel's swipe is heard on the item itself, not passively, so a sideways swipe does not scroll the list too. + const offered = act !== undefined; + const heard = useRef<(event: WheelEvent) => void>(() => undefined); + useEffect(() => { + const item = ref.current; + if (item === null || !offered) return; + const listen = (event: WheelEvent) => heard.current(event); + item.addEventListener("wheel", listen, { passive: false }); + return () => item.removeEventListener("wheel", listen); + }, [offered]); + // A row the list drops takes its wheel's swipe with it. + useEffect( + () => () => { + if (wheel.current !== undefined) cancelAnimationFrame(wheel.current.frame); + }, + [], + ); + + // Note that the wheel's rest is timed on the frames' own clock, a frame after the last delta standing for when it moved, so it reads one clock throughout. + const wheelFrame = (time: number) => { + const held = wheel.current; + if (held === undefined) return; + if (held.movedAt === undefined) held.movedAt = time; + if (time - held.movedAt < SWIPE.WHEEL_REST_MS) { + held.frame = requestAnimationFrame(wheelFrame); + return; + } + wheel.current = undefined; + settle(held.act); + }; + const wheelMoved = (event: WheelEvent) => { + const current = latest.current.act; + if (current === undefined || press.current !== undefined) return; + if (wheel.current === undefined && Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + event.preventDefault(); + if (wheel.current === undefined) { + measure(); + wheel.current = { + act: current, + movedAt: undefined, + frame: requestAnimationFrame(wheelFrame), + }; + setState(SWIPE_STATE.MOVING); + } + wheel.current.movedAt = undefined; + setOffset(Math.max(0, at.current - event.deltaX)); + }; + useLayoutEffect(() => { + heard.current = wheelMoved; + }); + + const view: RowSwipeView = (() => { + const armed = state === SWIPE_STATE.MOVING && acts(offset, width.current); + return { state, offset, armed, label: labelAt(offset, armed) }; + })(); + + return { + view, + item: { + ref, + onPointerDown: (event) => { + swallow.current = false; + const current = latest.current.act; + if (current === undefined || event.button !== 0) return; + if (event.target instanceof Element && event.target.closest("[data-swipe-act]") !== null) { + return; + } + press.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + from: at.current, + act: current, + swiping: false, + }; + }, + onPointerMove: (event) => { + const held = press.current; + if (held === undefined || held.pointerId !== event.pointerId) return; + const dx = event.clientX - held.x; + const dy = event.clientY - held.y; + if (!held.swiping) { + if (Math.abs(dx) < SWIPE.START && Math.abs(dy) < SWIPE.START) return; + // A press that goes up or down more than sideways is the list's to scroll, never a swipe. + if (Math.abs(dy) > Math.abs(dx)) { + press.current = undefined; + return; + } + held.swiping = true; + measure(); + ref.current?.setPointerCapture?.(event.pointerId); + setState(SWIPE_STATE.MOVING); + } + setOffset(Math.max(0, held.from + dx)); + }, + onPointerUp: (event) => { + const held = press.current; + if (held === undefined || held.pointerId !== event.pointerId) return; + press.current = undefined; + if (held.swiping) { + swallow.current = true; + setOffset(Math.max(0, held.from + event.clientX - held.x)); + settle(held.act); + return; + } + // A click on an open row closes it and opens nothing. + if (held.from > 0) { + swallow.current = true; + close(); + } + }, + onPointerCancel: (event) => { + if (press.current?.pointerId !== event.pointerId) return; + const swiping = press.current.swiping; + press.current = undefined; + if (swiping) close(); + }, + onClickCapture: (event) => { + if (!swallow.current) return; + swallow.current = false; + event.preventDefault(); + event.stopPropagation(); + }, + }, + onPress: () => { + if (latest.current.act === undefined) return; + latest.current.onAct(); + close(); + }, + }; +} From f605f74537c8ecb00c3de320ba0c01c927ecde39 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:01:30 +0000 Subject: [PATCH 2/4] fix(ui): an open row's acts keep their click, and a trackpad swipe yields to a press and to scrolling A press on the row's own acts is theirs, never a swipe's; a press or a wheel anywhere interrupts a trackpad's swipe still waiting for its wheel to rest, which then does nothing; and a wheel more up or down than sideways always scrolls the list, a swipe under way or not. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/components/list-row/list-row.test.ts | 64 +++++++++++++++++-- .../src/components/list-row/use-row-swipe.ts | 23 +++++-- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/list-row/list-row.test.ts b/packages/ui/src/components/list-row/list-row.test.ts index 4f2f3aefec..60ab76981d 100644 --- a/packages/ui/src/components/list-row/list-row.test.ts +++ b/packages/ui/src/components/list-row/list-row.test.ts @@ -348,6 +348,11 @@ function SwipedRow({ children: createElement(ListRow, { title: "Alert at 80%?", onPress: () => opened.push("open"), + actions: createElement(IconButton, { + icon: glyph(), + label: "Mark as read", + onClick: () => acted.push("hover mark"), + }), swipe: { label, icon: glyph(), onAct: () => acted.push(label) }, }), }); @@ -518,22 +523,27 @@ test("an open row closes on a press anywhere else, or on a click on the row itse expect([itself.acted, itself.opened]).toEqual([[], []]); }); -test("a trackpad's two-finger swipe moves the row by its sideways wheel and is let go once the wheel rests: a short one rests open, a long one acts; a wheel up or down is the list's", () => { +/** A trackpad over the window's frames: its wheel turned on an item, answering whether the event was kept from the list, and the frames asked for run at a time given, as the window draws one. */ +function trackpad() { const frames: FrameRequestCallback[] = []; vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => frames.push(frame)); vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); - /** Runs the frames asked for at `time`, as the window draws one. */ const frameAt = (time: number) => act(() => { for (const frame of frames.splice(0)) frame(time); }); - const wheel = (item: HTMLElement, deltaX: number, deltaY = 0) => + const wheel = (item: HTMLElement, deltaX: number, deltaY = 0): boolean => { + const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX, deltaY }); act(() => { - item.dispatchEvent( - new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX, deltaY }), - ); + item.dispatchEvent(event); }); + return event.defaultPrevented; + }; + return { frameAt, wheel }; +} +test("a trackpad's two-finger swipe moves the row by its sideways wheel and is let go once the wheel rests: a short one rests open, a long one acts; a wheel up or down is the list's", () => { + const { frameAt, wheel } = trackpad(); const short = swiped(); wheel(short.item, 0, 30); expect(standing(short.item)).toEqual(["rest", "0px", false]); @@ -563,3 +573,45 @@ test("a row that offers no swipe draws none and hears a press as a click", () => const container = draw(createElement(Members)); expect(container.querySelector("li[data-swipe], [data-swipe-act]")).toBeNull(); }); + +test("an open row's own acts take their click, the row's swipe doing nothing", () => { + const { item, press, acted, opened } = swiped(); + drag(press, 10, 40); + release(press, 40); + const mark = + item.querySelector(".luke-list-row-actions button") ?? expect.fail("act"); + act(() => { + mark.dispatchEvent(pointer("pointerdown", 250)); + mark.dispatchEvent(pointer("pointerup", 250)); + mark.click(); + }); + expect([acted, opened]).toEqual([["hover mark"], []]); +}); + +test("a trackpad's swipe a press interrupts, on the row or anywhere else, does nothing once its wheel rests", () => { + const { frameAt, wheel } = trackpad(); + const elsewhere = swiped(); + wheel(elsewhere.item, -220); + act(() => { + document.body.dispatchEvent(pointer("pointerdown", 400)); + }); + expect(standing(elsewhere.item)).toEqual(["rest", "0px", false]); + const onRow = swiped(); + wheel(onRow.item, -220); + act(() => { + onRow.press.dispatchEvent(pointer("pointerdown", 10)); + }); + frameAt(1_000); + frameAt(1_200); + frameAt(1_400); + expect([elsewhere.acted, onRow.acted]).toEqual([[], []]); +}); + +test("a wheel more up or down than sideways is the list's to scroll while a trackpad's swipe is under way, and leaves the row where it stands", () => { + const { wheel } = trackpad(); + const { item } = swiped(); + expect(wheel(item, -40)).toBe(true); + expect(wheel(item, 0, 40)).toBe(false); + expect(wheel(item, -5, 30)).toBe(false); + expect(standing(item)).toEqual(["moving", "40px", false]); +}); diff --git a/packages/ui/src/components/list-row/use-row-swipe.ts b/packages/ui/src/components/list-row/use-row-swipe.ts index 6838337aee..052ca47e2d 100644 --- a/packages/ui/src/components/list-row/use-row-swipe.ts +++ b/packages/ui/src/components/list-row/use-row-swipe.ts @@ -94,6 +94,9 @@ interface Wheel { frame: number; } +/** What a press lands on that is a control of its own rather than the row's face: the act's button, or one of the row's acts. */ +const ROW_CONTROLS = "[data-swipe-act], .luke-list-row-actions"; + /** Whether a release at `offset` on a row `width` wide acts. */ function acts(offset: number, width: number): boolean { return width > 0 && offset >= width * SWIPE.ACT_SHARE; @@ -135,7 +138,10 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip latest.current = { act, onAct }; }); + // Note that closing lets go of a trackpad's swipe still waiting for its wheel to rest, so its frame can never settle it after. const close = () => { + if (wheel.current !== undefined) cancelAnimationFrame(wheel.current.frame); + wheel.current = undefined; setOffset(0); setState(SWIPE_STATE.REST); }; @@ -158,10 +164,11 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip width.current = ref.current?.getBoundingClientRect().width ?? 0; }; - // An open row closes on a press or a wheel anywhere else, as another row's swipe begins. + // A row open, or out on a trackpad's swipe, closes on a press or a wheel anywhere else, as another row's swipe begins. const open = state === SWIPE_STATE.OPEN; + const out = state !== SWIPE_STATE.REST; useEffect(() => { - if (!open) return; + if (!out) return; const elsewhere = (event: Event) => { if (event.target instanceof Node && ref.current?.contains(event.target)) return; close(); @@ -172,7 +179,7 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip document.removeEventListener("pointerdown", elsewhere, true); document.removeEventListener("wheel", elsewhere, true); }; - }, [open]); + }, [out]); // An open row whose act changed under it closes, so its button never offers what the row no longer does. useEffect(() => { if (open && openedOn.current !== act) close(); @@ -210,7 +217,8 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip const wheelMoved = (event: WheelEvent) => { const current = latest.current.act; if (current === undefined || press.current !== undefined) return; - if (wheel.current === undefined && Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + // A wheel more up or down than sideways is the list's to scroll, a swipe under way or not, and leaves the swipe's rest uncounted. + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; event.preventDefault(); if (wheel.current === undefined) { measure(); @@ -239,11 +247,12 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip ref, onPointerDown: (event) => { swallow.current = false; + // A press interrupts a trackpad's swipe, which then does nothing. + if (wheel.current !== undefined) close(); const current = latest.current.act; if (current === undefined || event.button !== 0) return; - if (event.target instanceof Element && event.target.closest("[data-swipe-act]") !== null) { - return; - } + // Note that a press on the act's button or on the row's own acts is theirs, never a swipe's, so an open row's acts still take their click. + if (event.target instanceof Element && event.target.closest(ROW_CONTROLS) !== null) return; press.current = { pointerId: event.pointerId, x: event.clientX, From 3fbc26edae9537e45f456defb2e10aeec65b3b42 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:03:23 +0000 Subject: [PATCH 3/4] fix(ui): a pointer's swipe interrupted from elsewhere does nothing when let go Closing a row lets go of a pointer's swipe still held, releasing its capture and swallowing its release's click, so a press or a wheel elsewhere mid-drag leaves the act undone. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/components/list-row/list-row.test.ts | 23 +++++++++++++++++++ .../src/components/list-row/use-row-swipe.ts | 11 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/list-row/list-row.test.ts b/packages/ui/src/components/list-row/list-row.test.ts index 60ab76981d..fa7547efce 100644 --- a/packages/ui/src/components/list-row/list-row.test.ts +++ b/packages/ui/src/components/list-row/list-row.test.ts @@ -615,3 +615,26 @@ test("a wheel more up or down than sideways is the list's to scroll while a trac expect(wheel(item, -5, 30)).toBe(false); expect(standing(item)).toEqual(["moving", "40px", false]); }); + +test("a pointer's swipe interrupted by a press or a wheel elsewhere does nothing when it is let go, and opens nothing", () => { + for (const interrupt of ["pointerdown", "wheel"] as const) { + const { item, press, acted, opened } = swiped(); + drag(press, 20, 200); + expect(standing(item)).toEqual(["moving", "200px", true]); + act(() => { + document.body.dispatchEvent( + interrupt === "wheel" + ? new WheelEvent("wheel", { bubbles: true, deltaX: 0, deltaY: 30 }) + : new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 400, + pointerId: 2, + }), + ); + }); + expect(standing(item)).toEqual(["rest", "0px", false]); + release(press, 200); + expect([acted, opened]).toEqual([[], []]); + } +}); diff --git a/packages/ui/src/components/list-row/use-row-swipe.ts b/packages/ui/src/components/list-row/use-row-swipe.ts index 052ca47e2d..67a2470d4e 100644 --- a/packages/ui/src/components/list-row/use-row-swipe.ts +++ b/packages/ui/src/components/list-row/use-row-swipe.ts @@ -138,10 +138,19 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip latest.current = { act, onAct }; }); - // Note that closing lets go of a trackpad's swipe still waiting for its wheel to rest, so its frame can never settle it after. + // Note that closing lets go of a trackpad's swipe still waiting for its wheel to rest, so its frame can never settle it after, + // and of a pointer's swipe still held, so its release does nothing and its click opens nothing. const close = () => { if (wheel.current !== undefined) cancelAnimationFrame(wheel.current.frame); wheel.current = undefined; + const held = press.current; + press.current = undefined; + if (held?.swiping === true) { + swallow.current = true; + if (ref.current?.hasPointerCapture?.(held.pointerId) === true) { + ref.current.releasePointerCapture(held.pointerId); + } + } setOffset(0); setState(SWIPE_STATE.REST); }; From 8acf4bd774c2ffd0fc76cf3313a67b1e894246fa Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:05:37 +0000 Subject: [PATCH 4/4] fix(ui): a swipe never swallows a click the keyboard makes Only a pointer's click follows a swipe's release; a keyboard's click, which no press came before, always lands, so an interrupted swipe let go off its row leaves the row's Enter working. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../renderer/desktop/desktop-inbox.test.ts | 2 +- .../src/components/list-row/list-row.test.ts | 22 +++++++++++++++++-- .../src/components/list-row/use-row-swipe.ts | 2 ++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts b/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts index 5012af1ff7..c191f04254 100644 --- a/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts @@ -3497,7 +3497,7 @@ test("a row swiped right marks its item: a long swipe marks an unread item read press.dispatchEvent(at("pointermove", 30)); press.dispatchEvent(at("pointermove", 10 + to)); press.dispatchEvent(at("pointerup", 10 + to)); - press.click(); + press.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); }); swipe(220); await settle(); diff --git a/packages/ui/src/components/list-row/list-row.test.ts b/packages/ui/src/components/list-row/list-row.test.ts index fa7547efce..27f99c6d27 100644 --- a/packages/ui/src/components/list-row/list-row.test.ts +++ b/packages/ui/src/components/list-row/list-row.test.ts @@ -396,11 +396,16 @@ function drag(target: HTMLElement, ...steps: number[]): void { } } +/** The click a pointer's release makes, a press's first, which a keyboard's click is not. */ +function pointerClick(): MouseEvent { + return new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, detail: 1 }); +} + /** The pointer let go at `dx` right of where it pressed, and the click its release makes on `target`. */ function release(target: HTMLElement, dx: number): void { act(() => { target.dispatchEvent(pointer("pointerup", 10 + dx)); - target.click(); + target.dispatchEvent(pointerClick()); }); } @@ -583,7 +588,7 @@ test("an open row's own acts take their click, the row's swipe doing nothing", ( act(() => { mark.dispatchEvent(pointer("pointerdown", 250)); mark.dispatchEvent(pointer("pointerup", 250)); - mark.click(); + mark.dispatchEvent(pointerClick()); }); expect([acted, opened]).toEqual([["hover mark"], []]); }); @@ -638,3 +643,16 @@ test("a pointer's swipe interrupted by a press or a wheel elsewhere does nothing expect([acted, opened]).toEqual([[], []]); } }); + +test("a pointer's swipe interrupted and let go off the row leaves no click to swallow, so the keyboard still opens the row", () => { + const { item, press, acted, opened } = swiped(); + drag(press, 20, 200); + act(() => { + document.body.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaX: 0, deltaY: 30 })); + document.body.dispatchEvent(pointer("pointerup", 500)); + }); + expect(standing(item)).toEqual(["rest", "0px", false]); + // Note that a keyboard's click comes with no press before it, and a detail of 0. + act(() => press.click()); + expect([acted, opened]).toEqual([[], ["open"]]); +}); diff --git a/packages/ui/src/components/list-row/use-row-swipe.ts b/packages/ui/src/components/list-row/use-row-swipe.ts index 67a2470d4e..1cbbbcba40 100644 --- a/packages/ui/src/components/list-row/use-row-swipe.ts +++ b/packages/ui/src/components/list-row/use-row-swipe.ts @@ -315,6 +315,8 @@ export function useRowSwipe(act: string | undefined, onAct: () => void): RowSwip onClickCapture: (event) => { if (!swallow.current) return; swallow.current = false; + // Note that only a pointer's click is a swipe's to swallow; a click the keyboard makes, which no press came before, always lands. + if (event.detail === 0) return; event.preventDefault(); event.stopPropagation(); },