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: ` + + + 12 + +20 + -8 + + + `, + upvoteOnly: ` + + + 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: ` + + + 12 + +20 + -8 + + + `, + }, + template: ({ component, testid }) => html` +
+ ${component} +
+ `, + }); + + // Horizontal with and without downvote + runVisualTests({ + baseClass: "s-vote", + modifiers: { + primary: ["horizontal"], + }, + options: { + includeNullModifier: false, + }, + children: { + default: ` + + `, + }, + 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" %} +
+ + + + + + + + + + {% for item in vote.classes %} + + + + + + {% endfor %} + +
ClassParentDescription
{{ item.class }}{{ item.applies }}{{ item.description }}
+
+
+ +
+ {% 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 %} +
+ + + 12 + + +
+{% endhighlight %} +
+ {% for vote in vote.groups.base %} +
+ {{ vote.description }} +
+ + + {{ vote.count }} + + +
+
+ {% 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 %} +
+ + + +
+{% endhighlight %} +
+ {% for vote in vote.groups.expanded %} +
+
+ + + {{ vote.positive }} + {{ vote.count }} + {{ vote.negative }} + + +
+
+ {% 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 %} +
+ +
+{% endhighlight %} +
+ {% for vote in vote.groups.horizontal %} +
+ {% if vote.description %} + {{ vote.description }} + {% endif %} +
+ +
+
+ {% endfor %} +
+
+ + + {% header "h3", "Voted" %} +

+ Use filled vote icons to indicate when the current user has upvoted or downvoted the content. +

+ +
+{% highlight html %} +
+ + + 12 + + +
+{% 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 %} + + + {{ vote.count }} + + + {% else %} + + {% 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 @@ + + + + +
+ + {#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} + + + {/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";