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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions apps/desktop/src/renderer/desktop/desktop-inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -467,9 +467,9 @@ function wordsOf(element: Element | null | undefined): string | null {
}

function rowNamed(page: HTMLElement, title: string): HTMLButtonElement {
const row = [...page.querySelectorAll<HTMLButtonElement>(".inbox-tasks li > button")].find(
(each) => each.querySelector(ROW_TITLE)?.textContent === title,
);
const row = [
...page.querySelectorAll<HTMLButtonElement>(".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;
}
Expand Down Expand Up @@ -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<HTMLButtonElement>("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",
);
});
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/desktop/desktop-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,9 @@ async function narrowTaskInbox(
...tab.querySelectorAll<HTMLButtonElement>(".inbox-view-items > .desktop-toolbar button"),
].find((button) => button.textContent === "Inbox") ?? null,
row: (title: string) =>
[...tab.querySelectorAll<HTMLButtonElement>(".inbox-tasks li > button")].find((each) =>
each.textContent?.includes(title),
),
[
...tab.querySelectorAll<HTMLButtonElement>(".inbox-tasks li button.luke-list-row-main"),
].find((each) => each.textContent?.includes(title)),
unmount: () => {
act(() => root.unmount());
container.remove();
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/renderer/tasks/inbox-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ? <MailOpenIcon /> : <MailIcon />,
onAct: () => onMark(listed, !unread),
}
}
current={chosen ? LIST_ROW_CURRENT.PAGE : undefined}
unread={unread}
hint={{ Hint: Tooltip, label: title }}
Expand Down
117 changes: 113 additions & 4 deletions packages/ui/src/components/list-row/list-row.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -226,17 +226,126 @@
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;
}

.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);
}
}
Loading
Loading