diff --git a/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts b/apps/desktop/src/renderer/desktop/desktop-inbox.test.ts index ca8f9d2b8c..c191f04254 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.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); + }); + 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..27f99c6d27 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,330 @@ 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"), + actions: createElement(IconButton, { + icon: glyph(), + label: "Mark as read", + onClick: () => acted.push("hover mark"), + }), + 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 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.dispatchEvent(pointerClick()); + }); +} + +/** 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([[], []]); +}); + +/** 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); + const frameAt = (time: number) => + act(() => { + for (const frame of frames.splice(0)) frame(time); + }); + const wheel = (item: HTMLElement, deltaX: number, deltaY = 0): boolean => { + const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX, deltaY }); + act(() => { + 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]); + 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(); +}); + +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.dispatchEvent(pointerClick()); + }); + 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]); +}); + +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([[], []]); + } +}); + +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/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..1cbbbcba40 --- /dev/null +++ b/packages/ui/src/components/list-row/use-row-swipe.ts @@ -0,0 +1,330 @@ +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; +} + +/** 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; +} + +/** 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 }; + }); + + // 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); + }; + // 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; + }; + + // 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 (!out) 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); + }; + }, [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(); + }); + // 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; + // 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(); + 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; + // 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; + // 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, + 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; + // 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(); + }, + }, + onPress: () => { + if (latest.current.act === undefined) return; + latest.current.onAct(); + close(); + }, + }; +}