Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/red-cobras-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackoverflow/stacks-svelte": patch
---

extract the vote state logic in an opt in svelte module
12 changes: 0 additions & 12 deletions packages/stacks-svelte/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,5 @@ const config: StorybookConfig = {
},
},
},
viteFinal: (config) => {
// Include svelte-sonner in optimizeDeps to ensure its virtual CSS modules
// (from <style global> blocks) are properly processed by vite-plugin-svelte.
// Without this, Storybook fails to load the Toaster component styles.
// Note: svelte-sonner is excluded in vite.config.ts for test compatibility,
// but Storybook needs it included for proper CSS handling.
config!.optimizeDeps!.exclude = config?.optimizeDeps?.exclude?.filter(
(dep) => dep !== "svelte-sonner"
);

return config;
},
};
export default config;
2 changes: 1 addition & 1 deletion packages/stacks-svelte/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import pluginChaiFriendly from "eslint-plugin-chai-friendly";
export default [
eslint.configs.recommended,
{
files: ["**/*.svelte", "*.svelte"],
files: ["**/*.svelte", "*.svelte", "**/*.svelte.ts", "*.svelte.ts"],
languageOptions: {
parser: svelteParser,
parserOptions: {
Expand Down
49 changes: 47 additions & 2 deletions packages/stacks-svelte/src/components/Vote/Vote.stories.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts" module>
import { defineMeta } from "@storybook/addon-svelte-csf";
import Vote, { type Status } from "./Vote.svelte";
import { createVoteState } from "./vote-state.svelte";

const VoteStatuses: Status[] = [null, "upvoted", "downvoted"];

Expand All @@ -17,8 +18,8 @@
</script>

<Story name="Base">
{#snippet template(args)}
<Vote {...args} />
{#snippet template({ total, ...args })}
<Vote total={total ?? 0} {...args} />
{/snippet}
</Story>

Expand Down Expand Up @@ -70,3 +71,47 @@
</div>
{/snippet}
</Story>

<Story name="With State Utility" asChild>
{@const voteState = createVoteState({
total: 42,
upvotes: 50,
downvotes: 8,
onUpvote: async () => {
// Simulate server delay
await new Promise((resolve) => setTimeout(resolve, 300));
console.log("Upvote sent to server");
},
onDownvote: async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
console.log("Downvote sent to server");
},
})}
<div class="d-flex fd-column g16">
<Vote {...voteState} />
<p class="fs-caption fc-medium">
Click the vote buttons - state is managed automatically with
optimistic updates
</p>
</div>
</Story>

<Story name="With Error Rollback" asChild>
{@const voteState = createVoteState({
total: 10,
onUpvote: async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
throw new Error("Server error - vote rolled back");
},
onDownvote: async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
throw new Error("Server error - vote rolled back");
},
})}
<div class="d-flex fd-column g16">
<Vote {...voteState} />
<p class="fs-caption fc-medium">
Votes will rollback after 500ms (simulated server error)
</p>
</div>
</Story>
61 changes: 11 additions & 50 deletions packages/stacks-svelte/src/components/Vote/Vote.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,13 @@
i18nExpanded?: string | undefined;
/**
* Callback fired when the upvote button is clicked.
* Should return a promise that resolves on success.
*/
onupvote?: () => Promise<void>;
onupvoteclick?: () => void;

/**
* Callback fired when the downvote button is clicked.
* Should return a promise that resolves on success.
*/
ondownvote?: () => Promise<void>;
ondownvoteclick?: () => void;

/**
* Additional CSS classes added to the element
Expand All @@ -97,8 +95,8 @@

const {
total,
upvotes = undefined,
downvotes = undefined,
upvotes,
downvotes,
horizontal,
upvoteOnly = false,
status = null,
Expand All @@ -109,8 +107,8 @@
i18nDownvoted = "Downvoted",
i18nExpand = "Show vote details",
i18nExpanded = "Hide vote details",
onupvote = () => Promise.resolve(),
ondownvote = () => Promise.resolve(),
onupvoteclick = () => {},
ondownvoteclick = () => {},
class: className = "",
}: Props = $props();

Expand Down Expand Up @@ -140,39 +138,7 @@
let expandable = $derived(
upvotes !== undefined && downvotes !== undefined && !horizontal
);
// svelte-ignore state_referenced_locally
// Props are intentionally captured as initial values. The component uses optimistic updates
// for vote actions, so local state may temporarily diverge from props during async operations.
let currentCount = $state(total || 0);
// svelte-ignore state_referenced_locally
let currentStatus = $state(status);
const classes = $derived(getClasses(className, expanded, horizontal));

async function handleVote(
type: "upvoted" | "downvoted",
callback: () => Promise<void>
) {
const previousCount = currentCount;
const previousStatus = currentStatus;
const increment = type === "upvoted" ? 1 : -1;

if (currentStatus === type) {
currentStatus = null;
currentCount -= increment;
} else {
currentCount += currentStatus ? increment * 2 : increment;
currentStatus = type;
}

try {
await callback();
} catch {
// Revert on failure
currentCount = previousCount;
currentStatus = previousStatus;
// Don't re-throw - the error should be handled by the callback if needed
}
}
</script>

{#snippet votes()}
Expand All @@ -186,9 +152,7 @@
<span class="s-vote--upvotes">+{formatCount(upvotes, 1000)}</span>
{/if}
<span class="s-vote--total">
{currentCount !== 0 || currentStatus !== null
? formatCount(currentCount, 1000)
: i18nVote}
{total !== 0 ? formatCount(total, 1000) : i18nVote}
</span>
{#if downvotes !== undefined}
<span class="s-vote--downvotes"
Expand All @@ -204,8 +168,8 @@
{/snippet}

<div class={classes}>
<button class="s-vote--btn" onclick={() => handleVote("upvoted", onupvote)}>
{#if currentStatus === "upvoted"}
<button class="s-vote--btn" onclick={onupvoteclick}>
{#if status === "upvoted"}
<Icon src={IconVote16UpFill} />
<span class="v-visible-sr">{i18nUpvoted}</span>
{:else}
Expand All @@ -218,11 +182,8 @@
</button>
{#if !upvoteOnly}
{@render votes()}
<button
class="s-vote--btn"
onclick={() => handleVote("downvoted", ondownvote)}
>
{#if currentStatus === "downvoted"}
<button class="s-vote--btn" onclick={ondownvoteclick}>
{#if status === "downvoted"}
<Icon src={IconVote16DownFill} />
<span class="v-visible-sr">{i18nDownvoted}</span>
{:else}
Expand Down
86 changes: 50 additions & 36 deletions packages/stacks-svelte/src/components/Vote/Vote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
import sinon from "sinon";

import Vote from "./Vote.svelte";
import { createVoteState } from "./vote-state.svelte";

describe("Vote", () => {
it("should render the vote component", () => {
Expand Down Expand Up @@ -88,93 +89,103 @@ describe("Vote", () => {
expect(svg).to.exist;
});

it("should call onupvote handler when upvote button is clicked", async () => {
const onupvote = sinon.stub().resolves();
render(Vote, { total: 12, onupvote });
it("should call onupvoteclick handler when upvote button is clicked", async () => {
const onupvoteclick = sinon.stub();
render(Vote, { total: 12, onupvoteclick });

const user = userEvent.setup();
const upvoteBtn = screen.getAllByRole("button")[0];

await user.click(upvoteBtn);
await tick();

expect(onupvote).to.have.been.calledOnce;
expect(onupvoteclick).to.have.been.calledOnce;
});

it("should call ondownvote handler when downvote button is clicked", async () => {
const ondownvote = sinon.stub().resolves();
render(Vote, { total: 12, ondownvote });
it("should call ondownvoteclick handler when downvote button is clicked", async () => {
const ondownvoteclick = sinon.stub();
render(Vote, { total: 12, ondownvoteclick });

const user = userEvent.setup();
const downvoteBtn = screen.getAllByRole("button")[1];

await user.click(downvoteBtn);
await tick();

expect(ondownvote).to.have.been.calledOnce;
expect(ondownvoteclick).to.have.been.calledOnce;
});

it("should update count when upvoting", async () => {
const onupvote = sinon.stub().resolves();
render(Vote, { total: 12, onupvote });
it("should update count when upvoting with state utility", async () => {
const onUpvote = sinon.stub().resolves();
const voteState = createVoteState({ total: 12, onUpvote });
render(Vote, voteState);

const user = userEvent.setup();
const upvoteBtn = screen.getAllByRole("button")[0];

await user.click(upvoteBtn);
await tick();

expect(screen.getByText("13")).to.exist;
expect(voteState.total).to.equal(13);
expect(voteState.status).to.equal("upvoted");
});

it("should update count when downvoting", async () => {
const ondownvote = sinon.stub().resolves();
render(Vote, { total: 12, ondownvote });
it("should update count when downvoting with state utility", async () => {
const onDownvote = sinon.stub().resolves();
const voteState = createVoteState({ total: 12, onDownvote });
render(Vote, voteState);

const user = userEvent.setup();
const downvoteBtn = screen.getAllByRole("button")[1];

await user.click(downvoteBtn);
await tick();

expect(screen.getByText("11")).to.exist;
expect(voteState.total).to.equal(11);
expect(voteState.status).to.equal("downvoted");
});

it("should revert count on error", async () => {
const onupvote = sinon.stub().rejects(new Error("Failed"));
render(Vote, { total: 12, onupvote });
it("should revert count on error with state utility", async () => {
const onUpvote = sinon.stub().rejects(new Error("Failed"));
const voteState = createVoteState({ total: 12, onUpvote });
render(Vote, voteState);

const user = userEvent.setup();
const upvoteBtn = screen.getAllByRole("button")[0];

await user.click(upvoteBtn);
await tick();

expect(screen.getByText("12")).to.exist;
expect(voteState.total).to.equal(12);
expect(voteState.status).to.equal(null);
});

it("should toggle vote when clicking same button again", async () => {
const onupvote = sinon.stub().resolves();
render(Vote, { total: 12, onupvote });
it("should toggle vote when clicking same button again with state utility", async () => {
const onUpvote = sinon.stub().resolves();
const voteState = createVoteState({ total: 12, onUpvote });
render(Vote, voteState);

const user = userEvent.setup();
const upvoteBtn = screen.getAllByRole("button")[0];

// First click - upvote
await user.click(upvoteBtn);
await tick();
expect(screen.getByText("13")).to.exist;
expect(voteState.total).to.equal(13);
expect(voteState.status).to.equal("upvoted");

// Second click - remove upvote
await user.click(upvoteBtn);
await tick();
expect(screen.getByText("12")).to.exist;
expect(voteState.total).to.equal(12);
expect(voteState.status).to.equal(null);
});

it("should switch from upvote to downvote", async () => {
const onupvote = sinon.stub().resolves();
const ondownvote = sinon.stub().resolves();
render(Vote, { total: 12, onupvote, ondownvote });
it("should switch from upvote to downvote with state utility", async () => {
const onUpvote = sinon.stub().resolves();
const onDownvote = sinon.stub().resolves();
const voteState = createVoteState({ total: 12, onUpvote, onDownvote });
render(Vote, voteState);

const user = userEvent.setup();
const upvoteBtn = screen.getAllByRole("button")[0];
Expand All @@ -183,12 +194,14 @@ describe("Vote", () => {
// Upvote
await user.click(upvoteBtn);
await tick();
expect(screen.getByText("13")).to.exist;
expect(voteState.total).to.equal(13);
expect(voteState.status).to.equal("upvoted");

// Downvote (should change by 2)
await user.click(downvoteBtn);
await tick();
expect(screen.getByText("11")).to.exist;
expect(voteState.total).to.equal(11);
expect(voteState.status).to.equal("downvoted");
});

it("should apply custom class", () => {
Expand Down Expand Up @@ -219,17 +232,18 @@ describe("Vote", () => {
expect(screen.getByText("Expand votes")).to.exist;
});

it("should show count instead of 'Vote' text after voting on 0 count", async () => {
const onupvote = sinon.stub().resolves();
render(Vote, { total: 0, onupvote });
it("should show count instead of 'Vote' text after voting on 0 count with state utility", async () => {
const onUpvote = sinon.stub().resolves();
const voteState = createVoteState({ total: 0, onUpvote });
render(Vote, voteState);

const user = userEvent.setup();
const upvoteBtn = screen.getAllByRole("button")[0];

await user.click(upvoteBtn);
await tick();

expect(screen.getByText("1")).to.exist;
expect(screen.queryByText("Vote")).to.not.exist;
expect(voteState.total).to.equal(1);
expect(voteState.status).to.equal("upvoted");
});
});
Loading