diff --git a/.changeset/eight-pants-worry.md b/.changeset/eight-pants-worry.md
new file mode 100644
index 0000000000..8fac4e436d
--- /dev/null
+++ b/.changeset/eight-pants-worry.md
@@ -0,0 +1,6 @@
+---
+"@stackoverflow/stacks": minor
+"@stackoverflow/stacks-svelte": minor
+---
+
+Add Vote component
diff --git a/package-lock.json b/package-lock.json
index e6cd867df2..39269c1be5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -75,7 +75,7 @@
"storybook": "^9.1.5",
"stylelint": "^16.25.0",
"stylelint-config-recommended": "^16.0.0",
- "stylelint-config-standard": "^38.0.0",
+ "stylelint-config-standard": "^39.0.1",
"svelte": "^5.39.11",
"svelte-check": "^4.3.3",
"svelte-preprocess": "^6.0.3",
@@ -15621,9 +15621,9 @@
}
},
"node_modules/stylelint-config-standard": {
- "version": "38.0.0",
- "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-38.0.0.tgz",
- "integrity": "sha512-uj3JIX+dpFseqd/DJx8Gy3PcRAJhlEZ2IrlFOc4LUxBX/PNMEQ198x7LCOE2Q5oT9Vw8nyc4CIL78xSqPr6iag==",
+ "version": "39.0.1",
+ "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-39.0.1.tgz",
+ "integrity": "sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==",
"dev": true,
"funding": [
{
@@ -15637,13 +15637,36 @@
],
"license": "MIT",
"dependencies": {
- "stylelint-config-recommended": "^16.0.0"
+ "stylelint-config-recommended": "^17.0.0"
},
"engines": {
"node": ">=18.12.0"
},
"peerDependencies": {
- "stylelint": "^16.18.0"
+ "stylelint": "^16.23.0"
+ }
+ },
+ "node_modules/stylelint-config-standard/node_modules/stylelint-config-recommended": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-17.0.0.tgz",
+ "integrity": "sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^16.23.0"
}
},
"node_modules/stylelint/node_modules/balanced-match": {
diff --git a/package.json b/package.json
index 52be7f5aa0..2088d3bf83 100644
--- a/package.json
+++ b/package.json
@@ -85,7 +85,7 @@
"storybook": "^9.1.5",
"stylelint": "^16.25.0",
"stylelint-config-recommended": "^16.0.0",
- "stylelint-config-standard": "^38.0.0",
+ "stylelint-config-standard": "^39.0.1",
"svelte": "^5.39.11",
"svelte-check": "^4.3.3",
"svelte-preprocess": "^6.0.3",
diff --git a/packages/stacks-classic/lib/components/vote/vote.a11y.test.ts b/packages/stacks-classic/lib/components/vote/vote.a11y.test.ts
new file mode 100644
index 0000000000..c107011c2d
--- /dev/null
+++ b/packages/stacks-classic/lib/components/vote/vote.a11y.test.ts
@@ -0,0 +1,59 @@
+import { runA11yTests } from "../../test/a11y-test-utils";
+import {
+ IconVote16Up,
+ IconVote16Down,
+} from "@stackoverflow/stacks-icons/icons";
+import "../../index";
+
+const children = {
+ default: `
+
+ ${IconVote16Up}
+ upvote
+
+
+ 12
+ +20
+ -8
+
+
+ ${IconVote16Down}
+ downvote
+
+ `,
+ upvoteOnly: `
+
+ ${IconVote16Up}
+ upvote
+
+
+ 12
+ +20
+ -8
+
+ `,
+};
+
+describe("vote", () => {
+ runA11yTests({
+ baseClass: "s-vote",
+ modifiers: {
+ primary: ["expanded"],
+ },
+ children: {
+ default: children.default,
+ },
+ });
+
+ // Horizontal with and without downvote
+ runA11yTests({
+ baseClass: "s-vote",
+ modifiers: {
+ primary: ["horizontal"],
+ },
+ options: {
+ includeNullModifier: false,
+ },
+ children,
+ });
+});
diff --git a/packages/stacks-classic/lib/components/vote/vote.less b/packages/stacks-classic/lib/components/vote/vote.less
new file mode 100644
index 0000000000..284c0b589c
--- /dev/null
+++ b/packages/stacks-classic/lib/components/vote/vote.less
@@ -0,0 +1,134 @@
+.s-vote {
+ --_vo-fd: column;
+ --_vo-child-bg: var(--black-150);
+ --_vo-child-br: unset;
+ --_vo-child-fd: var(--_vo-fd);
+ --_vo-child-g: calc(var(--su8) + var(--su2)); // 10px
+ --_vo-child-h: unset;
+ --_vo-child-w: calc(var(--su48) + var(--su2)); // 50px
+ --_vo-child-p: unset;
+
+ // CHILD ELEMENTS
+ &:not(&__horizontal){
+ :first-child {
+ --_vo-child-p: calc(var(--su12) + var(--su2)) 0 calc(var(--su12) - var(--su2)); // 14px 0 10px
+ --_vo-child-br: var(--br-pill) var(--br-pill) 0 0;
+ }
+
+ :last-child {
+ --_vo-child-p: calc(var(--su12) - var(--su2)) 0 calc(var(--su12) + var(--su2)); // 10px 0 14px
+ --_vo-child-br: 0 0 var(--br-pill) var(--br-pill);
+ }
+
+ :only-child {
+ --_vo-child-br: var(--br-pill);
+ --_vo-child-g: calc(var(--su16) + var(--su4)); // 18px
+ --_vo-child-p: calc(var(--su12) + var(--su2)) 0; // 14px 0
+ }
+ }
+
+ // MODIFIERS
+ &&__expanded {
+ --_vo-child-g: var(--su2);
+ --_vo-child-p: 0;
+
+ .s-vote {
+ &--total {
+ display: none;
+ }
+ &--upvotes,
+ &--downvotes {
+ display: block;
+ }
+ }
+ }
+
+ &&__horizontal {
+ --_vo-fd: row;
+ --_vo-child-h: var(--su32);
+ --_vo-child-p: 0 var(--su4);
+ --_vo-child-w: unset;
+
+ :first-child {
+ --_vo-child-p: 0 var(--su6) 0 calc(var(--su8) + var(--su2)); // 0 6px 0 10px
+ --_vo-child-br: var(--br-pill) 0 0 var(--br-pill);
+ }
+
+ :last-child {
+ --_vo-child-p: 0 calc(var(--su8) + var(--su2)) 0 var(--su6); // 0 10px 0 6px
+ --_vo-child-br: 0 var(--br-pill) var(--br-pill) 0;
+ }
+
+ .s-vote--votes:last-child:not(:only-child) {
+ --_vo-child-p: 0 calc(var(--su12) + var(--su2)) 0 var(--su4); // 0 14px 0 4px
+ }
+
+ :only-child {
+ --_vo-child-br: var(--br-pill);
+ --_vo-child-g: calc(var(--su8) + var(--su2)); // 10px
+ --_vo-child-p: 0 calc(var(--su12) + var(--su2)) 0 calc(var(--su8) + var(--su2)); // 0 10px
+ }
+ }
+
+ // CHILD ELEMENTS
+ > button {
+ // Reset button styles
+ appearance: none;
+ -webkit-appearance: none;
+ background: none;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ margin: 0;
+ padding: 0;
+ }
+
+ & &--btn,
+ & > &--votes {
+ background-color: var(--_vo-child-bg);
+ border-radius: var(--_vo-child-br);
+ flex-direction: var(--_vo-child-fd);
+ gap: var(--_vo-child-g);
+ height: var(--_vo-child-h);
+ padding: var(--_vo-child-p);
+ width: var(--_vo-child-w);
+
+ align-items: center;
+ display: inline-flex;
+ justify-content: center;
+ overflow: hidden;
+ font-weight: 600;
+ text-align: center;
+ white-space: nowrap;
+ }
+
+ & &--upvotes,
+ & &--downvotes {
+ display: none;
+ }
+
+ & &--upvotes {
+ color: var(--green-500);
+ }
+
+ & &--downvotes {
+ color: var(--red-500);
+ }
+
+ // INTERACTION
+ > button,
+ & &--btn {
+ &:focus-visible {
+ .focus-styles(true, false);
+ }
+
+ &:hover {
+ --_vo-child-bg: var(--black-200);
+ }
+ }
+
+ flex-direction: var(--_vo-fd);
+
+ display: flex;
+}
diff --git a/packages/stacks-classic/lib/components/vote/vote.visual.test.ts b/packages/stacks-classic/lib/components/vote/vote.visual.test.ts
new file mode 100644
index 0000000000..badf453347
--- /dev/null
+++ b/packages/stacks-classic/lib/components/vote/vote.visual.test.ts
@@ -0,0 +1,71 @@
+import { runVisualTests } from "../../test/visual-test-utils";
+import { html } from "@open-wc/testing";
+import {
+ IconVote16Up,
+ IconVote16Down,
+} from "@stackoverflow/stacks-icons/icons";
+import "../../index";
+
+describe("vote", () => {
+ runVisualTests({
+ baseClass: "s-vote",
+ modifiers: {
+ primary: ["expanded"],
+ },
+ children: {
+ default: `
+
+ ${IconVote16Up}
+ upvote
+
+
+ 12
+ +20
+ -8
+
+
+ ${IconVote16Down}
+ downvote
+
+ `,
+ },
+ template: ({ component, testid }) => html`
+
+ ${component}
+
+ `,
+ });
+
+ // Horizontal with and without downvote
+ runVisualTests({
+ baseClass: "s-vote",
+ modifiers: {
+ primary: ["horizontal"],
+ },
+ options: {
+ includeNullModifier: false,
+ },
+ children: {
+ default: `
+
+ ${IconVote16Up}
+ upvote
+
+ +20
+
+
+ `,
+ },
+ template: ({ component, testid }) => html`
+
+ ${component}
+
+ `,
+ });
+});
diff --git a/packages/stacks-classic/lib/stacks-static.less b/packages/stacks-classic/lib/stacks-static.less
index 6079483265..0506c51c71 100644
--- a/packages/stacks-classic/lib/stacks-static.less
+++ b/packages/stacks-classic/lib/stacks-static.less
@@ -53,6 +53,7 @@
@import "components/topbar/topbar.less";
@import "components/uploader/uploader.less";
@import "components/user-card/user-card.less";
+@import "components/vote/vote.less";
// LESS CONSTANTS AND MIXINS
@import "exports/exports.less";
diff --git a/packages/stacks-classic/lib/tsconfig.json b/packages/stacks-classic/lib/tsconfig.json
index 1270c7b023..00d5eff093 100644
--- a/packages/stacks-classic/lib/tsconfig.json
+++ b/packages/stacks-classic/lib/tsconfig.json
@@ -5,7 +5,7 @@
"lib": ["dom", "es6", "dom.iterable", "scripthost"],
"outDir": "../dist",
"sourceMap": true,
- "moduleResolution": "node",
+ "moduleResolution": "bundler",
"skipLibCheck": true,
"declaration": true,
"allowSyntheticDefaultImports": true,
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark-expanded.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark-expanded.ico
new file mode 100644
index 0000000000..a83118aeba
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7ae50bf024c25dc32ef4a3140dc61d8b875e9c5c9da5aaf2d51bdce0d171bba
+size 2648
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark-horizontal.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark-horizontal.ico
new file mode 100644
index 0000000000..a54c476119
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d87a9ef75d9e2d412a7356d4a29b4787ea116b8ae960c047ddb014b3435d4027
+size 937
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark.ico
new file mode 100644
index 0000000000..d69ad7e6ea
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-dark.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:82eb7291bbb477599604c2e8639e558dfb05ac9beabc2d2536ad5586c89a557e
+size 2053
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark-expanded.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark-expanded.ico
new file mode 100644
index 0000000000..1b1e585f9c
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1be4a09d33cde37a27f4daa37c274aa4dbcf713f2820812ebc89aaa6948406a9
+size 2753
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark-horizontal.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark-horizontal.ico
new file mode 100644
index 0000000000..1e5a3f880c
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:41de16635f607b6257f604daea519c889e4cdbccb72ed20b313b8fdb3a5f58c5
+size 929
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark.ico
new file mode 100644
index 0000000000..05ccfe8484
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-dark.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a3d6201ed029ee406b1bb35be8f2e3386afd2d1d0d81535f821019d4e289a6fe
+size 2131
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light-expanded.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light-expanded.ico
new file mode 100644
index 0000000000..d4bb9f38ae
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ac482d6222ba3071023ccdc5bc814567b5a86b1c58cf693be19884d116258bd4
+size 2743
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light-horizontal.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light-horizontal.ico
new file mode 100644
index 0000000000..38f5297bd2
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:41e44d816237cc71935f610f00ee7dff3812fd793c66cf613a3fdf2faf7f08ec
+size 914
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light.ico
new file mode 100644
index 0000000000..d70946886f
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-highcontrast-light.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5c3969c34de0ed3079b92bece5295b9a3d2e15c94eb637639733afed34786b51
+size 2176
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light-expanded.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light-expanded.ico
new file mode 100644
index 0000000000..440c4c964b
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2c0dee5a72aa1cb51d2562a0a71516d92a858d7986382dfcbddd01f1bbc6d8d8
+size 2734
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light-horizontal.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light-horizontal.ico
new file mode 100644
index 0000000000..38f5297bd2
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:41e44d816237cc71935f610f00ee7dff3812fd793c66cf613a3fdf2faf7f08ec
+size 914
diff --git a/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light.ico b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light.ico
new file mode 100644
index 0000000000..d70946886f
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Chromium/baseline/s-vote-light.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5c3969c34de0ed3079b92bece5295b9a3d2e15c94eb637639733afed34786b51
+size 2176
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark-expanded.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark-expanded.ico
new file mode 100644
index 0000000000..9cbfcc1c20
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1d7bb1a519ecb334ef2d5d0cb7be69a8ec85226bc4bf16b21f548dc1827d7d7f
+size 3313
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark-horizontal.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark-horizontal.ico
new file mode 100644
index 0000000000..9ce0f4b2d0
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:19b669dff278db8345daae41d996106af137d3cdd493e11f992d0a53eddb04ad
+size 968
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark.ico
new file mode 100644
index 0000000000..5825e53293
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-dark.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:97a6148bad73d21452e3ba47cf226413a8dad61dc7ef1515eb45a93178163dd2
+size 2477
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark-expanded.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark-expanded.ico
new file mode 100644
index 0000000000..296cfb7d1f
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:921dbbe889521bc69393b2e0ac634142d2df1ee1435071cfd2b6af152de2d1b5
+size 3437
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark-horizontal.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark-horizontal.ico
new file mode 100644
index 0000000000..4a29fda14d
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:82dd8ac2bf8dd53fcb71f8cfc30d3a507bd319fcb22032c4e48fc72847b063de
+size 1042
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark.ico
new file mode 100644
index 0000000000..ec7715cf42
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-dark.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2e2c62c53393a3f7be6e12b98ebf934d5a882d7ad111070a08055d5491b20ed8
+size 2523
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light-expanded.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light-expanded.ico
new file mode 100644
index 0000000000..2d99491778
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8060263bd120d410acf0f7f1ff20b1beed2e9c59327d7c15269b256726432c39
+size 3468
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light-horizontal.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light-horizontal.ico
new file mode 100644
index 0000000000..c9870b0a45
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b2336be6233fad3d29854f5f292ca306ae4f84f03857ceaa1ccc9fa36ac0bb7
+size 1030
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light.ico
new file mode 100644
index 0000000000..569f6c913b
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-highcontrast-light.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:33b0651ef9699b41d89c294230c6dcbd5e544ec7671e5f7c21734ac933c0cf20
+size 2686
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light-expanded.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light-expanded.ico
new file mode 100644
index 0000000000..f9ae8a4700
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:423064eaa9a19831f261d8a4a1266272582dfafcb161b6112dd111c669477a64
+size 3443
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light-horizontal.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light-horizontal.ico
new file mode 100644
index 0000000000..c9870b0a45
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b2336be6233fad3d29854f5f292ca306ae4f84f03857ceaa1ccc9fa36ac0bb7
+size 1030
diff --git a/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light.ico b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light.ico
new file mode 100644
index 0000000000..569f6c913b
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Firefox/baseline/s-vote-light.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:33b0651ef9699b41d89c294230c6dcbd5e544ec7671e5f7c21734ac933c0cf20
+size 2686
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark-expanded.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark-expanded.ico
new file mode 100644
index 0000000000..a31d4fe2be
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:30066a07b2cdbaf63d51fb919f45b8b1c0406889be9ab38e41a9a3dc5cf8239a
+size 3135
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark-horizontal.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark-horizontal.ico
new file mode 100644
index 0000000000..244f48441e
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e3a0278cb43d0c767fd1a07897d7e2d83054751772d3da080708aa8bcfe05983
+size 1080
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark.ico
new file mode 100644
index 0000000000..7da3f0b055
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-dark.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:35d1e08dc303a036f67d6efeb519780ad78981c2278863e3b2d421324a6fec23
+size 2485
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark-expanded.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark-expanded.ico
new file mode 100644
index 0000000000..60cbaa7171
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cd831ee2abe944bef85c395030edffa560a6463aca8d633df4859a287f943f1c
+size 3183
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark-horizontal.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark-horizontal.ico
new file mode 100644
index 0000000000..0d1caec458
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3c6a95d31234e69935bed304d173f986181cd571d47daaa70e353e5c40b999f4
+size 1088
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark.ico
new file mode 100644
index 0000000000..0bd1eef397
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-dark.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1fb7d668ed25d3f87734ac5dad8c129f46a312297e31fe0cf3ce764197a8d537
+size 2517
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light-expanded.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light-expanded.ico
new file mode 100644
index 0000000000..4e490e9af1
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c7d7534a9bfee9ff90553f9a1413efdf226bbdbc92a1c5c086055bc05749ea7a
+size 3137
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light-horizontal.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light-horizontal.ico
new file mode 100644
index 0000000000..957904027f
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b60ce314c9ff580183d863357edbb764385fb9740017a93713e9391e54bd4d98
+size 1074
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light.ico
new file mode 100644
index 0000000000..cddbf6ca87
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-highcontrast-light.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1c07a563ccfe820b875b2f743d6718fb01edbe7af7268d544af75feb148c0dc1
+size 2503
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light-expanded.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light-expanded.ico
new file mode 100644
index 0000000000..e61d206a76
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light-expanded.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:82f8df9cc8f048ca4c01f888ed59ce09ffa5e9429cd184eb3e734d165f4f6d9f
+size 3131
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light-horizontal.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light-horizontal.ico
new file mode 100644
index 0000000000..957904027f
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light-horizontal.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b60ce314c9ff580183d863357edbb764385fb9740017a93713e9391e54bd4d98
+size 1074
diff --git a/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light.ico b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light.ico
new file mode 100644
index 0000000000..cddbf6ca87
--- /dev/null
+++ b/packages/stacks-classic/screenshots/Webkit/baseline/s-vote-light.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1c07a563ccfe820b875b2f743d6718fb01edbe7af7268d544af75feb148c0dc1
+size 2503
diff --git a/packages/stacks-docs/_data/site-navigation.json b/packages/stacks-docs/_data/site-navigation.json
index 9025e7277c..689b8b1348 100644
--- a/packages/stacks-docs/_data/site-navigation.json
+++ b/packages/stacks-docs/_data/site-navigation.json
@@ -349,6 +349,10 @@
{
"title": "User cards",
"url": "/product/components/user-cards/"
+ },
+ {
+ "title": "Vote",
+ "url": "/product/components/vote/"
}
]
}
diff --git a/packages/stacks-docs/_data/vote.json b/packages/stacks-docs/_data/vote.json
new file mode 100644
index 0000000000..cf39215f2b
--- /dev/null
+++ b/packages/stacks-docs/_data/vote.json
@@ -0,0 +1,96 @@
+{
+ "classes": [
+ {
+ "class": ".s-vote",
+ "applies": "N/A",
+ "description": "Base vote component."
+ },
+ {
+ "class": ".s-vote__expanded",
+ "applies": ".s-vote",
+ "description": "Expanded vote style that shows upvote and downvote counts separately."
+ },
+ {
+ "class": ".s-vote__horizontal",
+ "applies": ".s-vote",
+ "description": "Horizontal vote style that arranges buttons and counts in a row. This layout does not officially support downvoting or expanded vote count."
+ },
+ {
+ "class": ".s-vote--btn",
+ "applies": ".s-vote",
+ "description": "Vote button."
+ },
+ {
+ "class": ".s-vote--votes",
+ "applies": ".s-vote",
+ "description": "Container for vote counts."
+ },
+ {
+ "class": ".s-vote--upvotes",
+ "applies": ".s-vote--votes",
+ "description": "Upvote count."
+ },
+ {
+ "class": ".s-vote--total",
+ "applies": ".s-vote--votes",
+ "description": "Total vote count."
+ },
+ {
+ "class": ".s-vote--downvotes",
+ "applies": ".s-vote--votes",
+ "description": "Downvote count."
+ }
+ ],
+ "groups": {
+ "base": [
+ {
+ "description": "Base",
+ "count": "12"
+ },
+ {
+ "description": "0 vote count",
+ "count": "Vote"
+ },
+ {
+ "description": "≥ 1,000 votes",
+ "count": "27.5K"
+ }
+ ],
+ "expanded": [
+ {
+ "count": "20",
+ "positive": "+12",
+ "negative": "-8"
+ }
+ ],
+ "horizontal": [
+ {
+ "count": "5",
+ "downvote": false
+ }
+ ],
+ "voted": [
+ {
+ "description": "Upvoted",
+ "count": "27.5K",
+ "upvote": true,
+ "downvote": true,
+ "upvoted": true
+ },
+ {
+ "description": "Downvoted",
+ "count": "11",
+ "upvote": true,
+ "downvote": true,
+ "downvoted": true
+ },
+ {
+ "description": "Horizontal upvoted",
+ "count": "6",
+ "horizontal": true,
+ "upvote": true,
+ "upvoted": true
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/packages/stacks-docs/product/components/vote.html b/packages/stacks-docs/product/components/vote.html
new file mode 100644
index 0000000000..5b51071043
--- /dev/null
+++ b/packages/stacks-docs/product/components/vote.html
@@ -0,0 +1,235 @@
+---
+layout: page
+title: Vote
+description: The vote component allows users to vote on the quality of content by casting an upvote or downvote.
+figma: https://www.figma.com/design/do4Ug0Yws8xCfRjHe9cJfZ/Project-SHINE---Product-UI?node-id=617-18830&p=f&t=u4su1MPgebbjmcfI-0
+svelte: https://beta.svelte.stackoverflow.design/?path=/docs/components-vote--docs
+tags: components
+---
+
+
+
+ {% header "h2", "Classes" %}
+
+
+
+
+ Class
+ Parent
+ Description
+
+
+
+ {% for item in vote.classes %}
+
+ {{ item.class }}
+ {{ item.applies }}
+ {{ item.description }}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% header "h2", "Examples" %}
+ {% header "h3", "Base" %}
+
+ The base vote component includes an upvote button, a downvote button, and a vote count. When the vote count is zero and the current user has not voted, it should display Vote in place of a number. Otherwise, show the vote count and truncate large numbers (e.g., 1.2k).
+
+
+
+{% highlight html %}
+
+
+ @Svg.Vote16Up
+ upvote
+
+
+ 12
+
+
+ @Svg.Vote16Down
+ downvote
+
+
+{% endhighlight %}
+
+ {% for vote in vote.groups.base %}
+
+
{{ vote.description }}
+
+
+ {% icon "Vote16Up" %}
+ upvote
+
+
+ {{ vote.count }}
+
+
+ {% icon "Vote16Down" %}
+ downvote
+
+
+
+ {% endfor %}
+
+
+
+ {% header "h3", "Expanded" %}
+
+ Include the .s-vote__expanded modifier to show upvote and downvote counts instead of the total vote count. This modifier hides .s-vote--total and shows .s-vote--upvotes and .s-vote--downvotes instead.
+
+
+
+{% highlight html %}
+
+
+ @Svg.Vote16Up
+ upvote
+
+
+ 12
+ 20
+ 8
+
+
+ @Svg.Vote16Down
+ downvote
+
+
+{% endhighlight %}
+
+ {% for vote in vote.groups.expanded %}
+
+
+
+ {% icon "Vote16Up" %}
+ upvote
+
+
+ {{ vote.positive }}
+ {{ vote.count }}
+ {{ vote.negative }}
+
+
+ {% icon "Vote16Down" %}
+ downvote
+
+
+
+ {% endfor %}
+
+
+
+ {% header "h3", "Horizontal" %}
+
+ Apply the .s-vote__horizontal modifier to arrange the vote buttons and counts in a horizontal layout. This layout does not officially support expanded vote count. This configuration is best suited for scenarios such as comment voting, where a more compact design is preferred.
+
+
+
+{% highlight html %}
+
+
+ @Svg.Vote16Up
+ upvote
+
+ 12
+
+
+
+{% endhighlight %}
+
+ {% for vote in vote.groups.horizontal %}
+
+ {% if vote.description %}
+
{{ vote.description }}
+ {% endif %}
+
+
+ {% icon "Vote16Up" %}
+ upvote
+
+ {{ vote.count }}
+
+
+
+
+ {% endfor %}
+
+
+
+
+ {% header "h3", "Voted" %}
+
+ Use filled vote icons to indicate when the current user has upvoted or downvoted the content.
+
+
+
+{% highlight html %}
+
+
+ @Svg.Vote16Up
+ upvote
+
+
+ 12
+
+
+ @Svg.Vote16Down
+ downvote
+
+
+{% endhighlight %}
+
+ {% for vote in vote.groups.voted %}
+ {% assign classList = "s-vote" %}
+ {% if vote.horizontal %}
+ {% assign classList = classList | append: " s-vote__horizontal" %}
+ {% endif %}
+
+
{{ vote.description }}
+
+ {% if vote.downvote %}
+
+ {% if vote.upvoted %}
+ {% icon "Vote16UpFill" %}
+ upvoted
+ {% else %}
+ {% icon "Vote16Up" %}
+ upvote
+ {% endif %}
+
+
+ {{ vote.count }}
+
+
+ {% if vote.downvoted %}
+ {% icon "Vote16DownFill" %}
+ downvoted
+ {% else %}
+ {% icon "Vote16Down" %}
+ downvote
+ {% endif %}
+
+ {% else %}
+
+ {% if vote.upvoted %}
+ {% icon "Vote16UpFill" %}
+ upvoted
+ {% else %}
+ {% icon "Vote16Up" %}
+ upvote
+ {% endif %}
+
+ {{ vote.count }}
+
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
diff --git a/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte b/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte
new file mode 100644
index 0000000000..a17e34bffe
--- /dev/null
+++ b/packages/stacks-svelte/src/components/Vote/Vote.stories.svelte
@@ -0,0 +1,64 @@
+
+
+
+ {#snippet template(args)}
+
+ {/snippet}
+
+
+
+ {#snippet template()}
+
+
+
+
+
+
+
+ {/snippet}
+
+
+
+ {#snippet template()}
+
+
+
+
+ {/snippet}
+
+
+
+ {#snippet template()}
+
+
+
+
+
+ {/snippet}
+
+
+
+ {#snippet template()}
+
+
+
+
+
+ {/snippet}
+
diff --git a/packages/stacks-svelte/src/components/Vote/Vote.svelte b/packages/stacks-svelte/src/components/Vote/Vote.svelte
new file mode 100644
index 0000000000..174c856f5b
--- /dev/null
+++ b/packages/stacks-svelte/src/components/Vote/Vote.svelte
@@ -0,0 +1,225 @@
+
+
+
+
+
+ handleVote("upvoted", onupvote)}>
+ {#if currentStatus === "upvoted"}
+
+ {i18nUpvoted}
+ {:else}
+
+ {i18nUpvote}
+ {/if}
+ {#if horizontal}
+
+
+ {currentCount !== 0 || currentStatus !== null
+ ? formatNumber(currentCount)
+ : i18nVote}
+
+
+ {/if}
+
+ {#if !horizontal}
+ (expandable ? (expanded = !expanded) : null)}
+ >
+ {#if upvotes !== undefined}
+ +{formatNumber(upvotes)}
+ {/if}
+
+ {currentCount !== 0 || currentStatus !== null
+ ? formatNumber(currentCount)
+ : i18nVote}
+
+ {#if downvotes !== undefined}
+ -{formatNumber(downvotes)}
+ {/if}
+ {#if expandable}
+
+ {expanded ? i18nExpanded : i18nExpand}
+
+ {/if}
+
+ handleVote("downvoted", ondownvote)}
+ >
+ {#if currentStatus === "downvoted"}
+
+ {i18nDownvoted}
+ {:else}
+
+ {i18nDownvote}
+ {/if}
+
+ {/if}
+
diff --git a/packages/stacks-svelte/src/components/Vote/Vote.test.ts b/packages/stacks-svelte/src/components/Vote/Vote.test.ts
new file mode 100644
index 0000000000..a4394f25dd
--- /dev/null
+++ b/packages/stacks-svelte/src/components/Vote/Vote.test.ts
@@ -0,0 +1,235 @@
+import { tick } from "svelte";
+import { expect } from "@open-wc/testing";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import sinon from "sinon";
+
+import Vote from "./Vote.svelte";
+
+describe("Vote", () => {
+ it("should render the vote component", () => {
+ render(Vote, { total: 12 });
+ expect(screen.getByText("12")).to.exist;
+ });
+
+ it("should render 'Vote' text when total is 0 ", () => {
+ render(Vote, { total: 0 });
+ expect(screen.getByText("Vote")).to.exist;
+ });
+
+ it("should format large numbers", () => {
+ render(Vote, { total: 1234 });
+ expect(screen.getByText("1.2k")).to.exist;
+ });
+
+ it("should render upvote and downvote buttons by default", () => {
+ render(Vote, { total: 12 });
+ const buttons = screen.getAllByRole("button");
+ expect(buttons).to.have.lengthOf(2);
+ });
+
+ it("should render only upvote button when horizontal is true", () => {
+ render(Vote, { total: 12, horizontal: true });
+ const buttons = screen.getAllByRole("button");
+ expect(buttons).to.have.lengthOf(1);
+ });
+
+ it("should apply horizontal class when horizontal prop is true", () => {
+ const { container } = render(Vote, { total: 12, horizontal: true });
+ const voteEl = container.querySelector(".s-vote");
+ expect(voteEl).to.have.class("s-vote__horizontal");
+ });
+
+ it("should render upvotes and downvotes when provided", () => {
+ render(Vote, { total: 12, upvotes: 20, downvotes: 8 });
+ expect(screen.getByText("+20")).to.exist;
+ expect(screen.getByText("-8")).to.exist;
+ });
+
+ it("should apply expanded class when expanded state is true", async () => {
+ const { container } = render(Vote, {
+ total: 12,
+ upvotes: 20,
+ downvotes: 8,
+ });
+ const votesBtn = container.querySelector(
+ ".s-vote--votes"
+ ) as HTMLElement;
+ const user = userEvent.setup();
+
+ await user.click(votesBtn);
+ await tick();
+
+ const voteEl = container.querySelector(".s-vote");
+ expect(voteEl).to.have.class("s-vote__expanded");
+ });
+
+ it("should not be expandable in horizontal layout", () => {
+ render(Vote, {
+ total: 12,
+ upvotes: 20,
+ downvotes: 8,
+ horizontal: true,
+ });
+ // Should render as span, not button
+ const votesEl = screen.getByText("12").parentElement;
+ expect(votesEl?.tagName).to.equal("SPAN");
+ });
+
+ it("should render filled upvote icon when status is upvoted", () => {
+ const { container } = render(Vote, { total: 13, status: "upvoted" });
+ const svg = container.querySelector("svg.IconVote16UpFill");
+ expect(svg).to.exist;
+ });
+
+ it("should render filled downvote icon when status is downvoted", () => {
+ const { container } = render(Vote, { total: 11, status: "downvoted" });
+ const svg = container.querySelector("svg.IconVote16DownFill");
+ 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 });
+
+ const user = userEvent.setup();
+ const upvoteBtn = screen.getAllByRole("button")[0];
+
+ await user.click(upvoteBtn);
+ await tick();
+
+ expect(onupvote).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 });
+
+ const user = userEvent.setup();
+ const downvoteBtn = screen.getAllByRole("button")[1];
+
+ await user.click(downvoteBtn);
+ await tick();
+
+ expect(ondownvote).to.have.been.calledOnce;
+ });
+
+ it("should update count when upvoting", async () => {
+ const onupvote = sinon.stub().resolves();
+ render(Vote, { total: 12, onupvote });
+
+ const user = userEvent.setup();
+ const upvoteBtn = screen.getAllByRole("button")[0];
+
+ await user.click(upvoteBtn);
+ await tick();
+
+ expect(screen.getByText("13")).to.exist;
+ });
+
+ it("should update count when downvoting", async () => {
+ const ondownvote = sinon.stub().resolves();
+ render(Vote, { total: 12, ondownvote });
+
+ const user = userEvent.setup();
+ const downvoteBtn = screen.getAllByRole("button")[1];
+
+ await user.click(downvoteBtn);
+ await tick();
+
+ expect(screen.getByText("11")).to.exist;
+ });
+
+ it("should revert count on error", async () => {
+ const onupvote = sinon.stub().rejects(new Error("Failed"));
+ render(Vote, { total: 12, onupvote });
+
+ const user = userEvent.setup();
+ const upvoteBtn = screen.getAllByRole("button")[0];
+
+ await user.click(upvoteBtn);
+ await tick();
+
+ expect(screen.getByText("12")).to.exist;
+ });
+
+ it("should toggle vote when clicking same button again", async () => {
+ const onupvote = sinon.stub().resolves();
+ render(Vote, { total: 12, onupvote });
+
+ 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;
+
+ // Second click - remove upvote
+ await user.click(upvoteBtn);
+ await tick();
+ expect(screen.getByText("12")).to.exist;
+ });
+
+ it("should switch from upvote to downvote", async () => {
+ const onupvote = sinon.stub().resolves();
+ const ondownvote = sinon.stub().resolves();
+ render(Vote, { total: 12, onupvote, ondownvote });
+
+ const user = userEvent.setup();
+ const upvoteBtn = screen.getAllByRole("button")[0];
+ const downvoteBtn = screen.getAllByRole("button")[1];
+
+ // Upvote
+ await user.click(upvoteBtn);
+ await tick();
+ expect(screen.getByText("13")).to.exist;
+
+ // Downvote (should change by 2)
+ await user.click(downvoteBtn);
+ await tick();
+ expect(screen.getByText("11")).to.exist;
+ });
+
+ it("should apply custom class", () => {
+ const { container } = render(Vote, {
+ total: 12,
+ class: "custom-class",
+ });
+ const voteEl = container.querySelector(".s-vote");
+ expect(voteEl).to.have.class("custom-class");
+ });
+
+ it("should use custom i18n text for vote button", () => {
+ render(Vote, { total: 0, i18nVote: "Votar" });
+ expect(screen.getByText("Votar")).to.exist;
+ });
+
+ it("should use custom i18n text for screen readers", () => {
+ render(Vote, {
+ total: 12,
+ upvotes: 20,
+ downvotes: 8,
+ i18nUpvote: "Vote up",
+ i18nDownvote: "Vote down",
+ i18nExpand: "Expand votes",
+ });
+ expect(screen.getByText("Vote up")).to.exist;
+ expect(screen.getByText("Vote down")).to.exist;
+ 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 });
+
+ 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;
+ });
+});
diff --git a/packages/stacks-svelte/src/utils/format.test.ts b/packages/stacks-svelte/src/utils/format.test.ts
new file mode 100644
index 0000000000..e29f942243
--- /dev/null
+++ b/packages/stacks-svelte/src/utils/format.test.ts
@@ -0,0 +1,65 @@
+import { expect } from "@open-wc/testing";
+import { formatNumber } from "./format";
+
+describe("formatNumber", () => {
+ it("should return the number as-is for numbers < 1000", () => {
+ expect(formatNumber(0)).to.equal("0");
+ expect(formatNumber(1)).to.equal("1");
+ expect(formatNumber(999)).to.equal("999");
+ });
+
+ it("should format numbers >= 1000 and < 10000 with one decimal and 'k'", () => {
+ expect(formatNumber(1000)).to.equal("1k");
+ expect(formatNumber(1234)).to.equal("1.2k");
+ expect(formatNumber(5678)).to.equal("5.7k");
+ expect(formatNumber(9900)).to.equal("9.9k");
+ expect(formatNumber(9999)).to.equal("10k"); // rounds to 10
+ });
+
+ it("should format numbers >= 10000 and < 100000 without decimal (max 4 chars)", () => {
+ expect(formatNumber(10000)).to.equal("10k");
+ expect(formatNumber(12345)).to.equal("12k");
+ expect(formatNumber(56789)).to.equal("57k");
+ expect(formatNumber(99999)).to.equal("100k");
+ });
+
+ it("should format numbers >= 100000 and < 1000000 without decimal", () => {
+ expect(formatNumber(100000)).to.equal("100k");
+ expect(formatNumber(123456)).to.equal("123k");
+ expect(formatNumber(999999)).to.equal("1000k");
+ });
+
+ it("should format numbers >= 1000000 and < 10000000 with one decimal and 'm'", () => {
+ expect(formatNumber(1000000)).to.equal("1m");
+ expect(formatNumber(1234567)).to.equal("1.2m");
+ expect(formatNumber(5678901)).to.equal("5.7m");
+ expect(formatNumber(9900000)).to.equal("9.9m");
+ expect(formatNumber(9999999)).to.equal("10m"); // rounds to 10
+ });
+
+ it("should format numbers >= 10000000 without decimal", () => {
+ expect(formatNumber(10000000)).to.equal("10m");
+ expect(formatNumber(12345678)).to.equal("12m");
+ expect(formatNumber(56789012)).to.equal("57m");
+ expect(formatNumber(99999999)).to.equal("100m");
+ expect(formatNumber(100000000)).to.equal("100m");
+ expect(formatNumber(123456789)).to.equal("123m");
+ expect(formatNumber(999999999)).to.equal("1000m");
+ });
+
+ it("should ensure result is at most 4 characters", () => {
+ // 3 chars or less
+ expect(formatNumber(999)).to.equal("999");
+ expect(formatNumber(1000)).to.equal("1k");
+ expect(formatNumber(1500)).to.equal("1.5k");
+
+ // Exactly 4 chars
+ expect(formatNumber(9999)).to.equal("10k");
+ expect(formatNumber(10000)).to.equal("10k");
+ expect(formatNumber(99999)).to.equal("100k");
+ expect(formatNumber(100000)).to.equal("100k");
+ expect(formatNumber(999999)).to.equal("1000k");
+ expect(formatNumber(9999999)).to.equal("10m");
+ expect(formatNumber(10000000)).to.equal("10m");
+ });
+});
diff --git a/packages/stacks-svelte/src/utils/format.ts b/packages/stacks-svelte/src/utils/format.ts
new file mode 100644
index 0000000000..5cd645c351
--- /dev/null
+++ b/packages/stacks-svelte/src/utils/format.ts
@@ -0,0 +1,38 @@
+/**
+ * Formats a number by abbreviating it with k (thousands) or m (millions) suffix.
+ * The result will be at most 4 characters long.
+ *
+ * @param num - The number to format
+ * @returns The formatted number as a string
+ *
+ * @example
+ * formatNumber(123) // "123"
+ * formatNumber(1234) // "1.2k"
+ * formatNumber(9999) // "10k"
+ * formatNumber(12345) // "12k"
+ * formatNumber(1234567) // "1.2m"
+ * formatNumber(12345678) // "12m"
+ */
+export function formatNumber(num: number): string {
+ if (num < 1000) {
+ return num.toString();
+ }
+
+ if (num < 1000000) {
+ const k = num / 1000;
+ const rounded = parseFloat(k.toFixed(1));
+ // If the rounded result would be >= 10k, don't use decimal
+ if (rounded >= 10) {
+ return Math.round(k) + "k";
+ }
+ return rounded + "k";
+ }
+
+ const m = num / 1000000;
+ const rounded = parseFloat(m.toFixed(1));
+ // If the rounded result would be >= 10m, don't use decimal
+ if (rounded >= 10) {
+ return Math.round(m) + "m";
+ }
+ return rounded + "m";
+}
diff --git a/packages/stacks-svelte/src/utils/index.ts b/packages/stacks-svelte/src/utils/index.ts
new file mode 100644
index 0000000000..039fa7607f
--- /dev/null
+++ b/packages/stacks-svelte/src/utils/index.ts
@@ -0,0 +1 @@
+export { formatNumber } from "./format";