diff --git a/web/src/lib/components/repo/StarButton.stories.svelte b/web/src/lib/components/repo/StarButton.stories.svelte index 75e244297..b2874a3a9 100644 --- a/web/src/lib/components/repo/StarButton.stories.svelte +++ b/web/src/lib/components/repo/StarButton.stories.svelte @@ -37,29 +37,29 @@ }; const starred = async ({ canvas }: PlayContext) => { - const button = await canvas.findByRole("button", { name: "Starred" }); + const button = await canvas.findByRole("button", { name: "Unstar" }); await waitFor(() => expect(button).toBeEnabled()); }; - // clicking star: the icon swaps to a spinner while the write is in flight, - // then settles on "Starred" with the filled star icon + // clicking star: the icon swaps to a spinner while the write is in flight, then the + // star swaps to star-off and the label crossfades to "Unstar" const starring = async ({ canvas }: PlayContext) => { const button = await canvas.findByRole("button", { name: "Star" }); await userEvent.click(button); - await waitFor(() => expect(canvas.getByRole("button", { name: "Starred" })).toBeVisible()); + await waitFor(() => expect(canvas.getByRole("button", { name: "Unstar" })).toBeVisible()); }; - // a hung write: the button stays on "Star" with the icon replaced by a spinner + // a hung write: the button commits to "Unstar" straight away and stays there while the + // write is still out, with only aria-busy reporting that it has not settled const starringLoading = async ({ canvas }: PlayContext) => { const button = await canvas.findByRole("button", { name: "Star" }); await userEvent.click(button); - await waitFor(() => expect(button).toHaveAttribute("aria-busy", "true")); - await expect(button).toHaveTextContent("Star"); - await expect(button).toBeDisabled(); + await waitFor(() => expect(button).toHaveAccessibleName("Unstar")); + await expect(button).toHaveAttribute("aria-busy", "true"); }; const unstarring = async ({ canvas }: PlayContext) => { - const button = await canvas.findByRole("button", { name: "Starred" }); + const button = await canvas.findByRole("button", { name: "Unstar" }); await userEvent.click(button); await waitFor(() => expect(canvas.getByRole("button", { name: "Star" })).toBeVisible()); }; diff --git a/web/src/lib/components/repo/StarButton.svelte b/web/src/lib/components/repo/StarButton.svelte index 215d6ca78..143f17b00 100644 --- a/web/src/lib/components/repo/StarButton.svelte +++ b/web/src/lib/components/repo/StarButton.svelte @@ -2,18 +2,20 @@ import { resolve } from "$app/paths"; import { page } from "$app/state"; import Star from "$icon/star"; + import StarOff from "$icon/star-off"; import TriangleAlert from "$icon/triangle-alert"; import { getAuth } from "$lib/auth.svelte"; import { loginWithReturn } from "$lib/auth/guards"; import { createStar, deleteStar } from "$lib/api/graph"; import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; - import Spinner from "$lib/components/ui/Spinner.svelte"; import Tooltip from "$lib/components/ui/Tooltip.svelte"; import { getProfileCounts } from "$lib/components/profile/counts.svelte"; import { createOptimisticRelation, createOptimisticCount } from "$lib/optimistic.svelte"; import { formatCount } from "$lib/format"; import Bones from "$lib/components/ui/Bones.svelte"; + import { CROSSFADE_DURATION, CROSSFADE_EASING } from "$lib/motion/spring"; + import { prefersReducedMotion } from "svelte/motion"; import uiLoadingprimitivesCount from "$lib/bones/ui-loadingprimitives--count.bones.json"; import type { StarChange } from "$lib/components/profile/types"; import type { RepoCounts } from "./types"; @@ -99,35 +101,35 @@ resolve(loginWithReturn(page.url.pathname + page.url.search) as "/login") ); const starsHref = $derived(resolve(`/${repoOwnerHandle}/${repoName}/stars` as "/")); + const fade = $derived(prefersReducedMotion.current ? 0 : CROSSFADE_DURATION); + const face = $derived(failed ? "err" : starred ? "on" : "off"); const toggle = async () => { const agent = auth.agent; if (!agent || !relation.known || relation.loading || !repoDid) return; - relation.begin(); + // every read happens before begin(), which flips the relation to the intended state + const wasStarred = relation.active; + const existingRkey = relation.rkey; + const delta = wasStarred ? -1 : 1; + relation.begin(!wasStarred); starCount.resetFailure(); + starCount.adjust(delta); + profileCounts?.adjust(agent.sub, "stars", delta); try { - if (relation.active && relation.rkey) { - await deleteStar(agent, relation.rkey); + if (wasStarred && existingRkey) { + await deleteStar(agent, existingRkey); relation.deleted(); - starCount.adjust(-1); - profileCounts?.adjust(agent.sub, "stars", -1); - onCommit?.({ - viewerDid: agent.sub, - repoDid, - starred: false, - rkey: null, - delta: -1 - }); + onCommit?.({ viewerDid: agent.sub, repoDid, starred: false, rkey: null, delta }); } else { const rkey = await createStar(agent, repoDid); relation.created(rkey); - starCount.adjust(1); - profileCounts?.adjust(agent.sub, "stars", 1); - onCommit?.({ viewerDid: agent.sub, repoDid, starred: true, rkey, delta: 1 }); + onCommit?.({ viewerDid: agent.sub, repoDid, starred: true, rkey, delta }); } } catch { relation.fail(); + starCount.adjust(-delta); starCount.fail(); + profileCounts?.adjust(agent.sub, "stars", -delta); } }; @@ -140,22 +142,22 @@ size="small" {insetShadow} href={signedIn ? undefined : loginHref} - disabled={signedIn && (!relation.known || !repoDid || relation.loading)} + disabled={signedIn && (!relation.known || !repoDid)} aria-busy={relation.loading} onclick={signedIn ? toggle : undefined} - class="flex-1" + class="min-w-20 flex-1" + style="--swap: {fade}ms; --swap-ease: {CROSSFADE_EASING}" > - {#if relation.loading} - - {:else if failed} -