diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index f96863764909d..666b59adb6dde 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -134,6 +134,7 @@ apps/chart-docsite @microsoft/charting-team
#### Packages
packages/azure-themes @Jacqueline-ms @robtaft-ms
packages/react-conformance @microsoft/fluentui-react-build
+packages/charts/chart-web-components @microsoft/charting-team
packages/charts/react-charting @microsoft/charting-team
packages/charts/react-charts-preview/library @microsoft/charting-team
packages/charts/react-charts-preview/stories @microsoft/charting-team
diff --git a/apps/pr-deploy-site/just.config.ts b/apps/pr-deploy-site/just.config.ts
index 4a9cff295e94a..34244cfedaaf2 100644
--- a/apps/pr-deploy-site/just.config.ts
+++ b/apps/pr-deploy-site/just.config.ts
@@ -24,6 +24,7 @@ const dependencies = [
'@fluentui/public-docsite',
'@fluentui/react',
'@fluentui/react-charting',
+ '@fluentui/chart-web-components',
'@fluentui/chart-docsite',
'@fluentui/public-docsite-v9',
'@fluentui/react-experiments',
diff --git a/apps/pr-deploy-site/pr-deploy-site.js b/apps/pr-deploy-site/pr-deploy-site.js
index c16475726733a..ce4b6278494d8 100644
--- a/apps/pr-deploy-site/pr-deploy-site.js
+++ b/apps/pr-deploy-site/pr-deploy-site.js
@@ -60,6 +60,12 @@ var siteInfo = [
icon: 'BarChart4',
title: 'Charting',
},
+ {
+ package: '@fluentui/chart-web-components',
+ link: './chart-web-components/storybook/index.html',
+ icon: 'BarChart4',
+ title: 'Chart web components',
+ },
{
package: '@fluentui/theming-designer',
link: './theming-designer/index.html',
diff --git a/change/@fluentui-chart-web-components-dfff0cf0-8ca6-4c11-aacb-bdd0e7090bbb.json b/change/@fluentui-chart-web-components-dfff0cf0-8ca6-4c11-aacb-bdd0e7090bbb.json
new file mode 100644
index 0000000000000..c610fda40decf
--- /dev/null
+++ b/change/@fluentui-chart-web-components-dfff0cf0-8ca6-4c11-aacb-bdd0e7090bbb.json
@@ -0,0 +1,7 @@
+{
+ "type": "prerelease",
+ "comment": "Create chart web components. Includes donut chart and horizontal bar chart",
+ "packageName": "@fluentui/chart-web-components",
+ "email": "98592573+AtishayMsft@users.noreply.github.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/charts/chart-web-components/.eslintignore b/packages/charts/chart-web-components/.eslintignore
new file mode 100644
index 0000000000000..ba38ef5432888
--- /dev/null
+++ b/packages/charts/chart-web-components/.eslintignore
@@ -0,0 +1,8 @@
+# don't ever lint node_modules
+node_modules
+# don't lint build output (make sure it's set to your correct build folder name)
+dist
+# don't lint coverage output
+coverage
+# don't lint storybook
+.storybook
diff --git a/packages/charts/chart-web-components/.eslintrc.json b/packages/charts/chart-web-components/.eslintrc.json
new file mode 100644
index 0000000000000..3d5876196e9ba
--- /dev/null
+++ b/packages/charts/chart-web-components/.eslintrc.json
@@ -0,0 +1,74 @@
+{
+ "root": true,
+ "parser": "@typescript-eslint/parser",
+ "plugins": ["@typescript-eslint", "import"],
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/eslint-recommended",
+ "plugin:@typescript-eslint/recommended",
+ "prettier",
+ "plugin:playwright/recommended"
+ ],
+ "settings": {
+ "react": {
+ "version": "latest"
+ }
+ },
+ "rules": {
+ "no-empty": [
+ "error",
+ {
+ "allowEmptyCatch": true
+ }
+ ],
+ "no-extra-boolean-cast": "off",
+ "no-prototype-builtins": "off",
+ "no-fallthrough": "off",
+ "no-unexpected-multiline": "off",
+ "no-useless-escape": "off",
+ "import/order": "error",
+ "sort-imports": [
+ "error",
+ {
+ "ignoreCase": true,
+ "ignoreDeclarationSort": true
+ }
+ ],
+ "comma-dangle": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/no-use-before-define": "off",
+ "@typescript-eslint/no-empty-interface": "error",
+ "@typescript-eslint/no-empty-object-type": "off",
+ "@typescript-eslint/no-unsafe-declaration-merging": "off",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/explicit-function-return-type": "off",
+ "@typescript-eslint/camelcase": "off",
+ "@typescript-eslint/no-inferrable-types": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ "args": "none"
+ }
+ ],
+ "@typescript-eslint/no-unused-expressions": "warn",
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/naming-convention": [
+ "error",
+ {
+ "selector": "default",
+ "format": ["UPPER_CASE", "camelCase", "PascalCase"],
+ "leadingUnderscore": "allow"
+ },
+ {
+ "selector": "property",
+ "format": null // disable for property names because of our foo__expanded convention for JSS
+ // TODO: I think we can come up with a regex that ignores variables with __ in them
+ },
+ {
+ "selector": "variable",
+ "format": null // disable for variable names because of our foo__expanded convention for JSS
+ // TODO: I think we can come up with a regex that ignores variables with __ in them
+ }
+ ]
+ }
+}
diff --git a/packages/charts/chart-web-components/.gitignore b/packages/charts/chart-web-components/.gitignore
new file mode 100644
index 0000000000000..51511d1f8f36f
--- /dev/null
+++ b/packages/charts/chart-web-components/.gitignore
@@ -0,0 +1 @@
+test-results/
diff --git a/packages/charts/chart-web-components/.storybook/docs-root.css b/packages/charts/chart-web-components/.storybook/docs-root.css
new file mode 100644
index 0000000000000..b24a61b616134
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/docs-root.css
@@ -0,0 +1,473 @@
+/*
+ * Heads Up!
+ * This file should be kept in sync with the `docs-root.css` file for the React v9 Storybook.
+ */
+
+/* remove the docs wrapper bg to let page bg show through */
+#storybook-docs .sbdocs-wrapper {
+ background: transparent !important;
+}
+
+/* sb-show-main is missing during page transitions causing a page shift */
+/* todo: cleanup once we no longer inherit docs-root */
+.sb-show-main.sb-main-fullscreen,
+.sb-main-fullscreen {
+ margin: 0;
+ padding: 0;
+ display: block;
+}
+
+#storybook-docs .sbdocs-content {
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ max-width: 1200px;
+}
+
+#storybook-docs h1.sbdocs-title {
+ font-size: 44px;
+ line-height: 60px;
+ /* identical to box height, or 143% */
+ font-weight: 900;
+ letter-spacing: -0.04em;
+ color: #000000;
+}
+
+#storybook-docs details {
+ position: relative;
+ z-index: 99;
+}
+
+#storybook-docs .sbdocs:not(.sbdocs-preview) p {
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 18px;
+ line-height: 27px;
+ letter-spacing: -0.01em;
+ color: #000000;
+ margin-top: 24px;
+}
+
+#storybook-docs .sbdocs-img.featured-image {
+ max-width: 100%;
+ margin: 48px 0;
+ display: block;
+}
+
+#storybook-docs .sbdocs-img {
+ border-radius: 24px;
+}
+
+#storybook-docs .sbdocs:not(.sbdocs-preview) hr {
+ margin: 48px 0;
+ height: 0;
+ border-top: 1px solid #ebebeb;
+}
+
+#storybook-docs .sbdocs h2 {
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 24px;
+ line-height: 28px;
+ letter-spacing: -0.04em;
+ color: black;
+ border-top: 1px solid #ebebeb;
+ border-bottom: none;
+ margin: 48px 0 15px 0;
+ padding: 48px 0 0 0;
+}
+
+#storybook-docs .sbdocs h2 code {
+ border-radius: 4px;
+ font-size: 20px;
+}
+
+#storybook-docs .sbdocs-h3 {
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 18px;
+ line-height: 24px;
+ margin: 25px 0 0 0 !important;
+ letter-spacing: -0.01em;
+ color: #000000;
+}
+
+#storybook-docs .sbdocs-h3 code {
+ border-radius: 3px;
+ font-size: 16px;
+}
+
+/* Only apply to H3s inside of stories which have a parent with an ID */
+#storybook-docs [id] > .sbdocs-h3:before {
+ content: '';
+ display: block;
+ height: 40px;
+ margin: -40px 0 0;
+}
+
+#storybook-docs .sbdocs:not(.sbdocs-preview) li {
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 16px;
+ line-height: 150%;
+ letter-spacing: -0.01em;
+
+ /* Neutrals / Web / Gray 200 #1B1A19 */
+ color: #1b1a19;
+ margin-top: 8px;
+}
+
+#storybook-docs .sbdocs:not(.sbdocs-preview) ul {
+ margin: 12px 0;
+}
+
+#storybook-docs .sbdocs-ul .sbdocs:not(.sbdocs-preview) li {
+ list-style: none;
+ position: relative;
+}
+
+#storybook-docs .sbdocs-ul .sbdocs-li::before {
+ position: absolute;
+ content: '•';
+ color: #8d8d8d;
+ top: 0;
+ left: -15px;
+}
+
+#storybook-docs .sbdocs-ol .sbdocs-li::marker {
+ color: #8d8d8d;
+}
+
+#storybook-docs .sbdocs-preview {
+ border-radius: 16px;
+ background: #fff; /* --colorBrandBackgroundInverted */
+ padding: 0;
+ box-shadow: none;
+ border: 1px solid #d1d1d1; /* --colorNeutralStroke1 */
+}
+
+/* Apply the currently selected Fluent UI theme to the relevant areas of the docs */
+#storybook-docs .innerZoomElementWrapper > div {
+ box-sizing: border-box;
+}
+
+/* fix mouse interactions for toolbar on first story */
+#storybook-docs .sbdocs-preview > .os-host {
+ /* The toolbar sits within the story content area and is position: absolute by default. */
+ /* The story content overlays the toolbar making it non-interactive */
+ /* We don't use z-index because the toolbar can still sometimes overlay story content (flyout menu) */
+ /* The best solution is to use a static toolbar that is always outside the story content and interactive */
+ position: static;
+}
+
+#storybook-docs span + .sbdocs .docblock-argstable tbody tr td button {
+ color: #0078d4;
+ color: red;
+}
+
+#storybook-docs .docs-story + div {
+ background: #11100f;
+}
+
+#storybook-docs .sbdocs-content > div:last-child {
+ margin-bottom: 96px;
+}
+
+#storybook-docs .docs-story > div {
+ padding: 0;
+ background: none;
+}
+
+#storybook-docs .docs-story > div:last-child {
+ right: 31px;
+ border-radius: 24px;
+}
+
+.docs-story + div > div:last-child {
+ background: #000000;
+ box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.25);
+ border-radius: 5px 5px 0px 0px;
+ right: 31px;
+}
+
+.docs-story + div > div:last-child > button {
+ color: white;
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 14px;
+ line-height: 150%;
+ text-align: center;
+ letter-spacing: -0.01em;
+}
+
+#storybook-docs a.sbdocs-a {
+ color: #0078d4;
+ text-decoration: underline;
+}
+
+/* */
+/* Args Table */
+/* */
+
+#storybook-docs .docblock-argstable tbody {
+ box-shadow: none;
+ border-left: none;
+ border-right: none;
+}
+
+#storybook-docs .docblock-argstable-head th {
+ letter-spacing: -0.01em;
+ color: black;
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 16px;
+ line-height: 150%;
+ font-weight: 600;
+}
+
+#storybook-docs thead.docblock-argstable-head {
+ border-bottom: 1px solid #edebe9;
+}
+
+#storybook-docs .docblock-argstable tbody tr {
+ border: none;
+}
+
+#storybook-docs table.docblock-argstable tbody.docblock-argstable-body td,
+#storybook-docs .docblock-argstable th {
+ padding-top: 12px;
+ padding-bottom: 12px;
+ padding-left: 16px;
+}
+
+#storybook-docs .docblock-argstable tbody tr td:nth-child(1) span {
+ font-weight: normal;
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 16px;
+ line-height: 130%;
+ letter-spacing: -0.01em;
+ color: #616161;
+}
+
+#storybook-docs .docblock-argstable tbody tr td {
+ vertical-align: top;
+}
+
+#storybook-docs .docblock-argstable-body > tr > td > div > div > button {
+ color: #0078d4;
+ line-height: 21px;
+}
+
+#storybook-docs code,
+#storybook-docs .docblock-argstable tbody tr td:nth-child(3) > div > span,
+#storybook-docs .docblock-argstable-body > tr > td:nth-child(2) > div:nth-child(2) span,
+#storybook-docs .docblock-argstable-body > tr > td:nth-child(2) > div:nth-child(1) > div > span,
+#storybook-docs .css-16d4d7t {
+ font-family: 'Cascadia Code', Menlo, 'Courier New', Courier, monospace;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 130%;
+ letter-spacing: -0.2px;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+#storybook-docs code.sbdocs-code,
+#storybook-docs .sbdocs-p code,
+#storybook-docs .sbdocs-li code,
+#storybook-docs .docblock-argstable code,
+#storybook-docs .docblock-argstable tbody tr td:nth-child(3) > div > span,
+#storybook-docs .docblock-argstable-body > tr > td:nth-child(2) > div:nth-child(2) span,
+#storybook-docs .docblock-argstable-body > tr > td:nth-child(2) > div:nth-child(1) > div > span,
+#storybook-docs .css-16d4d7t {
+ font-size: 14px;
+ background: #f0f0f0;
+ border-radius: 4px;
+ padding: 1px 4px;
+ margin: 0 3px 0 3px;
+ color: black;
+ border: none;
+ line-height: 1.5;
+}
+
+#storybook-docs .docblock-argstable code {
+ white-space: normal;
+}
+
+#storybook-docs code {
+ padding: 0.1em 0.2em;
+ display: inline-block;
+ background-color: rgba(17, 16, 15, 0.1);
+ border-radius: 2px;
+ width: fit-content; /* prevent wrapping kebab-case words when they'll fit on one line */
+}
+
+.os-content-glue {
+ width: auto !important;
+}
+
+#storybook-docs .sbdocs-preview .prismjs {
+ overflow: hidden;
+}
+
+#storybook-docs .os-content .prismjs * {
+ font-family: 'Cascadia Code', Menlo, 'Courier New', Courier, monospace;
+ font-size: 14px;
+ line-height: 1.4em;
+}
+
+#storybook-docs .sbdocs-preview .prismjs code {
+ color: white;
+ background: #11100f;
+ margin: 0;
+ overflow-x: auto;
+}
+
+#storybook-docs .docblock-argstable-body td > div > p,
+#storybook-docs .docblock-argstable-body > tr > td:nth-child(2) p,
+#storybook-docs .docblock-argstable-body > tr > td:nth-child(2) > div:nth-child(1) > span {
+ font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue',
+ sans-serif;
+ font-size: 16px;
+ line-height: 130%;
+ color: black;
+ letter-spacing: -0.01em;
+}
+
+#storybook-docs .docblock-argstable tr > :nth-child(1) {
+ width: 10%;
+}
+
+#storybook-docs .docblock-argstable tr > :nth-child(2) {
+ width: 60%;
+}
+
+#storybook-docs .os-padding {
+ z-index: 0;
+}
+
+@font-face {
+ font-family: 'Segoe UI';
+ src: local('Segoe UI Light'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff2) format('woff2'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff) format('woff'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.ttf) format('truetype');
+ font-weight: 100;
+}
+
+@font-face {
+ font-family: 'Segoe UI';
+ src: local('Segoe UI Semilight'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff2) format('woff2'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff) format('woff'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.ttf) format('truetype');
+ font-weight: 200;
+}
+
+@font-face {
+ font-family: 'Segoe UI';
+ src: local('Segoe UI'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff2) format('woff2'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff) format('woff'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.ttf) format('truetype');
+ font-weight: 400;
+}
+
+@font-face {
+ font-family: 'Segoe UI';
+ src: local('Segoe UI Semibold'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff2) format('woff2'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff) format('woff'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.ttf) format('truetype');
+ font-weight: 600;
+}
+
+@font-face {
+ font-family: 'Segoe UI';
+ src: local('Segoe UI Bold'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff2) format('woff2'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff) format('woff'),
+ url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.ttf) format('truetype');
+ font-weight: 700;
+}
+
+body,
+body p,
+body ul,
+body ul li {
+ font-family: 'Segoe UI' !important;
+}
+
+h1.fluent {
+ font-weight: 700;
+ font-size: 40px;
+ font-family: 'Segoe UI';
+ line-height: 60px;
+ letter-spacing: -0.16px;
+}
+
+h1 .fluent-version {
+ display: block;
+ font-size: 24px; /* --font-size-base-600 */
+ line-height: 32px;
+ color: #707070; /* --color-neutral-foreground-3 */
+}
+
+h2.fluent {
+ font-weight: 600;
+ font-size: 24px;
+ font-family: 'Segoe UI';
+ line-height: 36px;
+ letter-spacing: -0.16px;
+}
+
+/* Mimic React v9 Provider styles:
+ * - apply font, background, and foreground colors
+ * - apply padding for story content
+ */
+#storybook-docs .innerZoomElementWrapper > div > div {
+ padding: 48px 24px;
+ font-family: var(--fontFamilyBase);
+ background: var(--colorNeutralBackground2);
+ color: var(--colorNeutralForeground2);
+}
+
+/*
+ * Theme Switcher
+ */
+#switches-container {
+ position: sticky;
+ display: flex;
+ gap: 20px;
+ align-items: center;
+ padding: 12px;
+ width: 100%;
+ top: 0;
+ box-sizing: border-box; /* keep from overflowing body making x scroll bar*/
+ background: #fff;
+ box-shadow: 0 0 3px rgb(0 0 0 / 22%);
+ z-index: 10;
+}
+
+#switches-container select {
+ padding: 5px var(--spacingHorizontalM);
+ border: var(--strokeWidthThin) solid #d1d1d1 /* --colorNeutralStroke1, without theme switching */;
+ border-radius: var(--borderRadiusMedium);
+ font-size: var(--fontSizeBase300);
+ font-weight: var(--fontWeightSemibold);
+ line-height: var(--lineHeightBase300);
+ width: 140px;
+}
+
+.custom-fullscreen #switches-container {
+ display: none;
+}
+
+.custom-fullscreen .sbdocs-wrapper {
+ padding: 20px;
+}
+
+.custom-fullscreen .sbdocs-content {
+ max-width: unset;
+}
diff --git a/packages/charts/chart-web-components/.storybook/main.cjs b/packages/charts/chart-web-components/.storybook/main.cjs
new file mode 100644
index 0000000000000..a608e4b28b9ab
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/main.cjs
@@ -0,0 +1,88 @@
+const path = require('path');
+const CircularDependencyPlugin = require('circular-dependency-plugin');
+const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');
+
+const tsBin = require.resolve('typescript');
+const tsConfigPath = path.resolve(__dirname, '../../../../tsconfig.base.wc.json');
+
+const tsPaths = new TsconfigPathsPlugin({
+ configFile: tsConfigPath,
+});
+
+module.exports =
+ /** @type {import('@storybook/html-webpack5').StorybookConfig} */
+ ({
+ features: {
+ // On-demand code splitting is disabled for now, as it causes issues e2e tests.
+ storyStoreV7: false,
+ },
+ // helpers.stories.ts is a file that contains helper functions for stories,
+ // and should not be treated as a story itself.
+ stories: ['../src/**/!(helpers)*.stories.@(ts|mdx)'],
+ staticDirs: ['../public'],
+ core: {
+ disableTelemetry: true,
+ },
+ framework: '@storybook/html-webpack5',
+ addons: [
+ {
+ name: '@storybook/addon-essentials',
+ options: {
+ backgrounds: false,
+ viewport: false,
+ toolbars: false,
+ actions: true,
+ },
+ },
+ ],
+ webpackFinal: async config => {
+ config.resolve = config.resolve ?? {};
+ config.resolve.extensions = config.resolve.extensions ?? [];
+ config.resolve.plugins = config.resolve.plugins ?? [];
+ config.module = config.module ?? {};
+ config.plugins = config.plugins ?? [];
+
+ config.resolve.extensionAlias = {
+ '.js': ['.js', '.ts'],
+ '.mjs': ['.mjs', '.mts'],
+ };
+ config.resolve.extensions.push(...['.ts', '.js']);
+ config.resolve.plugins.push(tsPaths);
+ config.module.rules = config.module.rules ?? [];
+ config.module.rules.push(
+ {
+ test: /\.([cm]?ts|tsx)$/,
+ loader: 'ts-loader',
+ sideEffects: true,
+ options: {
+ transpileOnly: true,
+ compiler: tsBin,
+ },
+ },
+ // Following config is needed to be able to resolve @storybook packages imported in specified files that don't ship valid ESM
+ // It also enables importing other packages without proper ESM extensions, but that should be avoided !
+ // @see https://webpack.js.org/configuration/module/#resolvefullyspecified
+ {
+ test: /\.m?js/,
+ resolve: { fullySpecified: false },
+ },
+ );
+
+ config.plugins.push(
+ new CircularDependencyPlugin({
+ exclude: /node_modules/,
+ failOnError: process.env.NODE_ENV === 'production',
+ }),
+ );
+
+ // Disable ProgressPlugin which logs verbose webpack build progress. Warnings and Errors are still logged.
+ if (process.env.TF_BUILD) {
+ config.plugins = config.plugins.filter(value => value && value.constructor.name !== 'ProgressPlugin');
+ }
+
+ return config;
+ },
+ docs: {
+ autodocs: true,
+ },
+ });
diff --git a/packages/charts/chart-web-components/.storybook/manager-head.html b/packages/charts/chart-web-components/.storybook/manager-head.html
new file mode 100644
index 0000000000000..5ff3ef7092e60
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/manager-head.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
diff --git a/packages/charts/chart-web-components/.storybook/manager.mjs b/packages/charts/chart-web-components/.storybook/manager.mjs
new file mode 100644
index 0000000000000..73873977355b4
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/manager.mjs
@@ -0,0 +1,14 @@
+import { addons } from '@storybook/manager-api';
+import webcomponentsTheme from './theme.mjs';
+
+addons.setConfig({
+ previewTabs: {
+ canvas: { hidden: true },
+ },
+ enableShortcuts: false,
+ sidebar: {
+ showRoots: true,
+ },
+ showPanel: false,
+ theme: webcomponentsTheme, // override the default Storybook theme with a custom fluent theme
+});
diff --git a/packages/charts/chart-web-components/.storybook/preview-body.html b/packages/charts/chart-web-components/.storybook/preview-body.html
new file mode 100644
index 0000000000000..93e32a40560db
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/preview-body.html
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/packages/charts/chart-web-components/.storybook/preview.mjs b/packages/charts/chart-web-components/.storybook/preview.mjs
new file mode 100644
index 0000000000000..e02465d8ba551
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/preview.mjs
@@ -0,0 +1,70 @@
+import { teamsDarkTheme, teamsLightTheme, webDarkTheme, webLightTheme } from '@fluentui/tokens';
+import * as prettier from 'prettier';
+import prettierPluginHTML from 'prettier/parser-html.js';
+import { setTheme } from '@fluentui/web-components';
+import webcomponentsTheme from './theme.mjs';
+
+import '../src/index-rollup.js';
+import './docs-root.css';
+
+const FAST_EXPRESSION_COMMENTS = //g; // Matches comments that contain FAST expressions
+
+const themes = {
+ 'web-light': webLightTheme,
+ 'web-dark': webDarkTheme,
+ 'teams-light': teamsLightTheme,
+ 'teams-dark': teamsDarkTheme,
+};
+
+function changeTheme(/** @type {Event} */ e) {
+ setTheme(themes[/** @type {keyof themes} */ (/** @type {HTMLInputElement}*/ (e.target).value)]);
+}
+
+// This is needed in Playwright.
+Object.defineProperty(window, 'setTheme', { value: setTheme });
+
+document.getElementById('theme-switch')?.addEventListener('change', changeTheme, false);
+setTheme(themes['web-light']);
+
+export const parameters = {
+ layout: 'fullscreen',
+ controls: { expanded: true },
+ viewMode: 'docs',
+ previewTabs: {
+ canvas: { hidden: true },
+ },
+ options: {
+ storySort: {
+ method: 'alphabetical',
+ order: ['Concepts', ['Introduction', 'Developer', ['Quick Start']], 'Components', 'Theme'],
+ },
+ },
+ docs: {
+ source: {
+ // To get around the inability to change Prettier options in the source addon, this transform function
+ // imports the standalone Prettier and uses it to format the source with the desired options.
+ transform(/** @type {string} */ src, /** @type {import('@storybook/html').StoryContext} */ storyContext) {
+ if (!src) {
+ const fragment = storyContext.originalStoryFn(storyContext.allArgs, storyContext);
+ if (!(fragment instanceof DocumentFragment) && !(fragment instanceof HTMLElement)) {
+ return;
+ }
+
+ const div = document.createElement('div');
+ div.append(fragment);
+ src = div.innerHTML;
+ }
+
+ src = src.replace(FAST_EXPRESSION_COMMENTS, ''); // remove comments
+ src = src.replace(/=""/g, ''); // remove values for boolean attributes
+ src = prettier.format(src, {
+ htmlWhitespaceSensitivity: 'ignore',
+ parser: 'html',
+ plugins: [prettierPluginHTML],
+ });
+ return src;
+ },
+ },
+ theme: webcomponentsTheme, // override the default Storybook theme with a custom fluent theme
+ },
+};
diff --git a/packages/charts/chart-web-components/.storybook/theme.mjs b/packages/charts/chart-web-components/.storybook/theme.mjs
new file mode 100644
index 0000000000000..515891480e7a6
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/theme.mjs
@@ -0,0 +1,34 @@
+import { create } from '@storybook/theming';
+
+export default create({
+ base: 'light',
+ brandTitle: 'Fluent UI\nChart Web Components',
+ brandUrl: 'https://github.com/microsoft/fluentui',
+
+ // Toolbar default and active colors
+ barSelectedColor: '#0078d4', // use msft primary blue default
+ barTextColor: '#222',
+
+ colorPrimary: '#dedede',
+ colorSecondary: 'deepskyblue',
+
+ // UI
+ appBg: '#ffffff',
+ appContentBg: '#ffffff',
+ appBorderColor: '#e0e0e0', // use msft gray
+ appBorderRadius: 4,
+
+ // Typography
+ fontBase:
+ '"Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;',
+ fontCode: 'monospace',
+
+ // Text colors
+ textColor: '#11100f',
+ textInverseColor: '#0078d4', // use msft primary blue default
+
+ // Form colors
+ inputBg: 'white',
+ inputTextColor: 'black',
+ inputBorderRadius: 4,
+});
diff --git a/packages/charts/chart-web-components/.storybook/tsconfig.json b/packages/charts/chart-web-components/.storybook/tsconfig.json
new file mode 100644
index 0000000000000..78905f4f65971
--- /dev/null
+++ b/packages/charts/chart-web-components/.storybook/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["*", "../public", "../src/**/*.stories.*"]
+}
diff --git a/packages/charts/chart-web-components/README.md b/packages/charts/chart-web-components/README.md
new file mode 100644
index 0000000000000..2d2f25a91b965
--- /dev/null
+++ b/packages/charts/chart-web-components/README.md
@@ -0,0 +1,92 @@
+# Fluent UI Chart Web Components
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
+[![npm version](https://img.shields.io/npm/v/@fluentui/web-components/beta?style=flat-square)](https://www.npmjs.com/package/@fluentui/web-components/v/beta)
+
+Microsoft's [Fluent UI Web Components](https://github.com/microsoft/fluentui/tree/master/packages/web-components) is designed to help you build web apps using Web Components styled with the [Fluent design language](https://github.com/microsoft/fluentui).
+
+## Installation
+
+## Install
+
+Fluent UI should be installed as a `dependency` of your app.
+
+**Yarn**
+
+```sh
+yarn add @fluentui/web-components@beta
+```
+
+**NPM**
+
+```sh
+npm i @fluentui/web-components@beta
+```
+
+**pnpm**
+
+```sh
+pnpm add @fluentui/web-components@beta
+```
+
+### From CDN
+
+A pre-bundled script that contains all APIs needed to use FAST Foundation is available on CDN. You can use this script by adding [`type="module"`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) to the script element and then importing from the CDN.
+
+```html
+
+
+
+
+
+
+
+```
+
+The above CDN location points to the latest Beta release of `@fluentui/web-components`. It is advised that when you deploy your site or app, you import the specific version you have developed and tested with.
+
+For simplicity, examples throughout the documentation will assume the library has been installed from NPM, but you can always replace the import location with the CDN URL.
+
+## Setup
+
+Fluent UI Web Components are styled using tokens in the form of CSS variables. You can use the `setTheme` utility to provide a theme for your website or application.
+
+```js
+import { setTheme } from '@fluentui/web-components';
+import { webLightTheme } from '@fluentui/tokens';
+
+setTheme(webLightTheme);
+```
+
+## Usage
+
+That's it. You can now use Fluent UI Web Components in your app.
+
+**Importing the defined component:**
+
+```js
+import '@fluentui/web-components/button.js';
+```
+
+**Defining the element yourself using named imports:**
+
+```js
+import { ButtonDefinition, FluentDesignSystem } from '@fluentui/web-components';
+
+ButtonDefinition.define(FluentDesignSystem.registry);
+```
+
+## Development
+
+To start the component development environment, run `yarn start`.
+
+### Known issue with Storybook site hot-reloading during development
+
+Storybook will watch modules for changes and hot-reload the module when necessary. This is usually great but poses a problem when the module being hot-reloaded defines a custom element. A custom element name can only be defined by the `CustomElementsRegistry` once, so reloading a module that defines a custom element will attempt to re-register the custom element name, throwing an error because the name has already been defined. This error will manifest with the following message:
+`Failed to execute 'define' on 'CustomElementRegistry': the name "my-custom-element-name" has already been used with this registry`
+
+This is a known issue and will indicate that you need to refresh the page. We're working on surfacing a more instructive error message for this case.
+
+## Testing
+
+When testing locally, start the dev server and in a separate terminal window, run `yarn test:dev` within the web-components folder.
diff --git a/packages/charts/chart-web-components/api-extractor.json b/packages/charts/chart-web-components/api-extractor.json
new file mode 100644
index 0000000000000..146de64b6d95e
--- /dev/null
+++ b/packages/charts/chart-web-components/api-extractor.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+
+ "mainEntryPointFilePath": "/dist/dts/index.d.ts",
+
+ "apiReport": {
+ "enabled": true,
+ "reportFolder": "/docs",
+ "reportFileName": "api-report.md"
+ },
+
+ "docModel": {
+ "enabled": true,
+ "apiJsonFilePath": "/dist/chart-web-components.api.json"
+ },
+ "dtsRollup": {
+ "enabled": true
+ },
+ "compiler": {
+ "skipLibCheck": false,
+ "tsconfigFilePath": "./tsconfig.api-extractor.json"
+ },
+ "messages": {
+ "extractorMessageReporting": {
+ "ae-missing-release-tag": {
+ "logLevel": "none",
+ "addToApiReportFile": true
+ },
+ "ae-different-release-tags": {
+ "logLevel": "none",
+ "addToApiReportFile": true
+ }
+ }
+ }
+}
diff --git a/packages/charts/chart-web-components/docs/api-report.md b/packages/charts/chart-web-components/docs/api-report.md
new file mode 100644
index 0000000000000..a315772fcfce4
--- /dev/null
+++ b/packages/charts/chart-web-components/docs/api-report.md
@@ -0,0 +1,161 @@
+## API Report File for "@fluentui/chart-web-components"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ElementStyles } from '@microsoft/fast-element';
+import { ElementViewTemplate } from '@microsoft/fast-element';
+import { FASTElement } from '@microsoft/fast-element';
+import { FASTElementDefinition } from '@microsoft/fast-element';
+import { Selection as Selection_2 } from 'd3-selection';
+
+// Warning: (ae-missing-release-tag) "DonutChart" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export class DonutChart extends FASTElement {
+ constructor();
+ // (undocumented)
+ activeLegend: string;
+ // (undocumented)
+ activeLegendChanged(oldValue: string, newValue: string): void;
+ // (undocumented)
+ chartWrapper: HTMLDivElement;
+ // (undocumented)
+ connectedCallback(): void;
+ // Warning: (ae-forgotten-export) The symbol "ChartProps_2" needs to be exported by the entry point index.d.ts
+ //
+ // (undocumented)
+ data: ChartProps_2;
+ // (undocumented)
+ elementInternals: ElementInternals;
+ // (undocumented)
+ getLegends(): Legend[];
+ // (undocumented)
+ group: SVGGElement;
+ // (undocumented)
+ handleLegendClick(legendTitle: string): void;
+ // (undocumented)
+ handleLegendMouseoutAndBlur(): void;
+ // (undocumented)
+ handleLegendMouseoverAndFocus(legendTitle: string): void;
+ // (undocumented)
+ height: number;
+ // (undocumented)
+ hideLegends: boolean;
+ // (undocumented)
+ hideTooltip: boolean;
+ // (undocumented)
+ innerRadius: number;
+ // (undocumented)
+ isLegendSelected: boolean;
+ // (undocumented)
+ legendListLabel?: string;
+ // Warning: (ae-forgotten-export) The symbol "Legend" needs to be exported by the entry point index.d.ts
+ //
+ // (undocumented)
+ legends: Legend[];
+ // (undocumented)
+ rootDiv: HTMLDivElement;
+ // (undocumented)
+ tooltipProps: {
+ isVisible: boolean;
+ legend: string;
+ yValue: string;
+ color: string;
+ xPos: number;
+ yPos: number;
+ };
+ // (undocumented)
+ tooltipPropsChanged(oldValue: any, newValue: any): void;
+ // (undocumented)
+ valueInsideDonut?: string;
+ // (undocumented)
+ width: number;
+}
+
+// @public (undocumented)
+export const DonutChartDefinition: FASTElementDefinition;
+
+// @public
+export const DonutChartStyles: ElementStyles;
+
+// Warning: (ae-internal-missing-underscore) The name "DonutChartTemplate" should be prefixed with an underscore because the declaration is marked as @internal
+//
+// @internal (undocumented)
+export const DonutChartTemplate: ElementViewTemplate;
+
+// @public
+export class HorizontalBarChart extends FASTElement {
+ constructor();
+ // (undocumented)
+ activeLegend: string;
+ // (undocumented)
+ activeLegendChanged: (oldValue: string, newValue: string) => void;
+ // (undocumented)
+ chartContainer: HTMLDivElement;
+ // (undocumented)
+ chartTitle?: string;
+ // (undocumented)
+ connectedCallback(): void;
+ // (undocumented)
+ _createBarsAndLegends(data: ChartProps, barNo?: number): Selection_2;
+ // Warning: (ae-forgotten-export) The symbol "ChartProps" needs to be exported by the entry point index.d.ts
+ //
+ // (undocumented)
+ data: ChartProps[];
+ // (undocumented)
+ elementInternals: ElementInternals;
+ // (undocumented)
+ handleLegendClick: (legendTitle: string) => void;
+ // (undocumented)
+ handleLegendMouseoutAndBlur: () => void;
+ // (undocumented)
+ handleLegendMouseoverAndFocus: (legendTitle: string) => void;
+ // (undocumented)
+ hideLegends: boolean;
+ // (undocumented)
+ hideRatio: boolean;
+ // (undocumented)
+ hideTooltip: boolean;
+ // (undocumented)
+ isLegendSelected: boolean;
+ // (undocumented)
+ legendListLabel?: string;
+ // (undocumented)
+ renderChart(): void;
+ // (undocumented)
+ rootDiv: HTMLDivElement;
+ // (undocumented)
+ tooltipProps: {
+ isVisible: boolean;
+ legend: string;
+ yValue: string;
+ color: string;
+ xPos: number;
+ yPos: number;
+ };
+ // Warning: (ae-forgotten-export) The symbol "ChartDataPoint" needs to be exported by the entry point index.d.ts
+ //
+ // (undocumented)
+ uniqueLegends: ChartDataPoint[];
+ // Warning: (ae-forgotten-export) The symbol "Variant" needs to be exported by the entry point index.d.ts
+ //
+ // (undocumented)
+ variant?: Variant;
+}
+
+// @public
+export const HorizontalBarChartDefinition: FASTElementDefinition;
+
+// @public
+export const HorizontalBarChartStyles: ElementStyles;
+
+// Warning: (ae-internal-missing-underscore) The name "HorizontalBarChartTemplate" should be prefixed with an underscore because the declaration is marked as @internal
+//
+// @internal (undocumented)
+export const HorizontalBarChartTemplate: ElementViewTemplate;
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/packages/charts/chart-web-components/package.json b/packages/charts/chart-web-components/package.json
new file mode 100644
index 0000000000000..f309235c412b5
--- /dev/null
+++ b/packages/charts/chart-web-components/package.json
@@ -0,0 +1,120 @@
+{
+ "name": "@fluentui/chart-web-components",
+ "description": "A library of Fluent Chart Web Components",
+ "version": "0.0.0-alpha.1",
+ "author": {
+ "name": "Microsoft"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/microsoft/fluentui/tree/master/packages/charts/chart-web-components"
+ },
+ "bugs": {
+ "url": "https://github.com/Microsoft/fluentui/issues/new/choose"
+ },
+ "type": "module",
+ "main": "dist/esm/index.js",
+ "types": "dist/chart-web-components.d.ts",
+ "unpkg": "dist/chart-web-components.min.js",
+ "files": [
+ "*.md",
+ "dist/dts/",
+ "dist/esm/",
+ "dist/*.js",
+ "dist/*.d.ts"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/dts/index.d.ts",
+ "default": "./dist/esm/index.js"
+ },
+ "./utilities.js": {
+ "types": "./dist/dts/utils/index.d.ts",
+ "default": "./dist/esm/utils/index.js"
+ },
+ "./*/define.js": {
+ "types": "./dist/dts/*/*.define.d.ts",
+ "default": "./dist/esm/*/*.define.js"
+ },
+ "./*/definition.js": {
+ "types": "./dist/dts/*/*.definition.d.ts",
+ "default": "./dist/esm/*/*.definition.js"
+ },
+ "./*/options.js": {
+ "types": "./dist/dts/*/*.options.d.ts",
+ "default": "./dist/esm/*/*.options.js"
+ },
+ "./*/styles.js": {
+ "types": "./dist/dts/*/*.styles.d.ts",
+ "default": "./dist/esm/*/*.styles.js"
+ },
+ "./*/template.js": {
+ "types": "./dist/dts/*/*.template.d.ts",
+ "default": "./dist/esm/*/*.template.js"
+ },
+ "./*/index.js": {
+ "types": "./dist/dts/*/index.d.ts",
+ "default": "./dist/esm/*/index.js"
+ },
+ "./*.js": {
+ "types": "./dist/dts/*/define.d.ts",
+ "default": "./dist/esm/*/define.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": [
+ "./dist/esm/**/define.js",
+ "./dist/chart-web-components.js",
+ "./dist/chart-web-components.min.js"
+ ],
+ "scripts": {
+ "verify-packaging": "node ./scripts/verify-packaging",
+ "type-check": "node ./scripts/type-check",
+ "benchmark": "yarn clean && yarn compile:benchmark && yarn compile && node ./scripts/run-benchmarks",
+ "compile": "node ./scripts/compile",
+ "compile:benchmark": "rollup -c rollup.bench.js",
+ "clean": "node ./scripts/clean dist",
+ "generate-api": "api-extractor run --local",
+ "build": "yarn compile && yarn rollup -c && yarn generate-api",
+ "lint": "eslint . --ext .ts",
+ "lint:fix": "eslint . --ext .ts --fix",
+ "format": "prettier -w src/**/*.{ts,html} --ignore-path ../../.prettierignore",
+ "format:check": "yarn format -c",
+ "code-style": "yarn format:check && yarn lint",
+ "start": "yarn start-storybook -p 6006 --docs",
+ "start-storybook": "storybook dev",
+ "build-storybook": "storybook build -o ./dist/storybook --docs",
+ "e2e": "playwright test",
+ "test:dev": "playwright test"
+ },
+ "devDependencies": {
+ "@microsoft/fast-element": "2.0.0",
+ "@tensile-perf/web-components": "~0.2.0",
+ "@storybook/html": "7.6.20",
+ "@storybook/html-webpack5": "7.6.20",
+ "chromedriver": "^125.0.0"
+ },
+ "dependencies": {
+ "@microsoft/fast-web-utilities": "^6.0.0",
+ "@fluentui/tokens": "1.0.0-alpha.18",
+ "@fluentui/web-components": "3.0.0-beta.72",
+ "@types/d3-selection": "^3.0.0",
+ "@types/d3-shape": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "d3-shape": "^3.0.0",
+ "tabbable": "^6.2.0",
+ "tslib": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@microsoft/fast-element": "^2.0.0-beta.26 || ^2.0.0"
+ },
+ "beachball": {
+ "disallowedChangeTypes": [
+ "major",
+ "minor",
+ "patch"
+ ],
+ "tag": "beta"
+ }
+}
diff --git a/packages/charts/chart-web-components/playwright.config.ts b/packages/charts/chart-web-components/playwright.config.ts
new file mode 100644
index 0000000000000..0b2718305e1ca
--- /dev/null
+++ b/packages/charts/chart-web-components/playwright.config.ts
@@ -0,0 +1,41 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+import { devices } from '@playwright/test';
+
+const config: PlaywrightTestConfig = {
+ reporter: 'list',
+ retries: 3,
+ fullyParallel: process.env.CI ? false : true,
+ timeout: process.env.CI ? 10000 : 30000,
+ use: {
+ baseURL: 'http://localhost:6006/iframe.html',
+ viewport: {
+ height: 720,
+ width: 1280,
+ },
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ testMatch: /.*\.spec\.ts$/,
+ },
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ testMatch: [/set-theme\.spec\.ts$/],
+ },
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ testMatch: [/set-theme\.spec\.ts$/],
+ },
+ ],
+ webServer: {
+ // double-quotes are required for Windows
+ command: `node -e "import('express').then(({ default: e }) => e().use(e.static('./dist/storybook')).listen(6006))"`,
+ port: 6006,
+ reuseExistingServer: process.env.CI ? false : true,
+ },
+};
+
+export default config;
diff --git a/packages/charts/chart-web-components/project.json b/packages/charts/chart-web-components/project.json
new file mode 100644
index 0000000000000..023ee8ab08c97
--- /dev/null
+++ b/packages/charts/chart-web-components/project.json
@@ -0,0 +1,10 @@
+{
+ "name": "chart-web-components",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "implicitDependencies": [],
+ "tags": ["platform:web", "web-components"],
+ "targets": {
+ "e2e": { "dependsOn": ["build-storybook"] }
+ }
+}
diff --git a/packages/charts/chart-web-components/public/100x100.png b/packages/charts/chart-web-components/public/100x100.png
new file mode 100644
index 0000000000000..9f5afbe735685
Binary files /dev/null and b/packages/charts/chart-web-components/public/100x100.png differ
diff --git a/packages/charts/chart-web-components/public/150x150.png b/packages/charts/chart-web-components/public/150x150.png
new file mode 100644
index 0000000000000..aabcb44e6c330
Binary files /dev/null and b/packages/charts/chart-web-components/public/150x150.png differ
diff --git a/packages/charts/chart-web-components/public/200x100.png b/packages/charts/chart-web-components/public/200x100.png
new file mode 100644
index 0000000000000..8d74b825c2290
Binary files /dev/null and b/packages/charts/chart-web-components/public/200x100.png differ
diff --git a/packages/charts/chart-web-components/public/300x100.png b/packages/charts/chart-web-components/public/300x100.png
new file mode 100644
index 0000000000000..1f44e10e5ed52
Binary files /dev/null and b/packages/charts/chart-web-components/public/300x100.png differ
diff --git a/packages/charts/chart-web-components/public/400x200.png b/packages/charts/chart-web-components/public/400x200.png
new file mode 100644
index 0000000000000..131a4f784cdfb
Binary files /dev/null and b/packages/charts/chart-web-components/public/400x200.png differ
diff --git a/packages/charts/chart-web-components/public/400x250.png b/packages/charts/chart-web-components/public/400x250.png
new file mode 100644
index 0000000000000..5a0f6353a8cbc
Binary files /dev/null and b/packages/charts/chart-web-components/public/400x250.png differ
diff --git a/packages/charts/chart-web-components/public/400x300.png b/packages/charts/chart-web-components/public/400x300.png
new file mode 100644
index 0000000000000..675c8ddc2580d
Binary files /dev/null and b/packages/charts/chart-web-components/public/400x300.png differ
diff --git a/packages/charts/chart-web-components/public/600x200.png b/packages/charts/chart-web-components/public/600x200.png
new file mode 100644
index 0000000000000..1e17b4ec1f030
Binary files /dev/null and b/packages/charts/chart-web-components/public/600x200.png differ
diff --git a/packages/charts/chart-web-components/public/958x20.png b/packages/charts/chart-web-components/public/958x20.png
new file mode 100644
index 0000000000000..2d802cf6bce66
Binary files /dev/null and b/packages/charts/chart-web-components/public/958x20.png differ
diff --git a/packages/charts/chart-web-components/public/SegoeUI-VF.ttf b/packages/charts/chart-web-components/public/SegoeUI-VF.ttf
new file mode 100644
index 0000000000000..859db801de8fd
Binary files /dev/null and b/packages/charts/chart-web-components/public/SegoeUI-VF.ttf differ
diff --git a/packages/charts/chart-web-components/public/favicon.ico b/packages/charts/chart-web-components/public/favicon.ico
new file mode 100644
index 0000000000000..bfe873eb228f9
Binary files /dev/null and b/packages/charts/chart-web-components/public/favicon.ico differ
diff --git a/packages/charts/chart-web-components/public/favicon.png b/packages/charts/chart-web-components/public/favicon.png
new file mode 100644
index 0000000000000..bfe873eb228f9
Binary files /dev/null and b/packages/charts/chart-web-components/public/favicon.png differ
diff --git a/packages/charts/chart-web-components/public/shell.css b/packages/charts/chart-web-components/public/shell.css
new file mode 100644
index 0000000000000..21dc2f875e590
--- /dev/null
+++ b/packages/charts/chart-web-components/public/shell.css
@@ -0,0 +1,82 @@
+/* This file should stay synchronized with the React v9 storybook styles. */
+
+/* sidebar logo (Web Components uses text) */
+.sidebar-header > div:first-of-type {
+ font-size: 20px;
+ white-space: break-spaces;
+ margin-right: 0;
+}
+
+/* remove sidebar shortcuts menu */
+.sidebar-header > div:last-child {
+ display: none;
+}
+
+/* Add left side background color splash */
+/* colors become distracting in mobile layout so scoped to where sidebar is visible */
+@media (min-width: 600px) {
+ #storybook-root > div:before {
+ content: '';
+ position: absolute;
+ top: -200px;
+ left: -200px;
+ width: 400px;
+ height: 400px;
+ background: #c989e8;
+ opacity: 0.5;
+ filter: blur(150px);
+ }
+
+ /* Add right side background color splash */
+ #storybook-root > div:after {
+ content: '';
+ position: absolute;
+ top: -200px;
+ right: -200px;
+ width: 400px;
+ height: 400px;
+ background: #b3d4ff;
+ opacity: 0.5;
+ filter: blur(150px);
+ }
+}
+
+/* Give sidebar a transparent white background to match design */
+.sidebar-container {
+ background: rgba(255, 255, 255, 0.6);
+}
+
+/* remove background preventing color splash from showing */
+#storybook-preview-wrapper {
+ background: transparent;
+}
+
+/*
+ * Set position fixed to create a layer and prevent iframe from jumping when content is
+ * larger than the viewport and the iframe itself
+ */
+[role='main'] {
+ position: fixed;
+ top: 0 !important;
+}
+
+/* remove box shadow style from storybooks wrapper div */
+[role='main'] > div {
+ box-shadow: none;
+}
+
+/* permanently hide toolbar so animation never appears on page load */
+[role='main'] .os-host {
+ display: none;
+}
+
+/* stop offset from changing page dimensions when 't' is pressed and toolbar opened */
+[role='main'] > div > div > div {
+ top: 0 !important;
+ height: 100% !important;
+}
+
+/* Remove 'Published on Chromatic' banner */
+#back-to-chromatic {
+ display: none !important;
+}
diff --git a/packages/charts/chart-web-components/rollup.bench.js b/packages/charts/chart-web-components/rollup.bench.js
new file mode 100644
index 0000000000000..3b8e9d8bb711f
--- /dev/null
+++ b/packages/charts/chart-web-components/rollup.bench.js
@@ -0,0 +1,21 @@
+import { nodeResolve } from '@rollup/plugin-node-resolve';
+import esbuild from 'rollup-plugin-esbuild';
+import commonJS from 'rollup-plugin-commonjs';
+
+const plugins = [nodeResolve({ browser: true }), commonJS(), esbuild({ tsconfig: './tsconfig.json' })];
+
+export default [
+ {
+ input: {
+ tokens: './src/utils/benchmark-dependencies/tokens.ts',
+ },
+ output: [
+ {
+ dir: './.tensile/benchmark-dependencies',
+ format: 'esm',
+ sourcemap: true,
+ },
+ ],
+ plugins,
+ },
+];
diff --git a/packages/charts/chart-web-components/rollup.config.js b/packages/charts/chart-web-components/rollup.config.js
new file mode 100644
index 0000000000000..f069b180ee587
--- /dev/null
+++ b/packages/charts/chart-web-components/rollup.config.js
@@ -0,0 +1,43 @@
+import { nodeResolve } from '@rollup/plugin-node-resolve';
+import commonJS from 'rollup-plugin-commonjs';
+import esbuild, { minify } from 'rollup-plugin-esbuild';
+import transformTaggedTemplate from 'rollup-plugin-transform-tagged-template';
+import { transformCSSFragment, transformHTMLFragment } from './scripts/transform-fragments';
+
+const parserOptions = {
+ sourceType: 'module',
+};
+
+export default [
+ {
+ input: 'src/index-rollup.ts',
+ output: [
+ {
+ file: 'dist/chart-web-components.js',
+ format: 'esm',
+ },
+ {
+ file: 'dist/chart-web-components.min.js',
+ format: 'esm',
+ plugins: [minify()],
+ },
+ ],
+ plugins: [
+ nodeResolve({ browser: true }),
+ commonJS(),
+ esbuild({
+ tsconfig: './tsconfig.lib.json',
+ }),
+ transformTaggedTemplate({
+ tagsToProcess: ['css'],
+ transformer: transformCSSFragment,
+ parserOptions,
+ }),
+ transformTaggedTemplate({
+ tagsToProcess: ['html'],
+ transformer: transformHTMLFragment,
+ parserOptions,
+ }),
+ ],
+ },
+];
diff --git a/packages/charts/chart-web-components/scripts/clean.js b/packages/charts/chart-web-components/scripts/clean.js
new file mode 100644
index 0000000000000..c7df6e70a1602
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/clean.js
@@ -0,0 +1,50 @@
+/* eslint-disable no-undef */
+/**
+ * Utility for cleaning directories.
+ * Usage: node build/clean.js %path%
+ */
+import * as path from 'path';
+import * as fsPromises from 'node:fs/promises';
+import yargs from 'yargs';
+
+main();
+
+/**
+ * Function to remove a given path
+ */
+function cleanPath(cleanPath) {
+ const removePath = path.resolve(process.cwd(), cleanPath);
+
+ const result = fsPromises.rm(removePath, { recursive: true }).then(() => {
+ console.log(removePath, 'cleaned');
+ });
+
+ return result;
+}
+
+function main() {
+ const argv = yargs.argv;
+
+ /**
+ * All paths passed to the clean script
+ */
+ const paths = argv._;
+
+ /**
+ * Clean all paths
+ */
+ if (!Array.isArray(paths)) {
+ throw new Error('"paths" must be an array');
+ }
+
+ const result = paths.map(cleanPath);
+
+ Promise.all(result)
+ .then(() => {
+ console.log('All paths cleaned');
+ })
+ .catch(error => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/packages/charts/chart-web-components/scripts/compile.js b/packages/charts/chart-web-components/scripts/compile.js
new file mode 100644
index 0000000000000..a2c8eac86b3ac
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/compile.js
@@ -0,0 +1,24 @@
+/* eslint-disable no-undef */
+
+import { execSync } from 'child_process';
+import chalk from 'chalk';
+
+main();
+
+function compile() {
+ try {
+ console.log(chalk.bold(`🎬 compile:start`));
+
+ console.log(chalk.blueBright(`compile: running tsc`));
+ execSync(`tsc -p tsconfig.lib.json --rootDir ./src --baseUrl .`, { stdio: 'inherit' });
+
+ console.log(chalk.bold(`🏁 compile:end`));
+ } catch (err) {
+ console.error(err);
+ process.exit(1);
+ }
+}
+
+function main() {
+ compile();
+}
diff --git a/packages/charts/chart-web-components/scripts/run-benchmarks.js b/packages/charts/chart-web-components/scripts/run-benchmarks.js
new file mode 100644
index 0000000000000..594e10bc1d7b6
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/run-benchmarks.js
@@ -0,0 +1,42 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { execSync } from 'child_process';
+
+const rootDir = path.join(import.meta.dirname, '..');
+const tensileConfig = 'tensile.config.js';
+
+try {
+ const esmOutput = path.join(rootDir, 'dist', 'esm');
+ const items = await fs.readdir(esmOutput);
+
+ // Collect all component folders
+ const folders = [];
+ for (const item of items) {
+ const itemPath = path.join(esmOutput, item);
+ const stats = await fs.lstat(itemPath);
+ if (stats.isDirectory()) {
+ folders.push(item);
+ }
+ }
+
+ // Collect all .bench.js files
+ const benchFiles = [];
+ for (const folder of folders) {
+ const folderPath = path.join(esmOutput, folder);
+ const files = await fs.readdir(folderPath);
+ const filteredFiles = files.filter(file => file.endsWith('.bench.js'));
+ benchFiles.push(...filteredFiles.map(file => path.relative(rootDir, path.join(folderPath, file))));
+ }
+
+ // Execute tensile for each .bench.js file
+ for (const file of benchFiles) {
+ try {
+ // eslint-disable-next-line no-undef
+ execSync(`tensile --file ./${file} --config ${tensileConfig} ${process.argv[2]}`, { stdio: 'inherit' });
+ } catch (error) {
+ console.error(`Error executing command for file ${file}: ${error.message}`);
+ }
+ }
+} catch (error) {
+ console.error(`Error reading directory: ${error.message}`);
+}
diff --git a/packages/charts/chart-web-components/scripts/setup-browser.cjs b/packages/charts/chart-web-components/scripts/setup-browser.cjs
new file mode 100644
index 0000000000000..86e98bc29b537
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/setup-browser.cjs
@@ -0,0 +1,11 @@
+/* eslint-disable no-undef */
+/**
+ *
+ * @param r {__WebpackModuleApi.RequireContext}
+ */
+function importAll(r) {
+ r.keys().forEach(r);
+}
+
+// Explicitly add to browser test
+importAll(require.context('../dist/esm', true, /\.spec\.js$/));
diff --git a/packages/charts/chart-web-components/scripts/transform-fragments.js b/packages/charts/chart-web-components/scripts/transform-fragments.js
new file mode 100644
index 0000000000000..8db1b711c9881
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/transform-fragments.js
@@ -0,0 +1,29 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/typedef */
+
+/**
+ * Reduces extra spaces in HTML tagged templates.
+ *
+ * @param {string} data - the fragment value
+ * @returns string
+ */
+export function transformHTMLFragment(data) {
+ data = data.replace(/\s*([<>])\s*/g, '$1'); // remove spaces before and after angle brackets
+ return data.replace(/\s{2,}/g, ' '); // Collapse all sequences to 1 space
+}
+
+/**
+ * Reduces extra spaces in CSS tagged templates.
+ *
+ * Breakdown of this regex:
+ * (?:\s*\/\*(?:.|\s)+?\*\/\s*) Remove comments (non-capturing)
+ * (?:;)\s+(?=\}) Remove semicolons and spaces followed by property list end (non-capturing)
+ * \s+(?=\{) Remove spaces before property list start (non-capturing)
+ * (?<=:)\s+ Remove spaces after property declarations (non-capturing)
+ * \s*([{};,])\s* Remove extra spaces before and after braces, semicolons, and commas (captures)
+ *
+ * @param {string} data - the fragment value
+ * @returns string
+ */
+export function transformCSSFragment(data) {
+ return data.replace(/(?:\s*\/\*(?:.|\s)+?\*\/\s*)|(?:;)\s+(?=\})|\s+(?=\{)|(?<=:)\s+|\s*([{};,])\s*/g, '$1');
+}
diff --git a/packages/charts/chart-web-components/scripts/type-check.js b/packages/charts/chart-web-components/scripts/type-check.js
new file mode 100644
index 0000000000000..5cd034b7ceb21
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/type-check.js
@@ -0,0 +1,58 @@
+// @ts-check
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { promisify } from 'node:util';
+import { exec } from 'node:child_process';
+import { exit } from 'node:process';
+
+const asyncExec = promisify(exec);
+
+main().catch(err => {
+ console.error(err);
+ exit(1);
+});
+
+/**
+ * Copied from ${@link 'file://./../../../../scripts/tasks/src/type-check.ts'}
+ */
+async function main() {
+ const rootConfig = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '../tsconfig.json'), 'utf-8'));
+
+ const tsConfigsRefs = getTsConfigs(rootConfig, { spec: false, e2e: false });
+
+ const asyncQueue = [];
+
+ for (const ref of tsConfigsRefs) {
+ const program = `tsc -p ${ref} --pretty --noEmit --baseUrl .`;
+ asyncQueue.push(asyncExec(program));
+ }
+
+ return Promise.all(asyncQueue).catch(err => {
+ console.error(err.stdout);
+ exit(1);
+ });
+}
+
+/**
+ * @param {{references?: Array<{ path: string }>;}} solutionConfig
+ * @param {{ spec: boolean, e2e: boolean }} exclude
+ */
+function getTsConfigs(solutionConfig, exclude) {
+ const refs = solutionConfig.references ?? [];
+ /** @type {string[]} */
+ const refsPaths = [];
+
+ for (const ref of refs) {
+ if (exclude.spec && ref.path.includes('spec')) {
+ continue;
+ }
+ if (exclude.e2e && ref.path.includes('cy')) {
+ continue;
+ }
+
+ refsPaths.push(ref.path);
+ }
+
+ return refsPaths;
+}
diff --git a/packages/charts/chart-web-components/scripts/verify-packaging.js b/packages/charts/chart-web-components/scripts/verify-packaging.js
new file mode 100644
index 0000000000000..19aeece5fa2eb
--- /dev/null
+++ b/packages/charts/chart-web-components/scripts/verify-packaging.js
@@ -0,0 +1,74 @@
+// @ts-check
+/**
+ * Copied from ${@link 'file://./../../../../scripts/tasks/src/verify-packaging.ts'}
+ */
+
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+import micromatch from 'micromatch';
+
+main();
+
+function main() {
+ /**
+ * @see https://docs.npmjs.com/cli/v10/commands/npm-publish#files-included-in-package
+ */
+ const alwaysPublishedFiles = ['LICENSE', 'package.json', 'README.md'];
+ const rootConfigFiles = [
+ 'just.config.[jt]s',
+ 'jest.config.[jt]s',
+ '.eslintrc.(js|json)',
+ 'project.json',
+ '.babelrc.json',
+ '.swcrc',
+ 'tsconfig(.*)?.json',
+ ];
+ const nonProdAssets = ['assets/', 'docs/*', 'temp/*', 'bundle-size/*', '.storybook/*', 'stories/*'];
+
+ verifyPackaging({ alwaysPublishedFiles, nonProdAssets, rootConfigFiles });
+}
+
+/**
+ *
+ * @param {{alwaysPublishedFiles:string[];rootConfigFiles:string[];nonProdAssets:string[]}} options
+ * @returns
+ */
+
+function verifyPackaging(options) {
+ const { alwaysPublishedFiles, nonProdAssets, rootConfigFiles } = options;
+ const root = path.join(import.meta.dirname, '../');
+
+ /** @type {{ private?: boolean }} */
+ const packageJSON = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf-8'));
+
+ // no need to check if package is not being published yet
+ if (packageJSON.private) {
+ return;
+ }
+
+ const npmPackResult = spawnSync('npm', ['pack', '--dry-run']);
+
+ const processedResult = npmPackResult.output
+ .toString()
+ .replace(/\bnpm notice\b\s+[\d.]+[kB]+\s+/gi, '')
+ .replace(/[ ]+/g, '');
+ const processedResultArr = processedResult.split('\n');
+
+ assert.ok(micromatch(processedResultArr, alwaysPublishedFiles).length, `npm always shipped files`);
+ assert.equal(
+ micromatch(processedResultArr, nonProdAssets).length,
+ 0,
+ `wont ship non production code related folders/files`,
+ );
+ assert.equal(micromatch(processedResultArr, 'dist/storybook/**').length, 0, `wont ship storybook assets`);
+ assert.equal(micromatch(processedResultArr, rootConfigFiles).length, 0, `wont ship configuration files`);
+ assert.ok(micromatch(processedResultArr, 'dist/*.d.ts').length, 'ships rolluped dts');
+ assert.ok(micromatch(processedResultArr, 'dist/*.(min.js|js)').length, 'ships rolluped js');
+ assert.equal(micromatch(processedResultArr, 'src/*').length, 0, `wont ship source code from "/src"`);
+
+ assert.ok(micromatch(processedResultArr, 'dist/esm/**/*.(js|map)').length, 'ships esm');
+ assert.ok(micromatch(processedResultArr, 'dist/dts/**/*.d.ts').length, 'ships types');
+}
diff --git a/packages/charts/chart-web-components/src/donut-chart/define.ts b/packages/charts/chart-web-components/src/donut-chart/define.ts
new file mode 100644
index 0000000000000..1b8e20ac99e32
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/define.ts
@@ -0,0 +1,4 @@
+import { FluentDesignSystem } from '@fluentui/web-components';
+import { definition } from './donut-chart.definition.js';
+
+definition.define(FluentDesignSystem.registry);
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.bench.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.bench.ts
new file mode 100644
index 0000000000000..5936ecc050474
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.bench.ts
@@ -0,0 +1,12 @@
+import { FluentDesignSystem } from '@fluentui/web-components';
+import { definition } from './donut-chart.definition.js';
+
+definition.define(FluentDesignSystem.registry);
+
+const itemRenderer = () => {
+ const donutChart = document.createElement('fluent-donut-chart');
+ return donutChart;
+};
+
+export default itemRenderer;
+export { tests } from '../utils/benchmark-wrapper.js';
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.definition.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.definition.ts
new file mode 100644
index 0000000000000..3cf608dc04648
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.definition.ts
@@ -0,0 +1,18 @@
+import { FluentDesignSystem } from '@fluentui/web-components';
+import { DonutChart } from './donut-chart.js';
+import { styles } from './donut-chart.styles.js';
+import { template } from './donut-chart.template.js';
+
+/**
+ * @public
+ * @remarks
+ * HTML Element: ``
+ */
+export const definition = DonutChart.compose({
+ name: `${FluentDesignSystem.prefix}-donut-chart`,
+ template,
+ styles,
+ shadowOptions: {
+ delegatesFocus: true,
+ },
+});
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.options.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.options.ts
new file mode 100644
index 0000000000000..75f288ca6bd9e
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.options.ts
@@ -0,0 +1,45 @@
+export interface ChartDataPoint {
+ /**
+ * Legend text for the datapoint in the chart
+ */
+ legend: string;
+
+ /**
+ * data the datapoint in the chart
+ */
+ data: number;
+
+ /**
+ * Color for the legend in the chart. If not provided, it will fallback on the default color palette.
+ */
+ color?: string;
+
+ /**
+ * Callout data for x axis
+ * This is an optional prop, If haven;t given legend will take
+ */
+ xAxisCalloutData?: string;
+
+ /**
+ * Callout data for y axis
+ * This is an optional prop, If haven't given data will take
+ */
+ yAxisCalloutData?: string;
+}
+
+export interface ChartProps {
+ /**
+ * chart title for the chart
+ */
+ chartTitle?: string;
+
+ /**
+ * data for the points in the chart
+ */
+ chartData: ChartDataPoint[];
+}
+
+export type Legend = {
+ title: string;
+ color: string;
+};
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.spec.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.spec.ts
new file mode 100644
index 0000000000000..72fd038e6e1cc
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.spec.ts
@@ -0,0 +1,139 @@
+import { test } from '@playwright/test';
+import { expect, fixtureURL } from '../helpers.tests.js';
+import { ChartDataPoint, ChartProps } from './donut-chart.options.js';
+
+const points: ChartDataPoint[] = [
+ {
+ legend: 'first',
+ data: 20000,
+ },
+ {
+ legend: 'second',
+ data: 39000,
+ },
+];
+
+const data: ChartProps = {
+ chartTitle: 'Donut chart basic example',
+ chartData: points,
+};
+
+test.describe('Donut-chart - Basic', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fixtureURL('components-donutchart--basic'));
+ await page.setContent(/* html */ `
+
+
+
+
+ `);
+ await page.waitForFunction(() => customElements.whenDefined('fluent-donut-chart'));
+ });
+
+ test('Should render chart properly', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const legends = element.locator('.legend-text');
+ await expect(legends.nth(0).getByText('first')).toBeVisible();
+ await expect(legends.nth(1).getByText('second')).toBeVisible();
+ await expect(element.getByText('39,000')).toBeVisible();
+ });
+
+ test('Should render path with proper attributes and css', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const arcList = element.locator('.arc');
+ await expect(arcList).toHaveCount(2);
+ await expect(arcList.nth(0)).toHaveAttribute('fill', '#637cef');
+ await expect(arcList.nth(0)).toHaveAttribute('aria-label', 'first, 20000.');
+ await expect(arcList.nth(0)).toHaveAttribute(
+ 'd',
+ 'M-76.547,47.334A90,90,0,0,1,-1.055,-89.994L-1.055,-54.99A55,55,0,0,0,-46.993,28.577Z',
+ );
+ await expect(arcList.nth(0)).toHaveCSS('fill', 'rgb(99, 124, 239)');
+ await expect(arcList.nth(0)).toHaveCSS('--borderRadiusMedium', '4px');
+
+ await expect(arcList.nth(1)).toHaveAttribute('fill', '#e3008c');
+ await expect(arcList.nth(1)).toHaveAttribute('aria-label', 'second, 39000.');
+ await expect(arcList.nth(1)).toHaveAttribute(
+ 'd',
+ 'M1.055,-89.994A90,90,0,1,1,-75.417,49.115L-45.863,30.358A55,55,0,1,0,1.055,-54.99Z',
+ );
+ await expect(arcList.nth(1)).toHaveCSS('fill', 'rgb(227, 0, 140)');
+ await expect(arcList.nth(1)).toHaveCSS('--borderRadiusMedium', '4px');
+ });
+
+ test('Should render legends data properly', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const legends = element.getByRole('option');
+ await expect(legends).toHaveCount(2);
+ const firstLegend = element.getByRole('option', { name: 'First' });
+ const secondLegend = element.getByRole('option', { name: 'Second' });
+ await expect(firstLegend).toBeVisible();
+ await expect(firstLegend).toHaveText('first');
+ await expect(firstLegend).toHaveCSS('--borderRadiusMedium', '4px');
+ await expect(secondLegend).toBeVisible();
+ await expect(secondLegend).toHaveText('second');
+ await expect(secondLegend).toHaveCSS('--borderRadiusMedium', '4px');
+ });
+
+ test('Should update path css values with mouse click event on legend', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const firstPath = element.getByLabel('first,');
+ const secondPath = element.getByLabel('second,');
+ const firstLegend = element.getByRole('option', { name: 'First' });
+ //mouse events
+ await firstLegend.click();
+ await expect(firstPath).toHaveCSS('opacity', '1');
+ await expect(secondPath).toHaveCSS('opacity', '0.1');
+ await firstLegend.click();
+ await expect(firstPath).toHaveCSS('opacity', '1');
+ await expect(secondPath).toHaveCSS('opacity', '1');
+ });
+
+ test('Should update path css values with mouse hover event on legend', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const firstPath = element.getByLabel('first,');
+ const secondPath = element.getByLabel('second,');
+ const firstLegend = element.getByRole('option', { name: 'First' });
+ //mouse events
+ await firstLegend.dispatchEvent('mouseover');
+ await expect(firstPath).toHaveCSS('opacity', '1');
+ await expect(secondPath).toHaveCSS('opacity', '0.1');
+ await firstLegend.dispatchEvent('mouseout');
+ await expect(firstPath).toHaveCSS('opacity', '1');
+ await expect(secondPath).toHaveCSS('opacity', '1');
+ });
+
+ test('Should show callout with mouse hover event on path', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const firstPath = element.getByLabel('first,');
+ const calloutRoot = element.locator('.tooltip');
+ await expect(calloutRoot).toHaveCount(0);
+ await firstPath.dispatchEvent('mouseover');
+ await expect(calloutRoot).toHaveCount(1);
+ await expect(calloutRoot).toHaveCSS('opacity', '1');
+ const calloutLegendText = element.locator('.tooltip-legend-text');
+ await expect(calloutLegendText).toHaveText('first');
+ const calloutContentY = element.locator('.tooltip-content-y');
+ await expect(calloutContentY).toHaveText('20000');
+ await firstPath.dispatchEvent('mouseout');
+ await expect(calloutRoot).not.toHaveCSS('opacity', '0');
+ });
+
+ test('Should update callout data when mouse moved from one path to another path', async ({ page }) => {
+ const element = page.locator('fluent-donut-chart');
+ const firstPath = element.getByLabel('first,');
+ const calloutRoot = element.locator('.tooltip');
+ await expect(calloutRoot).toHaveCount(0);
+ await firstPath.dispatchEvent('mouseover');
+ await expect(calloutRoot).toHaveCSS('opacity', '1');
+ const calloutLegendText = element.locator('.tooltip-legend-text');
+ await expect(calloutLegendText).toHaveText('first');
+ const calloutContentY = element.locator('.tooltip-content-y');
+ await expect(calloutContentY).toHaveText('20000');
+ const secondPath = element.getByLabel('second,');
+ await secondPath.dispatchEvent('mouseover');
+ await expect(calloutRoot).toHaveCSS('opacity', '1');
+ await expect(calloutLegendText).toHaveText('second');
+ await expect(calloutContentY).toHaveText('39000');
+ });
+});
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.stories.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.stories.ts
new file mode 100644
index 0000000000000..e6feb57bdf26b
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.stories.ts
@@ -0,0 +1,39 @@
+import { html } from '@microsoft/fast-element';
+import type { Meta, Story, StoryArgs } from '../helpers.stories.js';
+import { renderComponent } from '../helpers.stories.js';
+import { DonutChart as FluentDonutChart } from './donut-chart.js';
+import { ChartDataPoint, ChartProps } from './donut-chart.options.js';
+
+const points: ChartDataPoint[] = [
+ {
+ legend: 'first',
+ data: 20000,
+ },
+ {
+ legend: 'second',
+ data: 39000,
+ },
+];
+
+const data: ChartProps = {
+ chartTitle: 'Donut chart basic example',
+ chartData: points,
+};
+
+const storyTemplate = html>`
+
+
+`;
+
+export default {
+ title: 'Components/DonutChart',
+} as Meta;
+
+export const RTL: Story = renderComponent(html>`
+
+
+
+
+`);
+
+export const Basic: Story = renderComponent(storyTemplate).bind({});
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.styles.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.styles.ts
new file mode 100644
index 0000000000000..b3ef1d506d902
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.styles.ts
@@ -0,0 +1,156 @@
+import { css } from '@microsoft/fast-element';
+import {
+ borderRadiusMedium,
+ colorNeutralBackground1,
+ colorNeutralForeground1,
+ colorNeutralShadowAmbient,
+ colorNeutralShadowKey,
+ colorStrokeFocus1,
+ colorStrokeFocus2,
+ colorTransparentStroke,
+ display,
+ forcedColorsStylesheetBehavior,
+ spacingHorizontalL,
+ spacingHorizontalS,
+ spacingVerticalL,
+ spacingVerticalMNudge,
+ typographyBody1Styles,
+ typographyCaption1Styles,
+ typographyTitle2Styles,
+ typographyTitle3Styles,
+} from '@fluentui/web-components';
+
+/**
+ * Styles for the DonutChart component.
+ *
+ * @public
+ */
+export const styles = css`
+ ${display('inline-block')}
+
+ :host {
+ ${typographyBody1Styles}
+ align-items: center;
+ // display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ }
+
+ .chart {
+ box-sizing: content-box;
+ overflow: visible;
+ alignment-adjust: center;
+ display: block;
+ }
+
+ .arc.inactive {
+ opacity: 0.1;
+ }
+
+ .arc:focus {
+ outline: none;
+ stroke-width: 1px;
+ stroke: ${colorStrokeFocus1};
+ }
+
+ .arc-outline {
+ fill: none;
+ }
+
+ .arc-outline:has(+ .arc:focus) {
+ stroke-width: 4px;
+ stroke: ${colorStrokeFocus2};
+ }
+
+ .text-inside-donut {
+ ${typographyTitle3Styles}
+ fill: ${colorNeutralForeground1};
+ }
+
+ .legend-container {
+ padding-top: ${spacingVerticalL};
+ white-space: nowrap;
+ width: 100%;
+ align-items: center;
+ margin: -8px 0 0 -8px;
+ flex-wrap: wrap;
+ display: flex;
+ }
+
+ .legend {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ border: none;
+ padding: ${spacingHorizontalS};
+ background: none;
+ text-transform: capitalize;
+ }
+
+ .legend-rect {
+ width: 12px;
+ height: 12px;
+ margin-inline-end: ${spacingHorizontalS};
+ border: 1px solid;
+ }
+
+ .legend-text {
+ ${typographyCaption1Styles}
+ color: ${colorNeutralForeground1};
+ }
+
+ .legend.inactive .legend-rect {
+ background-color: transparent !important;
+ }
+
+ .legend.inactive .legend-text {
+ opacity: 0.67;
+ }
+
+ .tooltip {
+ display: grid;
+ overflow: hidden;
+ padding: ${spacingVerticalMNudge} ${spacingHorizontalL};
+ background-color: ${colorNeutralBackground1};
+ background-blend-mode: normal, luminosity;
+ border-radius: ${borderRadiusMedium};
+ border: 1px solid ${colorTransparentStroke};
+ filter: drop-shadow(0 0 2px ${colorNeutralShadowAmbient}) drop-shadow(0 8px 16px ${colorNeutralShadowKey});
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
+ }
+
+ .tooltip-body {
+ padding-inline-start: ${spacingHorizontalS};
+ color: ${colorNeutralForeground1};
+ border-inline-start: 4px solid;
+ }
+
+ .tooltip-legend-text {
+ ${typographyCaption1Styles}
+ }
+
+ .tooltip-content-y {
+ ${typographyTitle2Styles}
+ }
+`.withBehaviors(
+ forcedColorsStylesheetBehavior(css`
+ .text-inside-donut {
+ fill: rgb(179, 179, 179);
+ }
+
+ .legend-rect,
+ .tooltip-body {
+ forced-color-adjust: none;
+ }
+
+ .tooltip-legend-text,
+ .tooltip-content-y {
+ forced-color-adjust: auto;
+ color: rgb(255, 255, 255);
+ }
+ `),
+);
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.template.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.template.ts
new file mode 100644
index 0000000000000..39998ca3a5f8a
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.template.ts
@@ -0,0 +1,70 @@
+import { ElementViewTemplate, html, ref, repeat, when } from '@microsoft/fast-element';
+import type { DonutChart } from './donut-chart.js';
+import { Legend } from './donut-chart.options.js';
+
+/**
+ * Generates a template for the DonutChart component.
+ *
+ * @public
+ */
+export function donutChartTemplate(): ElementViewTemplate {
+ return html`
+
+
+
+
+ ${when(
+ x => !x.hideLegends,
+ html`
+
+ ${repeat(
+ x => x.legends,
+ html
+ `,
+ )}
+ ${when(
+ x => !x.hideTooltip && x.tooltipProps.isVisible,
+ html`
+
+ `,
+ )}
+
+ `;
+}
+
+/**
+ * @internal
+ */
+export const template: ElementViewTemplate = donutChartTemplate();
diff --git a/packages/charts/chart-web-components/src/donut-chart/donut-chart.ts b/packages/charts/chart-web-components/src/donut-chart/donut-chart.ts
new file mode 100644
index 0000000000000..a3f11503ff391
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/donut-chart.ts
@@ -0,0 +1,262 @@
+import { attr, FASTElement, nullableNumberConverter, observable } from '@microsoft/fast-element';
+import { arc as d3Arc, pie as d3Pie, PieArcDatum } from 'd3-shape';
+import {
+ getColorFromToken,
+ getNextColor,
+ getRTL,
+ jsonConverter,
+ SVG_NAMESPACE_URI,
+ validateChartProps,
+ wrapText,
+} from '../utils/chart-helpers.js';
+import { ChartDataPoint, ChartProps, Legend } from './donut-chart.options.js';
+
+export class DonutChart extends FASTElement {
+ @attr({ converter: nullableNumberConverter })
+ public height: number = 200;
+
+ @attr({ converter: nullableNumberConverter })
+ public width: number = 200;
+
+ @attr({ attribute: 'hide-legends', mode: 'boolean' })
+ public hideLegends: boolean = false;
+
+ @attr({ attribute: 'hide-tooltip', mode: 'boolean' })
+ public hideTooltip: boolean = false;
+
+ @attr({ converter: jsonConverter })
+ public data!: ChartProps;
+
+ @attr({ attribute: 'inner-radius', converter: nullableNumberConverter })
+ public innerRadius: number = 1;
+
+ @attr({ attribute: 'value-inside-donut' })
+ public valueInsideDonut?: string;
+
+ @attr({ attribute: 'legend-list-label' })
+ public legendListLabel?: string;
+
+ @observable
+ public legends: Legend[] = [];
+
+ @observable
+ public activeLegend: string = '';
+
+ @observable
+ public isLegendSelected: boolean = false;
+
+ @observable
+ public tooltipProps = {
+ isVisible: false,
+ legend: '',
+ yValue: '',
+ color: '',
+ xPos: 0,
+ yPos: 0,
+ };
+
+ public rootDiv!: HTMLDivElement;
+ public chartWrapper!: HTMLDivElement;
+ public group!: SVGGElement;
+ public elementInternals: ElementInternals = this.attachInternals();
+
+ private _arcs: SVGPathElement[] = [];
+ private _isRTL: boolean = false;
+ private _textInsideDonut?: SVGTextElement;
+
+ constructor() {
+ super();
+
+ this.elementInternals.role = 'region';
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+
+ validateChartProps(this.data, 'data');
+
+ this.data.chartData.forEach((dataPoint, index) => {
+ if (dataPoint.color) {
+ dataPoint.color = getColorFromToken(dataPoint.color);
+ } else {
+ dataPoint.color = getNextColor(index);
+ }
+ });
+
+ this.legends = this.getLegends();
+ this._isRTL = getRTL(this);
+ this.elementInternals.ariaLabel =
+ this.data.chartTitle || `Donut chart with ${this.data.chartData.length} segments.`;
+
+ this._render();
+ }
+
+ private _render() {
+ const pie = d3Pie()
+ .value(d => d.data)
+ .padAngle(0.02);
+ const arc = d3Arc>()
+ .innerRadius(this.innerRadius)
+ .outerRadius((Math.min(this.height, this.width) - 20) / 2);
+
+ pie(this.data.chartData).forEach(arcDatum => {
+ const arcGroup = document.createElementNS(SVG_NAMESPACE_URI, 'g');
+ this.group.appendChild(arcGroup);
+
+ const pathOutline = document.createElementNS(SVG_NAMESPACE_URI, 'path');
+ arcGroup.appendChild(pathOutline);
+ pathOutline.classList.add('arc-outline');
+ pathOutline.setAttribute('d', arc(arcDatum)!);
+
+ const path = document.createElementNS(SVG_NAMESPACE_URI, 'path');
+ arcGroup.appendChild(path);
+ this._arcs.push(path);
+ path.classList.add('arc');
+ path.setAttribute('d', arc(arcDatum)!);
+ path.setAttribute('fill', arcDatum.data.color!);
+ path.setAttribute('data-id', arcDatum.data.legend);
+ path.setAttribute('tabindex', '0');
+ path.setAttribute('aria-label', `${arcDatum.data.legend}, ${arcDatum.data.data}.`);
+ path.setAttribute('role', 'img');
+
+ path.addEventListener('mouseover', event => {
+ if (this.activeLegend !== '' && this.activeLegend !== arcDatum.data.legend) {
+ return;
+ }
+
+ const bounds = this.rootDiv.getBoundingClientRect();
+
+ this.tooltipProps = {
+ isVisible: true,
+ legend: arcDatum.data.legend,
+ yValue: `${arcDatum.data.data}`,
+ color: arcDatum.data.color!,
+ xPos: this._isRTL ? bounds.right - event.clientX : event.clientX - bounds.left,
+ yPos: event.clientY - bounds.top - 85,
+ };
+ });
+ path.addEventListener('focus', event => {
+ if (this.activeLegend !== '' && this.activeLegend !== arcDatum.data.legend) {
+ return;
+ }
+
+ const rootBounds = this.rootDiv.getBoundingClientRect();
+ const arcBounds = path.getBoundingClientRect();
+
+ this.tooltipProps = {
+ isVisible: true,
+ legend: arcDatum.data.legend,
+ yValue: `${arcDatum.data.data}`,
+ color: arcDatum.data.color!,
+ xPos: this._isRTL
+ ? rootBounds.right - arcBounds.left - arcBounds.width / 2
+ : arcBounds.left + arcBounds.width / 2 - rootBounds.left,
+ yPos: arcBounds.top - rootBounds.top - 85,
+ };
+ });
+ path.addEventListener('blur', event => {
+ this.tooltipProps = { isVisible: false, legend: '', yValue: '', color: '', xPos: 0, yPos: 0 };
+ });
+ });
+
+ this.rootDiv.addEventListener('mouseleave', () => {
+ this.tooltipProps = { isVisible: false, legend: '', yValue: '', color: '', xPos: 0, yPos: 0 };
+ });
+
+ if (this.valueInsideDonut) {
+ this._textInsideDonut = document.createElementNS(SVG_NAMESPACE_URI, 'text');
+ this.group.appendChild(this._textInsideDonut);
+ this._textInsideDonut.classList.add('text-inside-donut');
+ this._textInsideDonut.setAttribute('x', '0');
+ this._textInsideDonut.setAttribute('y', '0');
+ this._textInsideDonut.setAttribute('text-anchor', 'middle');
+ this._textInsideDonut.setAttribute('dominant-baseline', 'middle');
+ this._updateTextInsideDonut();
+ }
+ }
+
+ public getLegends(): Legend[] {
+ return this.data.chartData.map((d, index) => ({
+ title: d.legend,
+ color: d.color!,
+ }));
+ }
+
+ public handleLegendMouseoverAndFocus(legendTitle: string) {
+ if (this.isLegendSelected) {
+ return;
+ }
+
+ this.activeLegend = legendTitle;
+ }
+
+ public handleLegendMouseoutAndBlur() {
+ if (this.isLegendSelected) {
+ return;
+ }
+
+ this.activeLegend = '';
+ }
+
+ public handleLegendClick(legendTitle: string) {
+ if (this.isLegendSelected && this.activeLegend === legendTitle) {
+ this.activeLegend = '';
+ this.isLegendSelected = false;
+ } else {
+ this.activeLegend = legendTitle;
+ this.isLegendSelected = true;
+ }
+ }
+
+ public activeLegendChanged(oldValue: string, newValue: string) {
+ if (newValue === '') {
+ this._arcs?.forEach(arc => arc.classList.remove('inactive'));
+ } else {
+ this._arcs?.forEach(arc => {
+ if (arc.getAttribute('data-id') === newValue) {
+ arc.classList.remove('inactive');
+ } else {
+ arc.classList.add('inactive');
+ }
+ });
+ }
+
+ this._updateTextInsideDonut();
+ }
+
+ private _getTextInsideDonut(valueInsideDonut: string) {
+ let textInsideDonut = valueInsideDonut;
+
+ if (valueInsideDonut && (this.activeLegend !== '' || this.tooltipProps.isVisible)) {
+ const highlightedDataPoint = this.data.chartData.find(
+ dataPoint =>
+ dataPoint.legend === this.activeLegend ||
+ (this.tooltipProps.isVisible && dataPoint.legend === this.tooltipProps.legend),
+ );
+ textInsideDonut = highlightedDataPoint!.yAxisCalloutData
+ ? highlightedDataPoint!.yAxisCalloutData
+ : highlightedDataPoint!.data.toLocaleString();
+ }
+
+ return textInsideDonut;
+ }
+
+ private _updateTextInsideDonut() {
+ if (!this._textInsideDonut || !this.valueInsideDonut) {
+ return;
+ }
+
+ this._textInsideDonut.textContent = this._getTextInsideDonut(this.valueInsideDonut);
+ const lineHeight = this._textInsideDonut.getBoundingClientRect().height;
+ wrapText(this._textInsideDonut, 2 * this.innerRadius);
+ const lines = this._textInsideDonut.getElementsByTagName('tspan');
+ const start = -1 * Math.trunc((lines.length - 1) / 2);
+ for (let i = 0; i < lines.length; i++) {
+ lines[i].setAttribute('dy', `${(start + i) * lineHeight}`);
+ }
+ }
+
+ public tooltipPropsChanged(oldValue: any, newValue: any) {
+ this._updateTextInsideDonut();
+ }
+}
diff --git a/packages/charts/chart-web-components/src/donut-chart/index.ts b/packages/charts/chart-web-components/src/donut-chart/index.ts
new file mode 100644
index 0000000000000..60e54e1a59b9b
--- /dev/null
+++ b/packages/charts/chart-web-components/src/donut-chart/index.ts
@@ -0,0 +1,4 @@
+export { definition as DonutChartDefinition } from './donut-chart.definition.js';
+export { DonutChart } from './donut-chart.js';
+export { styles as DonutChartStyles } from './donut-chart.styles.js';
+export { template as DonutChartTemplate } from './donut-chart.template.js';
diff --git a/packages/charts/chart-web-components/src/helpers.stories.ts b/packages/charts/chart-web-components/src/helpers.stories.ts
new file mode 100644
index 0000000000000..c885723594b95
--- /dev/null
+++ b/packages/charts/chart-web-components/src/helpers.stories.ts
@@ -0,0 +1,101 @@
+import type { ElementViewTemplate, FASTElement, ViewTemplate } from '@microsoft/fast-element';
+import type { AnnotatedStoryFn, Args, ComponentAnnotations, Renderer, StoryAnnotations } from '@storybook/csf';
+
+/**
+ * A helper that returns a function to bind a Storybook story to a ViewTemplate.
+ *
+ * @param template - The ViewTemplate to render
+ * @returns - a function to bind a Storybook story
+ */
+export function renderComponent(template: ViewTemplate): (args: TArgs) => Element | DocumentFragment {
+ return function (args) {
+ const storyFragment = new DocumentFragment();
+ template.render(args, storyFragment);
+ if (storyFragment.childElementCount === 1) {
+ return storyFragment.firstElementChild!;
+ }
+ return storyFragment;
+ };
+}
+
+export declare interface FASTComponentsRenderer extends Renderer {
+ canvasElement: FASTElement;
+ component: typeof FASTElement | string;
+ storyResult: string | Node | DocumentFragment | ElementViewTemplate;
+}
+
+/**
+ * A helper that returns a function to bind a Storybook story to a ViewTemplate.
+ */
+export type FASTFramework = Renderer & {
+ component: typeof FASTElement;
+ storyResult: FASTElement | Element | DocumentFragment;
+};
+
+/**
+ * Metadata to configure the stories for a component.
+ */
+export declare type Meta = ComponentAnnotations>;
+
+/**
+ * Story object that represents a CSFv3 component example.
+ *
+ * @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports)
+ */
+export declare type StoryObj = StoryAnnotations>;
+
+/**
+ * Story function that represents a CSFv2 component example.
+ */
+export declare type StoryFn = AnnotatedStoryFn;
+
+/**
+ * Story function that represents a CSFv2 component example.
+ *
+ * NOTE that in Storybook 7.0, this type will be renamed to `StoryFn` and replaced by the current `StoryObj` type.
+ */
+export declare type Story = StoryFn>;
+
+/**
+ * Combined Storybook story args.
+ */
+export type StoryArgs = Partial> & Args;
+
+export function generateImage({
+ width,
+ height = width,
+ backgroundColor = 'rgb(204, 204, 204)',
+ color = 'rgb(150, 150, 150)',
+ text = `${width} x ${height}`,
+}: {
+ width: number;
+ height?: number;
+ backgroundColor?: string;
+ color?: string;
+ text?: string;
+}): string {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d') as CanvasRenderingContext2D;
+
+ canvas.width = width;
+ canvas.height = height;
+
+ // Clear the canvas.
+ context.clearRect(0, 0, canvas.width, canvas.height);
+
+ // get the font size to fit the text
+ context.font = '1px sans-serif';
+ const maxFontSize = Math.max(width / context.measureText(text).width / 2, 7);
+
+ // Draw the background
+ context.fillStyle = backgroundColor;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+
+ context.font = `${maxFontSize}px Helvetica, Arial, sans-serif`;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillStyle = color;
+ context.fillText(text, canvas.width / 2, canvas.height / 2);
+
+ return canvas.toDataURL('image/png');
+}
diff --git a/packages/charts/chart-web-components/src/helpers.tests.ts b/packages/charts/chart-web-components/src/helpers.tests.ts
new file mode 100644
index 0000000000000..46d72c8403a92
--- /dev/null
+++ b/packages/charts/chart-web-components/src/helpers.tests.ts
@@ -0,0 +1,86 @@
+import qs from 'qs';
+import { expect as baseExpect, type ExpectMatcherState, type Locator } from '@playwright/test';
+
+/**
+ * Returns a formatted URL for a given Storybook fixture.
+ *
+ * @param id - the Storybook fixture ID
+ * @param args - Story args
+ * @returns - the local URL for the Storybook fixture iframe
+ */
+export function fixtureURL(id: string = 'debug--blank', args?: Record): string {
+ const params: Record = { id };
+ if (args) {
+ params.args = qs
+ .stringify(args, {
+ allowDots: true,
+ delimiter: ';',
+ format: 'RFC1738',
+ encode: false,
+ })
+ .replace(/=/g, ':')
+ .replace(/\//g, '--');
+ }
+
+ const url = qs.stringify(params, {
+ addQueryPrefix: true,
+ format: 'RFC1738',
+ encode: false,
+ });
+
+ return url;
+}
+
+/**
+ * Evaluate whether an element has the given state or not on its `elementInternals` property.
+ *
+ * @param locator - The Playwright locator for the element.
+ * @param state - The name of the state.
+ * @param expected - Whether the given state is expected to exist.
+ * @param has - Whether the element is expected to have or not have the given state, defaults to `true`.
+ */
+async function toHaveCustomState(
+ this: ExpectMatcherState,
+ locator: Locator,
+ state: string,
+ options?: { timeout?: number },
+) {
+ const assertionName = 'toHaveCustomState';
+ let pass: boolean;
+ let matcherResult: any;
+ const expected: boolean = !this.isNot;
+
+ try {
+ baseExpect(await locator.evaluate((el, state) => el.matches(`:state(${state})`), state, options)).toEqual(true);
+ pass = true;
+ } catch (err: any) {
+ matcherResult = err.matcherResult;
+ pass = false;
+ }
+
+ const message = pass
+ ? () =>
+ this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) +
+ '\n\n' +
+ `Locator: ${locator}\n` +
+ `Expected: ${this.isNot ? 'not' : ''}${this.utils.printExpected(expected)}\n` +
+ (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '')
+ : () =>
+ this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) +
+ '\n\n' +
+ `Locator: ${locator}\n` +
+ `Expected: ${this.utils.printExpected(expected)}\n` +
+ (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '');
+
+ return {
+ name: assertionName,
+ message,
+ pass,
+ expected,
+ actual: matcherResult?.actual,
+ };
+}
+
+export const expect = baseExpect.extend({
+ toHaveCustomState,
+});
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/define.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/define.ts
new file mode 100644
index 0000000000000..2af6e03097468
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/define.ts
@@ -0,0 +1,4 @@
+import { FluentDesignSystem } from '@fluentui/web-components';
+import { definition } from './horizontal-bar-chart.definition.js';
+
+definition.define(FluentDesignSystem.registry);
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.bench.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.bench.ts
new file mode 100644
index 0000000000000..14336cd320d46
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.bench.ts
@@ -0,0 +1,12 @@
+import { FluentDesignSystem } from '@fluentui/web-components';
+import { definition } from './horizontal-bar-chart.definition.js';
+
+definition.define(FluentDesignSystem.registry);
+
+const itemRenderer = () => {
+ const horizontalbarchart = document.createElement('fluent-horizontal-bar-chart');
+ return horizontalbarchart;
+};
+
+export default itemRenderer;
+export { tests } from '../utils/benchmark-wrapper.js';
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.definition.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.definition.ts
new file mode 100644
index 0000000000000..211c4d0b63c56
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.definition.ts
@@ -0,0 +1,20 @@
+import { FluentDesignSystem } from '@fluentui/web-components';
+import { HorizontalBarChart } from './horizontal-bar-chart.js';
+import { styles } from './horizontal-bar-chart.styles.js';
+import { template } from './horizontal-bar-chart.template.js';
+
+/**
+ * The Fluent Textarea Element definition.
+ *
+ * @public
+ * @remarks
+ * HTML Element: ``
+ */
+export const definition = HorizontalBarChart.compose({
+ name: `${FluentDesignSystem.prefix}-horizontal-bar-chart`,
+ template,
+ styles,
+ shadowOptions: {
+ delegatesFocus: true,
+ },
+});
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.options.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.options.ts
new file mode 100644
index 0000000000000..13b6150cc68b6
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.options.ts
@@ -0,0 +1,50 @@
+export enum Variant {
+ PartToWhole = 'part-to-whole',
+ AbsoluteScale = 'absolute-scale',
+ SingleBar = 'single-bar',
+}
+
+export interface ChartDataPoint {
+ /**
+ * Legend text for the datapoint in the chart
+ */
+ legend: string;
+
+ /**
+ * data the datapoint in the chart
+ */
+ data: number;
+
+ /**
+ * total length of bar
+ */
+ total?: number;
+
+ /**
+ * onClick action for each datapoint in the chart
+ */
+ onClick?: VoidFunction;
+
+ /**
+ * Color for the legend in the chart. If not provided, it will fallback on the default color palette.
+ */
+ color?: string;
+
+ gradient?: [string, string];
+}
+
+export interface ChartProps {
+ /**
+ * title for the data series
+ */
+ chartSeriesTitle?: string;
+
+ /**
+ * data for the points in the chart
+ */
+ chartData: ChartDataPoint[];
+
+ benchmarkData?: number;
+
+ chartDataText?: string;
+}
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.spec.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.spec.ts
new file mode 100644
index 0000000000000..e35a14e1807a5
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.spec.ts
@@ -0,0 +1,730 @@
+import { Locator, test } from '@playwright/test';
+import { expect, fixtureURL } from '../helpers.tests.js';
+import { ChartDataPoint, ChartProps } from './horizontal-bar-chart.options.js';
+
+const chartPoints1: ChartDataPoint[] = [
+ {
+ legend: 'Debit card numbers (EU and USA)',
+ data: 40,
+ color: '#0099BC',
+ },
+ {
+ legend: 'Passport numbers (USA)',
+ data: 23,
+ color: '#77004D',
+ },
+ {
+ legend: 'Social security numbers',
+ data: 35,
+ color: '#4F68ED',
+ },
+ {
+ legend: 'Credit card Numbers',
+ data: 87,
+ color: '#AE8C00',
+ },
+ {
+ legend: 'Tax identification numbers (USA)',
+ data: 87,
+ color: '#004E8C',
+ },
+];
+
+const chartPoints2: ChartDataPoint[] = [
+ {
+ legend: 'Debit card numbers (EU and USA)',
+ data: 40,
+ color: '#0099BC',
+ },
+ {
+ legend: 'Passport numbers (USA)',
+ data: 56,
+ color: '#77004D',
+ },
+ {
+ legend: 'Social security numbers',
+ data: 35,
+ color: '#4F68ED',
+ },
+ {
+ legend: 'Credit card Numbers',
+ data: 92,
+ color: '#AE8C00',
+ },
+ {
+ legend: 'Tax identification numbers (USA)',
+ data: 87,
+ color: '#004E8C',
+ },
+];
+
+const chartPoints3: ChartDataPoint[] = [
+ {
+ legend: 'Phone Numbers',
+ data: 40,
+ color: '#881798',
+ },
+ {
+ legend: 'Credit card Numbers',
+ data: 23,
+ color: '#AE8C00',
+ },
+];
+
+const basicChartTestData: ChartProps[] = [
+ {
+ chartSeriesTitle: 'Monitored First',
+ chartData: chartPoints1,
+ },
+ {
+ chartSeriesTitle: 'Monitored Second',
+ chartData: chartPoints2,
+ },
+ {
+ chartSeriesTitle: 'Unmonitored',
+ chartData: chartPoints3,
+ },
+];
+
+const singleBarHBCData = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 1543,
+ total: 15000,
+ color: '#637cef',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'two',
+ chartData: [
+ {
+ legend: 'two',
+ data: 800,
+ total: 15000,
+ color: '#e3008c',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'three',
+ chartData: [
+ {
+ legend: 'three',
+ data: 8888,
+ total: 15000,
+ color: '#2aa0a4',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'four',
+ chartData: [
+ {
+ legend: 'four',
+ data: 15888,
+ total: 15000,
+ color: '#9373c0',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'five',
+ chartData: [
+ {
+ legend: 'five',
+ data: 11444,
+ total: 15000,
+ color: '#13a10e',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'six',
+ chartData: [
+ {
+ legend: 'six',
+ data: 14000,
+ total: 15000,
+ color: '#3a96dd',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'seven',
+ chartData: [
+ {
+ legend: 'seven',
+ data: 9855,
+ total: 15000,
+ color: '#ca5010',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'eight',
+ chartData: [
+ {
+ legend: 'eight',
+ data: 4250,
+ total: 15000,
+ color: '#57811b',
+ },
+ ],
+ },
+];
+
+const singleBarNMVariantData = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 1543,
+ total: 15000,
+ color: '#637cef',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'two',
+ chartData: [
+ {
+ legend: 'two',
+ data: 800,
+ total: 15000,
+ color: '#e3008c',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'three',
+ chartData: [
+ {
+ legend: 'three',
+ data: 8888,
+ total: 15000,
+ color: '#2aa0a4',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'four',
+ chartData: [
+ {
+ legend: 'four',
+ data: 15888,
+ total: 15000,
+ color: '#9373c0',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'five',
+ chartData: [
+ {
+ legend: 'five',
+ data: 11444,
+ total: 15000,
+ color: '#13a10e',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'six',
+ chartData: [
+ {
+ legend: 'six',
+ data: 14000,
+ total: 15000,
+ color: '#3a96dd',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'seven',
+ chartData: [
+ {
+ legend: 'seven',
+ data: 9855,
+ total: 15000,
+ color: '#ca5010',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'eight',
+ chartData: [
+ {
+ legend: 'eight',
+ data: 4250,
+ total: 15000,
+ color: '#57811b',
+ },
+ ],
+ },
+];
+
+const singlePointData = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 1543,
+ total: 15000,
+ gradient: ['#637cef', '#e3008c'],
+ },
+ ],
+ },
+];
+
+async function expectOptionsToBeVisible(element: Locator, options: string | any[]) {
+ for (let i = 0; i < options.length; i++) {
+ await expect(element.getByRole('option', { name: options[i] })).toBeVisible();
+ }
+}
+
+test.describe('horizontalbarchart - Basic', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fixtureURL('components-horizontalbarchart--basic'));
+ await page.setContent(/* html */ `
+
+
+
+
+ `);
+ await page.waitForFunction(() => customElements.whenDefined('fluent-horizontal-bar-chart'));
+ });
+
+ test('Should render horizontalbarchart properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ await expectOptionsToBeVisible(element, [
+ 'Debit card numbers (EU and USA)',
+ 'Passport numbers (USA)',
+ 'Social security numbers',
+ 'Credit card Numbers',
+ 'Phone Numbers',
+ ]);
+ await expect(page.getByText('Monitored First')).toBeVisible();
+ await expect(page.getByText('Monitored Second')).toBeVisible();
+ await expect(page.getByText('Unmonitored')).toBeVisible();
+ });
+
+ test('Should render legends data properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(6);
+ const firstLegend = legends.first();
+ await expect(firstLegend.locator('div').first()).toHaveCSS('background-color', 'rgb(0, 153, 188)');
+ await expect(firstLegend).toHaveText('Debit card numbers (EU and USA)');
+ });
+
+ test('Should update bar css/opaity when mouse hover on legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(6);
+ const firstLegend = legends.first();
+ //mouse events
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ await expect(bars).toHaveCount(12);
+ for (let i = 0; i < (await bars.count()); i++) {
+ if (i == 0 || i == 5) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ });
+
+ test('Should update bar css/opaity when mouse moved from one legend to another legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(6);
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ for (let i = 0; i < (await bars.count()); i++) {
+ if (i == 0 || i == 5) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ await legends.nth(0).dispatchEvent('mouseout');
+ await legends.nth(1).dispatchEvent('mouseover');
+ for (let i = 0; i < (await bars.count()); i++) {
+ if (i == 1 || i == 6) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ });
+
+ test('Should show callout when mouse hover on bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('Debit card numbers (EU and USA) 40');
+ });
+
+ test('Should update callout data when mouse moved from one bar to another bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('Debit card numbers (EU and USA) 40');
+ await bars.nth(0).dispatchEvent('mouseout');
+ await bars.nth(1).dispatchEvent('mouseover');
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('Passport numbers (USA) 23');
+ });
+});
+
+test.describe('horizontalbarchart - Single Bar HBC', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fixtureURL('components-horizontalbarchart--single-bar-hbc'));
+ await page.setContent(/* html */ `
+
+
+
+
+ `);
+ await page.waitForFunction(() => customElements.whenDefined('fluent-horizontal-bar-chart'));
+ });
+
+ test('Should render Single Bar HBC properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ await expectOptionsToBeVisible(element, ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight']);
+ const barsTitles = element.locator('.chart-title');
+ await expect(barsTitles).toHaveCount(8);
+ await expect(barsTitles.nth(0)).toHaveText('one');
+ await expect(barsTitles.nth(1)).toHaveText('two');
+ await expect(barsTitles.nth(2)).toHaveText('three');
+ await expect(barsTitles.nth(3)).toHaveText('four');
+ await expect(barsTitles.nth(4)).toHaveText('five');
+ await expect(barsTitles.nth(5)).toHaveText('six');
+ await expect(barsTitles.nth(6)).toHaveText('seven');
+ await expect(barsTitles.nth(7)).toHaveText('eight');
+ for (let i = 0; i < (await barsTitles.count()); i++) {
+ await expect(barsTitles.nth(i)).toBeVisible();
+ }
+ });
+
+ test('Should update bar css/opaity when mouse hover on legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(8);
+ //mouse events
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ await expect(bars).toHaveCount(8);
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 0) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ });
+
+ test('Should update bar css/opaity when mouse moved from one legend to another legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(8);
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 0) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ await legends.nth(0).dispatchEvent('mouseout');
+ await legends.nth(1).dispatchEvent('mouseover');
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 1) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ });
+
+ test('Should update bar css/opaity when mouse click on legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(8);
+ await legends.nth(0).click();
+ const bars = element.locator('.bar');
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 0) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ await legends.nth(0).click();
+ for (let i = 1; i < (await bars.count()); i++) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ }
+ });
+
+ test('Should show callout when mouse hover on bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('one 1543');
+ });
+
+ test('Should update callout data when mouse moved from one bar to another bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('one 1543');
+ await bars.nth(0).dispatchEvent('mouseout');
+ await bars.nth(1).dispatchEvent('mouseover');
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('two 800');
+ });
+});
+
+test.describe('horizontalbarchart - Single Bar NM Variant', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fixtureURL('components-horizontalbarchart--single-bar-nm-variant'));
+ await page.setContent(/* html */ `
+
+
+
+
+ `);
+ await page.waitForFunction(() => customElements.whenDefined('fluent-horizontal-bar-chart'));
+ });
+
+ test('Should render Single Bar HBC properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ await expectOptionsToBeVisible(element, ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight']);
+ });
+
+ test('Should render bars and bar labels properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ await expect(bars).toHaveCount(16);
+ await expect(bars.nth(0)).toHaveCSS('fill', 'rgb(99, 124, 239)');
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ await expect(bars.nth(0)).toHaveAttribute(`height`, '12');
+
+ const firstBarWidth = await bars.nth(0).getAttribute('width');
+ const firstBarWidthEmptySpace = await bars.nth(1).getAttribute('width');
+ expect(parseFloat(firstBarWidth!)).toBeLessThan(parseFloat(firstBarWidthEmptySpace!));
+ expect(parseFloat(firstBarWidth!) + parseFloat(firstBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+
+ const secondBarWidth = await bars.nth(2).getAttribute('width');
+ const secondBarWidthEmptySpace = await bars.nth(3).getAttribute('width');
+ expect(parseFloat(secondBarWidth!)).toBeLessThan(parseFloat(secondBarWidthEmptySpace!));
+ expect(parseFloat(secondBarWidth!) + parseFloat(secondBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+
+ const thirdBarWidth = await bars.nth(4).getAttribute('width');
+ const thirdBarWidthEmptySpace = await bars.nth(5).getAttribute('width');
+ expect(parseFloat(thirdBarWidth!)).toBeGreaterThan(parseFloat(thirdBarWidthEmptySpace!));
+ expect(parseFloat(thirdBarWidth!) + parseFloat(thirdBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+
+ const fourthBarWidth = await bars.nth(6).getAttribute('width');
+ const fourthBarWidthEmptySpace = await bars.nth(7).getAttribute('width');
+ expect(parseFloat(fourthBarWidth!)).toBeGreaterThan(parseFloat(fourthBarWidthEmptySpace!));
+ expect(parseFloat(fourthBarWidth!) + parseFloat(fourthBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+
+ const fifthBarWidth = await bars.nth(8).getAttribute('width');
+ const fifthBarWidthEmptySpace = await bars.nth(9).getAttribute('width');
+ expect(parseFloat(fifthBarWidth!)).toBeGreaterThan(parseFloat(fifthBarWidthEmptySpace!));
+ expect(parseFloat(fifthBarWidth!) + parseFloat(fifthBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+
+ const sixthBarWidth = await bars.nth(10).getAttribute('width');
+ const sixthBarWidthEmptySpace = await bars.nth(11).getAttribute('width');
+ expect(parseFloat(sixthBarWidth!)).toBeGreaterThan(parseFloat(sixthBarWidthEmptySpace!));
+ expect(parseFloat(sixthBarWidth!) + parseFloat(sixthBarWidthEmptySpace!)).toBeGreaterThanOrEqual(98);
+
+ const seventhBarWidth = await bars.nth(12).getAttribute('width');
+ const seventhBarWidthEmptySpace = await bars.nth(13).getAttribute('width');
+ expect(parseFloat(seventhBarWidth!)).toBeGreaterThan(parseFloat(seventhBarWidthEmptySpace!));
+ expect(parseFloat(seventhBarWidth!) + parseFloat(seventhBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+
+ const eigthBarWidth = await bars.nth(14).getAttribute('width');
+ const eigthBarWidthEmptySpace = await bars.nth(15).getAttribute('width');
+ expect(parseFloat(eigthBarWidth!)).toBeLessThan(parseFloat(eigthBarWidthEmptySpace!));
+ expect(parseFloat(eigthBarWidth!) + parseFloat(eigthBarWidthEmptySpace!)).toBeGreaterThanOrEqual(99);
+ });
+
+ test('Should update bar css/opaity when mouse hover on legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(8);
+ //mouse events
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ await expect(bars).toHaveCount(16);
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 0) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ });
+
+ test('Should update bar css/opaity when mouse moved from one legend to another legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(8);
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 0) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ await legends.nth(0).dispatchEvent('mouseout');
+ await legends.nth(1).dispatchEvent('mouseover');
+ for (let i = 1; i < (await bars.count()); i++) {
+ if (i == 2) {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '1');
+ } else {
+ await expect(bars.nth(i)).toHaveCSS('opacity', '0.1');
+ }
+ }
+ });
+
+ test('Should show callout when mouse hover on bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('one 1543');
+ });
+
+ test('Should update callout data when mouse moved from one bar to another bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('one 1543');
+ await bars.nth(0).dispatchEvent('mouseout');
+ await bars.nth(2).dispatchEvent('mouseover');
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('two 800');
+ });
+});
+
+test.describe('horizontalbarchart - Single Data Point', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(fixtureURL('components-horizontalbarchart--single-data-point'));
+ await page.setContent(/* html */ `
+
+
+
+
+ `);
+ await page.waitForFunction(() => customElements.whenDefined('fluent-horizontal-bar-chart'));
+ });
+
+ test('Should render Single Bar HBC properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ await expect(element.getByRole('option', { name: 'one' })).toBeVisible();
+ const barsTitles = element.locator('.chart-title');
+ await expect(barsTitles).toHaveCount(1);
+ await expect(barsTitles.nth(0)).toHaveText('one');
+ await expect(barsTitles.nth(0)).toBeVisible();
+ });
+
+ test('Should render bars and bar labels properly', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ await expect(bars).toHaveCount(2);
+ await expect(bars.nth(0)).toHaveCSS('fill', 'url("#gradient-0-0")');
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ await expect(bars.nth(0)).toHaveAttribute(`height`, '12');
+ const firstBarWidth = await bars.nth(0).getAttribute('width');
+ const firstBarWidthEmptySpace = await bars.nth(1).getAttribute('width');
+ expect(parseFloat(firstBarWidth!)).toBeLessThan(parseFloat(firstBarWidthEmptySpace!));
+ expect(parseFloat(firstBarWidth!) + parseFloat(firstBarWidthEmptySpace!)).toBeGreaterThan(99);
+ });
+
+ test('Should update bar css/opaity when mouse hover on legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await expect(legends).toHaveCount(1);
+ //mouse events
+ await legends.nth(0).dispatchEvent('mouseover');
+ const bars = element.locator('.bar');
+ await expect(bars).toHaveCount(2);
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ await expect(bars.nth(1)).toHaveCSS('opacity', '0.1');
+ });
+
+ test('Should update bar css/opaity when mouse click on legend', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const legends = element.locator('.legend');
+ await legends.nth(0).click();
+ const bars = element.locator('.bar');
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ await expect(bars.nth(1)).toHaveCSS('opacity', '0.1');
+ await legends.nth(0).click();
+ await expect(bars.nth(0)).toHaveCSS('opacity', '1');
+ await expect(bars.nth(1)).toHaveCSS('opacity', '1');
+ });
+
+ test('Should show callout when mouse hover on bar', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('one 1543');
+ });
+
+ test('Should hide callout when mouve moved to bar offset', async ({ page }) => {
+ const element = page.locator('fluent-horizontal-bar-chart');
+ const bars = element.locator('.bar');
+ const tooltip = element.locator('.tooltip');
+ await expect(tooltip).toHaveCount(0);
+ await bars.nth(0).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(1);
+ await expect(tooltip.nth(0)).toHaveCSS('opacity', '1');
+ await expect(tooltip.nth(0).locator('div').first()).toHaveText('one 1543');
+ await bars.nth(0).dispatchEvent('mouseout');
+ await bars.nth(1).dispatchEvent('mouseover');
+ await expect(tooltip).toHaveCount(0);
+ });
+});
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.stories.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.stories.ts
new file mode 100644
index 0000000000000..7bc6ed5341d46
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.stories.ts
@@ -0,0 +1,388 @@
+import { html } from '@microsoft/fast-element';
+import type { Meta, Story, StoryArgs } from '../helpers.stories.js';
+import { renderComponent } from '../helpers.stories.js';
+import { HorizontalBarChart as FluentHorizontalBarChart } from './horizontal-bar-chart.js';
+import { ChartDataPoint, ChartProps, Variant } from './horizontal-bar-chart.options.js';
+
+const singleBarHBCData = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 1543,
+ total: 15000,
+ color: '#637cef',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'two',
+ chartData: [
+ {
+ legend: 'two',
+ data: 800,
+ total: 15000,
+ color: '#e3008c',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'three',
+ chartData: [
+ {
+ legend: 'three',
+ data: 8888,
+ total: 15000,
+ color: '#2aa0a4',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'four',
+ chartData: [
+ {
+ legend: 'four',
+ data: 15888,
+ total: 15000,
+ color: '#9373c0',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'five',
+ chartData: [
+ {
+ legend: 'five',
+ data: 11444,
+ total: 15000,
+ color: '#13a10e',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'six',
+ chartData: [
+ {
+ legend: 'six',
+ data: 14000,
+ total: 15000,
+ color: '#3a96dd',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'seven',
+ chartData: [
+ {
+ legend: 'seven',
+ data: 9855,
+ total: 15000,
+ color: '#ca5010',
+ },
+ ],
+ },
+ {
+ chartSeriesTitle: 'eight',
+ chartData: [
+ {
+ legend: 'eight',
+ data: 4250,
+ total: 15000,
+ color: '#57811b',
+ },
+ ],
+ },
+];
+
+const singleBarNMVariantData: ChartProps[] = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 1543,
+ total: 15000,
+ color: '#637cef',
+ },
+ ],
+ chartDataText: '1.5k/15k hours',
+ },
+ {
+ chartSeriesTitle: 'two',
+ chartData: [
+ {
+ legend: 'two',
+ data: 800,
+ total: 15000,
+ color: '#e3008c',
+ },
+ ],
+ chartDataText: '800/15k hours',
+ },
+ {
+ chartSeriesTitle: 'three',
+ chartData: [
+ {
+ legend: 'three',
+ data: 8888,
+ total: 15000,
+ color: '#2aa0a4',
+ },
+ ],
+ chartDataText: '8.9k/15k hours',
+ },
+ {
+ chartSeriesTitle: 'four',
+ chartData: [
+ {
+ legend: 'four',
+ data: 15888,
+ total: 15000,
+ color: '#9373c0',
+ },
+ ],
+ chartDataText: '16k/15k hours',
+ },
+ {
+ chartSeriesTitle: 'five',
+ chartData: [
+ {
+ legend: 'five',
+ data: 11444,
+ total: 15000,
+ color: '#13a10e',
+ },
+ ],
+ chartDataText: '11k/15k hours',
+ },
+ {
+ chartSeriesTitle: 'six',
+ chartData: [
+ {
+ legend: 'six',
+ data: 14000,
+ total: 15000,
+ color: '#3a96dd',
+ },
+ ],
+ chartDataText: '14k/15k hours',
+ },
+ {
+ chartSeriesTitle: 'seven',
+ chartData: [
+ {
+ legend: 'seven',
+ data: 9855,
+ total: 15000,
+ color: '#ca5010',
+ },
+ ],
+ chartDataText: '9.9k/15k hours',
+ },
+ {
+ chartSeriesTitle: 'eight',
+ chartData: [
+ {
+ legend: 'eight',
+ data: 4250,
+ total: 15000,
+ color: '#57811b',
+ },
+ ],
+ chartDataText: '4.3k/15k hours',
+ },
+];
+
+const chartPoints1: ChartDataPoint[] = [
+ {
+ legend: 'Debit card numbers (EU and USA)',
+ data: 40,
+ color: '#0099BC',
+ },
+ {
+ legend: 'Passport numbers (USA)',
+ data: 23,
+ color: '#77004D',
+ },
+ {
+ legend: 'Social security numbers',
+ data: 35,
+ color: '#4F68ED',
+ },
+ {
+ legend: 'Credit card Numbers',
+ data: 87,
+ color: '#AE8C00',
+ },
+ {
+ legend: 'Tax identification numbers (USA)',
+ data: 87,
+ color: '#004E8C',
+ },
+];
+
+const chartPoints2: ChartDataPoint[] = [
+ {
+ legend: 'Debit card numbers (EU and USA)',
+ data: 40,
+ color: '#0099BC',
+ },
+ {
+ legend: 'Passport numbers (USA)',
+ data: 56,
+ color: '#77004D',
+ },
+ {
+ legend: 'Social security numbers',
+ data: 35,
+ color: '#4F68ED',
+ },
+ {
+ legend: 'Credit card Numbers',
+ data: 92,
+ color: '#AE8C00',
+ },
+ {
+ legend: 'Tax identification numbers (USA)',
+ data: 87,
+ color: '#004E8C',
+ },
+];
+
+const chartPoints3: ChartDataPoint[] = [
+ {
+ legend: 'Phone Numbers',
+ data: 40,
+ color: '#881798',
+ },
+ {
+ legend: 'Credit card Numbers',
+ data: 23,
+ color: '#AE8C00',
+ },
+];
+
+const data: ChartProps[] = [
+ {
+ chartSeriesTitle: 'Monitored First',
+ chartData: chartPoints1,
+ },
+ {
+ chartSeriesTitle: 'Monitored Second',
+ chartData: chartPoints2,
+ },
+ {
+ chartSeriesTitle: 'Unmonitored',
+ chartData: chartPoints3,
+ },
+];
+
+const singlePointData = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 1543,
+ total: 15000,
+ gradient: ['#637cef', '#e3008c'],
+ },
+ ],
+ },
+];
+
+const benchmarkData: ChartProps[] = [
+ {
+ chartSeriesTitle: 'one',
+ chartData: [
+ {
+ legend: 'one',
+ data: 10,
+ total: 100,
+ color: '#637cef',
+ },
+ ],
+ benchmarkData: 50,
+ },
+ {
+ chartSeriesTitle: 'two',
+ chartData: [
+ {
+ legend: 'two',
+ data: 30,
+ total: 200,
+ color: '#e3008c',
+ },
+ ],
+ benchmarkData: 30,
+ },
+ {
+ chartSeriesTitle: 'three',
+ chartData: [
+ {
+ legend: 'three',
+ data: 15,
+ total: 50,
+ color: '#2aa0a4',
+ },
+ ],
+ benchmarkData: 5,
+ },
+];
+
+const storyTemplate = html>`
+
+`;
+
+export default {
+ title: 'Components/HorizontalBarChart',
+} as Meta;
+
+export const RTL: Story = renderComponent(html>`
+
+`);
+
+export const singleDataPoint: Story = renderComponent(html<
+ StoryArgs
+>`
+
+
+
+
+`);
+
+export const Benchmark: Story = renderComponent(html>`
+
+
+`);
+
+export const singleBarNMVariant: Story = renderComponent(html<
+ StoryArgs
+>`
+
+
+
+
+`);
+
+export const singleBarHBC: Story = renderComponent(html>`
+
+
+
+
+`);
+
+export const Basic: Story = renderComponent(storyTemplate).bind({});
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.styles.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.styles.ts
new file mode 100644
index 0000000000000..65234926945d3
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.styles.ts
@@ -0,0 +1,184 @@
+import type { ElementStyles } from '@microsoft/fast-element';
+import { css } from '@microsoft/fast-element';
+import {
+ colorNeutralBackground1,
+ colorNeutralForeground1,
+ colorNeutralStrokeAccessible,
+ display,
+ forcedColorsStylesheetBehavior,
+ shadow4,
+ spacingHorizontalL,
+ spacingHorizontalS,
+ spacingHorizontalSNudge,
+ spacingVerticalL,
+ spacingVerticalMNudge,
+ typographyBody1StrongStyles,
+ typographyBody1Styles,
+ typographyCaption1Styles,
+} from '@fluentui/web-components';
+
+/**
+ * Styles for the HorizontalBarChart component.
+ *
+ * @public
+ */
+export const styles: ElementStyles = css`
+ ${display('inline-block')}
+
+ :host {
+ position: relative;
+ }
+ .root {
+ background-color: ${colorNeutralBackground1};
+ width: 100vw;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ }
+ .tooltip {
+ ${typographyCaption1Styles}
+ position: absolute;
+ z-index: 999;
+ display: grid;
+ overflow: hidden;
+ padding: ${spacingVerticalMNudge} ${spacingHorizontalL};
+ backgroundcolor: ${colorNeutralBackground1};
+ background-blend-mode: normal, luminosity;
+ text-align: center;
+ background: ${colorNeutralBackground1};
+ box-shadow: ${shadow4};
+ border: 2px;
+ pointer-events: none;
+ }
+ .tooltip-line {
+ padding-inline-start: 8px;
+ height: 50px;
+ border-inline-start: 4px solid;
+ }
+ .tooltip-legend-text {
+ font-size: 13px;
+ color: theme.semanticColors.bodyText;
+ text-align: start;
+ }
+ .tooltip-data-y {
+ font-weight: bold;
+ font-size: 30px;
+ text-align: start;
+ lineheight: 36px;
+ margin-top: 12px;
+ }
+ .bar {
+ opacity: 1;
+ }
+ .bar.inactive {
+ opacity: 0.1;
+ }
+ .bar:focus {
+ outline: none;
+ stroke-width: 2px;
+ stroke: black;
+ }
+ .svg-chart {
+ display: block;
+ overflow: visible;
+ }
+ .chart-title {
+ ${typographyBody1Styles}
+ display: flex;
+ justify-content: space-between;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ display: block;
+ color: ${colorNeutralForeground1};
+ margin-bottom: ${spacingHorizontalSNudge};
+ }
+ .legendcontainer {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ padding-top: ${spacingVerticalL};
+ width: 100%;
+ align-items: center;
+ margin: -8px 0 0 -8px;
+ }
+ .legend {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ border: none;
+ padding: ${spacingHorizontalS};
+ background: none;
+ text-transform: capitalize;
+ }
+ .legend-rect {
+ width: 12px;
+ height: 12px;
+ margin-inline-end: ${spacingHorizontalS};
+ border: 1px solid;
+ }
+ .legend-text {
+ ${typographyCaption1Styles}
+ color: ${colorNeutralForeground1};
+ }
+ .legend.inactive .legend-rect {
+ background-color: transparent !important;
+ }
+ .legend.inactive .legend-text {
+ opacity: 0.67;
+ }
+ .bar-label {
+ ${typographyBody1StrongStyles}
+ fill: ${colorNeutralForeground1};
+ }
+ .chart-title-div {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ }
+ .ratio-numerator {
+ ${typographyBody1StrongStyles}
+ color: ${colorNeutralForeground1};
+ }
+ .ratio-denominator {
+ ${typographyBody1StrongStyles}
+ color: ${colorNeutralForeground1};
+ font-weight: bold;
+ }
+ .benchmark-container {
+ position: relative;
+ height: 7px;
+ margin-top: -3px;
+ }
+ .triangle {
+ width: 0;
+ height: 0;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-bottom: 7px solid;
+ border-bottom-color: ${colorNeutralStrokeAccessible};
+ margin-bottom: 4px;
+ position: absolute;
+ }
+ .chart-data-text {
+ ${typographyBody1StrongStyles}
+ color: ${colorNeutralForeground1};
+ }
+`.withBehaviors(
+ forcedColorsStylesheetBehavior(css`
+ .legend-rect,
+ .tooltip-line,
+ .triangle {
+ forced-color-adjust: none;
+ }
+ .tooltip-legend-text,
+ .tooltip-content-y {
+ forced-color-adjust: auto;
+ color: rgb(255, 255, 255);
+ }
+ .bar-label {
+ fill: white !important;
+ }
+ `),
+);
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.template.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.template.ts
new file mode 100644
index 0000000000000..a4ad44d927c4e
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.template.ts
@@ -0,0 +1,66 @@
+import { ElementViewTemplate, html, ref, repeat, when } from '@microsoft/fast-element';
+import type { HorizontalBarChart } from './horizontal-bar-chart.js';
+import { ChartDataPoint } from './horizontal-bar-chart.options.js';
+
+/**
+ * Generates a template for the HorizontalBarChart component.
+ *
+ * @public
+ */
+export function horizontalbarchartTemplate(): ElementViewTemplate {
+ return html`
+
+
+ ${when(
+ x => !x.hideLegends,
+ html`
+
+ ${repeat(
+ x => x.uniqueLegends,
+ html
` `,
+ )}
+
+ `,
+ )}
+ ${when(
+ x => !x.hideTooltip && x.tooltipProps.isVisible,
+ html`
+
+ `,
+ )}
+
+ `;
+}
+
+/**
+ * @internal
+ */
+export const template: ElementViewTemplate = horizontalbarchartTemplate();
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.ts
new file mode 100644
index 0000000000000..f39e2e8bbd723
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/horizontal-bar-chart.ts
@@ -0,0 +1,470 @@
+import { attr, FASTElement, observable } from '@microsoft/fast-element';
+import { create as d3Create, select as d3Select } from 'd3-selection';
+import { getRTL, jsonConverter, SVG_NAMESPACE_URI, validateChartPropsArray } from '../utils/chart-helpers.js';
+import { ChartDataPoint, ChartProps, Variant } from './horizontal-bar-chart.options.js';
+
+/**
+ * A Horizontal Bar Chart HTML Element.
+ *
+ * @public
+ */
+export class HorizontalBarChart extends FASTElement {
+ @attr
+ public variant?: Variant;
+
+ @attr({ converter: jsonConverter })
+ public data!: ChartProps[];
+
+ @attr({ attribute: 'hide-ratio', mode: 'boolean' })
+ public hideRatio: boolean = false;
+
+ @attr({ attribute: 'hide-legends', mode: 'boolean' })
+ public hideLegends: boolean = false;
+
+ @attr({ attribute: 'hide-tooltip', mode: 'boolean' })
+ public hideTooltip: boolean = false;
+
+ @attr({ attribute: 'legend-list-label' })
+ public legendListLabel?: string;
+
+ @attr({ attribute: 'chart-title' })
+ public chartTitle?: string;
+
+ @observable
+ public uniqueLegends: ChartDataPoint[] = [];
+
+ @observable
+ public activeLegend: string = '';
+
+ @observable
+ public isLegendSelected: boolean = false;
+
+ @observable
+ public tooltipProps = {
+ isVisible: false,
+ legend: '',
+ yValue: '',
+ color: '',
+ xPos: 0,
+ yPos: 0,
+ };
+
+ public rootDiv!: HTMLDivElement;
+ public chartContainer!: HTMLDivElement;
+ public elementInternals: ElementInternals = this.attachInternals();
+
+ private _isRTL: boolean = false;
+ private barHeight: number = 12;
+ private _bars: SVGRectElement[] = [];
+
+ constructor() {
+ super();
+
+ this.elementInternals.role = 'region';
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+
+ validateChartPropsArray(this.data, 'data');
+
+ this._isRTL = getRTL(this);
+ this.elementInternals.ariaLabel = this.chartTitle || `Horizontal bar chart with ${this.data.length} categories.`;
+
+ this.initializeData();
+ this.renderChart();
+ }
+
+ private initializeData() {
+ if (this.variant === Variant.SingleBar) {
+ this._hydrateData();
+ }
+ this.hydrateLegends();
+ }
+
+ public renderChart() {
+ const chartContainerDiv = d3Select(this.chartContainer);
+ chartContainerDiv
+ .selectAll('div')
+ .data(this.data!)
+ .enter()
+ .append('div')
+ .each((d, i, nodes) => {
+ this.createSingleChartBars(d, i, nodes);
+ });
+ }
+
+ private createSingleChartBars(singleChartData: ChartProps, index: number, nodes: any) {
+ const singleChartBars = this._createBarsAndLegends(singleChartData!, index);
+
+ // create a div element. Loop through chart bars and add to the div as its children
+ const divEle = d3Select(nodes[index])
+ .attr('key', index)
+ .attr('id', `_MSBC_bar-${index}`)
+ .node()!
+ .appendChild(singleChartBars.node());
+ }
+
+ private hydrateLegends() {
+ // Create a map to store unique legends
+ const uniqueLegendsMap = new Map();
+
+ // Iterate through all chart points and populate the map
+ for (const dataSeries of this.data) {
+ for (const point of dataSeries.chartData!) {
+ if ((point as any).placeholder === true) {
+ continue;
+ }
+ // Check if the legend is already in the map
+ if (!uniqueLegendsMap.has(point.legend)) {
+ uniqueLegendsMap.set(point.legend, {
+ legend: point.legend,
+ data: point.data,
+ color: point.gradient ? point.gradient[0] : point.color,
+ });
+ }
+ }
+ }
+
+ // Convert the map values back to an array
+ this.uniqueLegends = Array.from(uniqueLegendsMap.values());
+ }
+
+ private _hydrateData() {
+ this.data!.forEach(({ chartData }) => {
+ if (chartData!.length === 1) {
+ const pointData = chartData![0];
+ const newEntry = {
+ legend: '',
+ data: pointData.total! - pointData.data! > 0 ? pointData.total! - pointData.data! : 0,
+ y: pointData.total!,
+ color: '#edebe9',
+ placeholder: true,
+ };
+ chartData!.push(newEntry);
+ }
+ });
+ }
+
+ private calculateBarSpacing(): number {
+ const svgWidth = this.rootDiv.getBoundingClientRect().width;
+ let barSpacing = 0;
+ const MARGIN_WIDTH_IN_PX = 3;
+ if (svgWidth) {
+ const currentBarSpacing = (MARGIN_WIDTH_IN_PX / svgWidth) * 100;
+ barSpacing = currentBarSpacing;
+ }
+ return barSpacing;
+ }
+
+ public _createBarsAndLegends(data: ChartProps, barNo?: number) {
+ const _isRTL = this._isRTL;
+ const _computeLongestBarTotalValue = () => {
+ let longestBarTotalValue = 0;
+ this.data!.forEach(({ chartData }) => {
+ const barTotalValue = chartData!.reduce(
+ (acc: number, point: ChartDataPoint) => acc + (point.data ? point.data : 0),
+ 0,
+ );
+ longestBarTotalValue = Math.max(longestBarTotalValue, barTotalValue);
+ });
+ return longestBarTotalValue;
+ };
+ const longestBarTotalValue = _computeLongestBarTotalValue();
+ const noOfBars =
+ data.chartData?.reduce((count: number, point: ChartDataPoint) => (count += (point.data || 0) > 0 ? 1 : 0), 0) ||
+ 1;
+ const barSpacingInPercent = this.calculateBarSpacing();
+ const totalMarginPercent = barSpacingInPercent * (noOfBars - 1);
+ // calculating starting point of each bar and it's range
+ const startingPoint: number[] = [];
+ const barTotalValue = data.chartData!.reduce(
+ (acc: number, point: ChartDataPoint) => acc + (point.data ? point.data : 0),
+ 0,
+ );
+ const total = this.variant === Variant.AbsoluteScale ? longestBarTotalValue : barTotalValue;
+
+ let sumOfPercent = 0;
+ data.chartData!.map((point: ChartDataPoint, index: number) => {
+ const pointData = point.data ? point.data : 0;
+ const currValue = (pointData / total) * 100;
+ let value = currValue ? currValue : 0;
+
+ if (value < 1 && value !== 0) {
+ value = 1;
+ } else if (value > 99 && value !== 100) {
+ value = 99;
+ }
+ sumOfPercent += value;
+
+ return sumOfPercent;
+ });
+
+ // Include an imaginary placeholder bar with value equal to
+ // the difference between longestBarTotalValue and barTotalValue
+ // while calculating sumOfPercent to get correct scalingRatio for absolute-scale variant
+ if (this.variant === Variant.AbsoluteScale) {
+ let value = total === 0 ? 0 : ((total - barTotalValue) / total) * 100;
+ if (value < 1 && value !== 0) {
+ value = 1;
+ } else if (value > 99 && value !== 100) {
+ value = 99;
+ }
+ sumOfPercent += value;
+ }
+
+ /**
+ * The %age of the space occupied by the margin needs to subtracted
+ * while computing the scaling ratio, since the margins are not being
+ * scaled down, only the data is being scaled down from a higher percentage to lower percentage
+ * Eg: 95% of the space is taken by the bars, 5% by the margins
+ * Now if the sumOfPercent is 120% -> This needs to be scaled down to 95%, not 100%
+ * since that's only space available to the bars
+ */
+
+ const scalingRatio = sumOfPercent !== 0 ? sumOfPercent / (100 - totalMarginPercent) : 1;
+
+ let prevPosition = 0;
+ let value = 0;
+
+ const createBars = (g: SVGGElement, point: ChartDataPoint, index: number) => {
+ const barHeight = 12;
+ const pointData = point.data ? point.data : 0;
+ if (index > 0) {
+ prevPosition += value;
+ }
+ value = (pointData / total) * 100 ? (pointData / total) * 100 : 0;
+ if (value < 1 && value !== 0) {
+ value = 1 / scalingRatio;
+ } else if (value > 99 && value !== 100) {
+ value = 99 / scalingRatio;
+ } else {
+ value = value / scalingRatio;
+ }
+
+ startingPoint.push(prevPosition);
+
+ const gEle = d3Select(g) // 'this' refers to the current 'g' element
+ .attr('key', index)
+ .attr('role', 'img')
+ .attr('aria-label', pointData);
+
+ let gradientId = '';
+ if (point.gradient) {
+ const defs = document.createElementNS(SVG_NAMESPACE_URI, 'defs');
+ gEle.node()!.appendChild(defs);
+
+ const linearGradient = document.createElementNS(SVG_NAMESPACE_URI, 'linearGradient');
+ defs.appendChild(linearGradient);
+ gradientId = `gradient-${barNo}-${index}`;
+ linearGradient.setAttribute('id', gradientId);
+
+ const stop1 = document.createElementNS(SVG_NAMESPACE_URI, 'stop');
+ linearGradient.appendChild(stop1);
+ stop1.setAttribute('offset', '0%');
+ stop1.setAttribute('stop-color', point.gradient[0]);
+
+ const stop2 = document.createElementNS(SVG_NAMESPACE_URI, 'stop');
+ linearGradient.appendChild(stop2);
+ stop2.setAttribute('offset', '100%');
+ stop2.setAttribute('stop-color', point.gradient[1]);
+ }
+ gEle;
+ const rect = gEle
+ .append('rect')
+ .attr('key', index)
+ .attr('id', `${barNo}-${index}`)
+ .attr('barinfo', `${point.legend}`)
+ .attr('class', 'bar')
+ .attr('style', point.gradient ? `fill:url(#${gradientId})` : `fill:${point.color!}`)
+ .attr(
+ 'x',
+ `${
+ _isRTL
+ ? 100 - startingPoint[index] - value - barSpacingInPercent * index
+ : startingPoint[index] + barSpacingInPercent * index
+ }%`,
+ )
+ .attr('y', 0)
+ .attr('width', value + '%')
+ .attr('height', barHeight)
+ .attr('tabindex', 0);
+ this._bars.push(rect.node()!);
+ };
+
+ const containerDiv = d3Create('div').attr('style', 'position: relative');
+
+ const chartTitleDiv = containerDiv.append('div').attr('class', 'chart-title-div');
+ chartTitleDiv
+ .append('div')
+ .append('span')
+ .attr('class', 'chart-title')
+ .text(data?.chartSeriesTitle ? data?.chartSeriesTitle : '');
+
+ const showChartDataText = this.variant !== Variant.AbsoluteScale;
+ // chartData length is always 2 in single-bar variant
+ const showRatio = !this.hideRatio && data!.chartData!.length === 2;
+ const getChartData = () => (data!.chartData![0].data ? data!.chartData![0].data : 0);
+
+ if (showChartDataText) {
+ if (data.chartDataText) {
+ const chartTitleRight = document.createElement('div');
+ chartTitleDiv.node()!.appendChild(chartTitleRight);
+ chartTitleRight.classList.add('chart-data-text');
+ chartTitleRight.textContent = data.chartDataText;
+ } else if (showRatio) {
+ const ratioDiv = chartTitleDiv.append('div').attr('role', 'text');
+ const numData = data!.chartData![0].data;
+ const denomData = data!.chartData![1].data;
+ const total = numData! + denomData!;
+ ratioDiv.append('span').attr('class', 'ratio-numerator').text(numData!);
+ ratioDiv.append('span').attr('class', 'ratio-denominator').text(`/${total!}`);
+ }
+ }
+
+ const svgDiv = containerDiv.append('div').attr('style', 'display: flex;');
+ const svgEle = svgDiv
+ .append('svg')
+ .attr('height', 12)
+ .attr('width', 100 + '%')
+ .attr('class', 'svg-chart')
+ .attr(
+ 'aria-label',
+ data?.chartSeriesTitle
+ ? data?.chartSeriesTitle
+ : `Series with ${data.chartData.length}${data.chartData.length > 1 ? ' stacked' : ''} bars.`,
+ )
+ .selectAll('g')
+ .data(data.chartData!)
+ .enter()
+ .append('g')
+ .each(function (this, d, i) {
+ createBars(this, d, i);
+ })
+ .on('mouseover', (event, d) => {
+ if (d && d.hasOwnProperty('placeholder') && (d as any).placeholder === true) {
+ return;
+ }
+
+ const bounds = this.rootDiv.getBoundingClientRect();
+ const centerX = window.innerWidth / 2;
+ const xPos = Math.max(0, Math.min(centerX, window.innerWidth));
+
+ this.tooltipProps = {
+ isVisible: true,
+ legend: d.legend,
+ yValue: `${d.data}`,
+ color: d.gradient ? d.gradient[0] : d.color!,
+ xPos: this._isRTL ? bounds.right - event.clientX : Math.min(event.clientX - bounds.left, xPos),
+ yPos: event.clientY - bounds.top - 40,
+ };
+ })
+ .on('mouseout', () => {
+ this.tooltipProps = { isVisible: false, legend: '', yValue: '', color: '', xPos: 0, yPos: 0 };
+ });
+
+ if (this.variant === Variant.AbsoluteScale) {
+ const showLabel = true;
+ const barLabel = barTotalValue;
+ if (showLabel) {
+ if (Math.round((startingPoint[startingPoint.length - 1] || 0) + value + totalMarginPercent) === 100) {
+ svgDiv
+ .append('text')
+ .attr('key', 'text')
+ .attr('style', 'margin-top: -4.5px; margin-left: 2px;')
+ .attr('class', 'bar-label')
+ .attr(
+ 'x',
+ `${
+ this._isRTL
+ ? 100 - (startingPoint[startingPoint.length - 1] || 0) - value - totalMarginPercent
+ : (startingPoint[startingPoint.length - 1] || 0) + value + totalMarginPercent
+ }%`,
+ )
+ .attr('textAnchor', 'start')
+ .attr('y', this.barHeight / 2 + 6)
+ .attr('dominantBaseline', 'central')
+ .attr('transform', `translate(${this._isRTL ? -4 : 4})`)
+ .attr('aria-label', `Total: ${barLabel}`)
+ .attr('role', 'img')
+ .text(barLabel);
+ } else {
+ svgEle
+ .append('text')
+ .attr('key', 'text')
+ .attr('class', 'bar-label')
+ .attr(
+ 'x',
+ `${
+ this._isRTL
+ ? 100 - (startingPoint[startingPoint.length - 1] || 0) - value - totalMarginPercent
+ : (startingPoint[startingPoint.length - 1] || 0) + value + totalMarginPercent
+ }%`,
+ )
+ .attr('textAnchor', 'start')
+ .attr('y', this.barHeight / 2 + 6)
+ .attr('dominantBaseline', 'central')
+ .attr('transform', `translate(${this._isRTL ? -4 : 4})`)
+ .attr('aria-label', `Total: ${barLabel}`)
+ .attr('role', 'img')
+ .text(barLabel);
+ }
+ }
+ }
+
+ if (data.benchmarkData) {
+ const benchmarkContainer = document.createElement('div');
+ containerDiv.node()!.appendChild(benchmarkContainer);
+ benchmarkContainer.classList.add('benchmark-container');
+
+ const triangle = document.createElement('div');
+ benchmarkContainer.appendChild(triangle);
+ triangle.classList.add('triangle');
+
+ const benchmarkRatio = (data.benchmarkData / total) * 100;
+ triangle.style['insetInlineStart'] = `calc(${benchmarkRatio}% - 4px)`;
+ }
+
+ return containerDiv;
+ }
+
+ public handleLegendMouseoverAndFocus = (legendTitle: string) => {
+ if (this.isLegendSelected) {
+ return;
+ }
+
+ this.activeLegend = legendTitle;
+ };
+
+ public handleLegendMouseoutAndBlur = () => {
+ if (this.isLegendSelected) {
+ return;
+ }
+
+ this.activeLegend = '';
+ };
+
+ public handleLegendClick = (legendTitle: string) => {
+ if (this.isLegendSelected && this.activeLegend === legendTitle) {
+ this.activeLegend = '';
+ this.isLegendSelected = false;
+ } else {
+ this.activeLegend = legendTitle;
+ this.isLegendSelected = true;
+ }
+ };
+
+ public activeLegendChanged = (oldValue: string, newValue: string) => {
+ if (newValue === '') {
+ this._bars?.forEach(bar => bar.classList.remove('inactive'));
+ } else {
+ this._bars?.forEach(bar => {
+ if (bar.getAttribute('barinfo') === newValue) {
+ bar.classList.remove('inactive');
+ } else {
+ bar.classList.add('inactive');
+ }
+ });
+ }
+ };
+}
diff --git a/packages/charts/chart-web-components/src/horizontal-bar-chart/index.ts b/packages/charts/chart-web-components/src/horizontal-bar-chart/index.ts
new file mode 100644
index 0000000000000..0695516e7dce4
--- /dev/null
+++ b/packages/charts/chart-web-components/src/horizontal-bar-chart/index.ts
@@ -0,0 +1,4 @@
+export { definition as HorizontalBarChartDefinition } from './horizontal-bar-chart.definition.js';
+export { HorizontalBarChart } from './horizontal-bar-chart.js';
+export { styles as HorizontalBarChartStyles } from './horizontal-bar-chart.styles.js';
+export { template as HorizontalBarChartTemplate } from './horizontal-bar-chart.template.js';
diff --git a/packages/charts/chart-web-components/src/index-rollup.ts b/packages/charts/chart-web-components/src/index-rollup.ts
new file mode 100644
index 0000000000000..8726e6a32d0f9
--- /dev/null
+++ b/packages/charts/chart-web-components/src/index-rollup.ts
@@ -0,0 +1,2 @@
+import './horizontal-bar-chart/define.js';
+import './donut-chart/define.js';
diff --git a/packages/charts/chart-web-components/src/index.ts b/packages/charts/chart-web-components/src/index.ts
new file mode 100644
index 0000000000000..9dcd14f29327d
--- /dev/null
+++ b/packages/charts/chart-web-components/src/index.ts
@@ -0,0 +1,7 @@
+export {
+ HorizontalBarChart,
+ HorizontalBarChartDefinition,
+ HorizontalBarChartStyles,
+ HorizontalBarChartTemplate,
+} from './horizontal-bar-chart/index.js';
+export { DonutChart, DonutChartDefinition, DonutChartStyles, DonutChartTemplate } from './donut-chart/index.js';
diff --git a/packages/charts/chart-web-components/src/utils/benchmark-wrapper.ts b/packages/charts/chart-web-components/src/utils/benchmark-wrapper.ts
new file mode 100644
index 0000000000000..3b1807cbff920
--- /dev/null
+++ b/packages/charts/chart-web-components/src/utils/benchmark-wrapper.ts
@@ -0,0 +1,22 @@
+// eslint-disable-next-line
+// @ts-nocheck
+import { tests } from '@tensile-perf/web-components';
+import { webLightTheme } from '@fluentui/tokens';
+import { setTheme } from '@fluentui/web-components';
+
+const testWrapper = (test: any, args: any) => {
+ setTheme(webLightTheme);
+ return test(args);
+};
+
+const wrappedTests = {};
+
+for (const testName of Object.keys(tests)) {
+ const test = tests[testName];
+
+ wrappedTests[testName] = (args: any) => {
+ return testWrapper(test, args);
+ };
+}
+
+export { wrappedTests as tests };
diff --git a/packages/charts/chart-web-components/src/utils/chart-helpers.ts b/packages/charts/chart-web-components/src/utils/chart-helpers.ts
new file mode 100644
index 0000000000000..45f9ecdb56dd5
--- /dev/null
+++ b/packages/charts/chart-web-components/src/utils/chart-helpers.ts
@@ -0,0 +1,193 @@
+import type { ValueConverter } from '@microsoft/fast-element';
+import { Direction } from '@microsoft/fast-web-utilities';
+import { getDirection } from '@fluentui/web-components';
+
+export const jsonConverter: ValueConverter = {
+ toView(value: any): string {
+ return JSON.stringify(value);
+ },
+ fromView(value: string): any {
+ return JSON.parse(value);
+ },
+};
+
+type Dict = { [key: string]: any };
+
+export const validateChartPropsArray = (obj: any, objName: string) => {
+ if (!Array.isArray(obj)) {
+ throw TypeError(`Invalid ${objName}: Expected an array.`);
+ }
+
+ obj.forEach((item, idx) => {
+ validateChartProps(item, `${objName}[${idx}]`);
+ });
+};
+
+export const validateChartProps = (obj: any, objName: string) => {
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
+ throw TypeError(`Invalid ${objName}: Expected an object.`);
+ }
+
+ if (!Array.isArray(obj.chartData)) {
+ throw TypeError(`Invalid ${objName}.chartData: Expected an array.`);
+ }
+
+ (obj.chartData as any[]).forEach((item, idx) => {
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
+ throw TypeError(`Invalid ${objName}.chartData[${idx}]: Expected an object.`);
+ }
+
+ if (typeof item.legend !== 'string') {
+ throw TypeError(`Invalid ${objName}.chartData[${idx}].legend: Expected a string.`);
+ }
+
+ if (typeof item.data !== 'number') {
+ throw TypeError(`Invalid ${objName}.chartData[${idx}].data: Expected a number.`);
+ }
+ });
+};
+
+export const DataVizPalette = {
+ color1: 'qualitative.1',
+ color2: 'qualitative.2',
+ color3: 'qualitative.3',
+ color4: 'qualitative.4',
+ color5: 'qualitative.5',
+ color6: 'qualitative.6',
+ color7: 'qualitative.7',
+ color8: 'qualitative.8',
+ color9: 'qualitative.9',
+ color10: 'qualitative.10',
+ color11: 'qualitative.21',
+ color12: 'qualitative.22',
+ color13: 'qualitative.23',
+ color14: 'qualitative.24',
+ color15: 'qualitative.25',
+ color16: 'qualitative.26',
+ color17: 'qualitative.27',
+ color18: 'qualitative.28',
+ color19: 'qualitative.29',
+ info: 'semantic.info',
+ disabled: 'semantic.disabled',
+ highError: 'semantic.highError',
+ error: 'semantic.error',
+ warning: 'semantic.warning',
+ success: 'semantic.success',
+ highSuccess: 'semantic.highSuccess',
+};
+
+/**
+ * Key: Color code.
+ * Value:
+ * Index 0 - Default color / Color for light theme,
+ * Index 1 - Color for dark theme
+ */
+type Palette = { [key: string]: string[] };
+
+const QualitativePalette: Palette = {
+ '1': ['#637cef'], // [cornflower.tint10],
+ '2': ['#e3008c'], // [hotPink.primary],
+ '3': ['#2aa0a4'], // [teal.tint20],
+ '4': ['#9373c0'], // [orchid.tint10],
+ '5': ['#13a10e'], // [lightGreen.primary],
+ '6': ['#3a96dd'], // [lightBlue.primary],
+ '7': ['#ca5010'], // [pumpkin.primary],
+ '8': ['#57811b'], // [lime.shade20],
+ '9': ['#b146c2'], // [lilac.primary],
+ '10': ['#ae8c00'], // [gold.shade10],
+ '21': ['#4f6bed'], // [cornflower.primary],
+ '22': ['#ea38a6'], // [hotPink.tint20],
+ '23': ['#038387'], // [teal.primary],
+ '24': ['#8764b8'], // [orchid.primary],
+ '25': ['#11910d'], // [lightGreen.shade10],
+ '26': ['#3487c7'], // [lightBlue.shade10],
+ '27': ['#d06228'], // [pumpkin.tint10],
+ '28': ['#689920'], // [lime.shade10],
+ '29': ['#ba58c9'], // [lilac.tint10],
+};
+
+const SemanticPalette: Palette = {
+ info: ['#015cda'],
+ disabled: ['#dbdbdb', '#4d4d4d'], // [grey[86], grey[30]]
+ highError: ['#6e0811', '#cc2635'], // [cranberry.shade30, cranberry.tint10],
+ error: ['#c50f1f', '#dc626d'], // [cranberry.primary, cranberry.tint30],
+ warning: ['#f7630c', '#f87528'], // [orange.primary, orange.tint10],
+ success: ['#107c10', '#54b054'], // [green.primary, green.tint30],
+ highSuccess: ['#094509', '#218c21'], // [green.shade30, green.tint10],
+};
+
+const Colors: { [key: string]: Palette } = {
+ qualitative: QualitativePalette,
+ semantic: SemanticPalette,
+};
+
+const QUALITATIVE_COLORS = Object.values(QualitativePalette);
+const TOKENS = Object.values(DataVizPalette);
+
+const getThemeSpecificColor = (colors: string[], isDarkTheme: boolean): string => {
+ if (colors.length === 0) {
+ return '';
+ }
+ const colorIdx = Number(isDarkTheme);
+ if (colorIdx < colors.length) {
+ return colors[colorIdx];
+ }
+ return colors[0];
+};
+
+export const getNextColor = (index: number, offset: number = 0, isDarkTheme: boolean = false): string => {
+ const colors = QUALITATIVE_COLORS[(index + offset) % QUALITATIVE_COLORS.length];
+ return getThemeSpecificColor(colors, isDarkTheme);
+};
+
+export const getColorFromToken = (token: string, isDarkTheme: boolean = false): string => {
+ if (TOKENS.indexOf(token) >= 0) {
+ const [paletteName, colorCode] = token.split('.');
+ const colors = Colors[paletteName][colorCode];
+ return getThemeSpecificColor(colors, isDarkTheme);
+ }
+ return token;
+};
+
+export const getRTL = (rootNode: HTMLElement): boolean => {
+ return getDirection(rootNode) === Direction.rtl;
+};
+
+export const SVG_NAMESPACE_URI = 'http://www.w3.org/2000/svg';
+
+export const wrapText = (text: SVGTextElement, width: number) => {
+ if (!text.textContent) {
+ return;
+ }
+
+ const words = text.textContent.split(/\s+/).reverse();
+ let word: string | undefined;
+ let line: string[] = [];
+ let lineNumber = 0;
+ const lineHeight = text.getBoundingClientRect().height;
+ const y = text.getAttribute('y') || '0';
+
+ text.textContent = null;
+
+ let tspan = document.createElementNS(SVG_NAMESPACE_URI, 'tspan');
+ text.appendChild(tspan);
+ tspan.setAttribute('x', '0');
+ tspan.setAttribute('y', y);
+ tspan.setAttribute('dy', `${lineNumber++ * lineHeight}`);
+
+ while ((word = words.pop())) {
+ line.push(word);
+ tspan.textContent = line.join(' ') + ' ';
+ if (tspan.getComputedTextLength() > width && line.length > 1) {
+ line.pop();
+ tspan.textContent = line.join(' ') + ' ';
+ line = [word];
+ tspan = document.createElementNS(SVG_NAMESPACE_URI, 'tspan');
+ text.appendChild(tspan);
+ tspan.setAttribute('x', '0');
+ tspan.setAttribute('y', y);
+ tspan.setAttribute('dy', `${lineNumber++ * lineHeight}`);
+ tspan.textContent = word;
+ }
+ }
+};
diff --git a/packages/charts/chart-web-components/tensile.config.js b/packages/charts/chart-web-components/tensile.config.js
new file mode 100644
index 0000000000000..d2531e5ea6487
--- /dev/null
+++ b/packages/charts/chart-web-components/tensile.config.js
@@ -0,0 +1,20 @@
+const config = {
+ // Browsers to test against
+ browsers: ['chrome'],
+
+ // Importmaps for your test.
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
+ imports: {
+ '@tensile-perf/web-components': '/node_modules/@tensile-perf/web-components/lib/index.js',
+ '@microsoft/fast-element': '/node_modules/@microsoft/fast-element/dist/fast-element.min.js',
+ '@microsoft/fast-element/utilities.js': '/node_modules/@microsoft/fast-element/dist/esm/utilities.js',
+ '@microsoft/fast-web-utilities': '/node_modules/@microsoft/fast-web-utilities/dist/index.js',
+ '@fluentui/tokens': '/tensile-assets/benchmark-dependencies/tokens.js',
+ '@fluentui/web-components': '/node_modules/@fluentui/web-components/dist/esm/index.js',
+ 'exenv-es6': '/node_modules/exenv-es6/dist/index.js',
+ tabbable: '/node_modules/tabbable/dist/index.esm.js',
+ tslib: '/node_modules/tslib/tslib.es6.js',
+ },
+};
+
+export default config;
diff --git a/packages/charts/chart-web-components/tsconfig.api-extractor.json b/packages/charts/chart-web-components/tsconfig.api-extractor.json
new file mode 100644
index 0000000000000..e245193e1fb3d
--- /dev/null
+++ b/packages/charts/chart-web-components/tsconfig.api-extractor.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "paths": null,
+ "baseUrl": "."
+ }
+}
diff --git a/packages/charts/chart-web-components/tsconfig.json b/packages/charts/chart-web-components/tsconfig.json
new file mode 100644
index 0000000000000..7c023fa3b6359
--- /dev/null
+++ b/packages/charts/chart-web-components/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "extends": "../../../tsconfig.base.wc.json",
+ "compilerOptions": {
+ "target": "ES2019",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "experimentalDecorators": true,
+ "allowJs": true
+ },
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ },
+ {
+ "path": "./.storybook/tsconfig.json"
+ }
+ ]
+}
diff --git a/packages/charts/chart-web-components/tsconfig.lib.json b/packages/charts/chart-web-components/tsconfig.lib.json
new file mode 100644
index 0000000000000..a9ffdf4359682
--- /dev/null
+++ b/packages/charts/chart-web-components/tsconfig.lib.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2019",
+ "module": "NodeNext",
+ "lib": ["ESNext", "DOM"],
+ "declaration": true,
+ "declarationDir": "dist/dts",
+ "outDir": "dist/esm",
+ "importHelpers": true
+ },
+ "include": ["src"],
+ "exclude": ["**/*.stories.*", "**/*.test.*", "**/*.spec.*"]
+}
diff --git a/packages/charts/chart-web-components/tsconfig.spec.json b/packages/charts/chart-web-components/tsconfig.spec.json
new file mode 100644
index 0000000000000..d9bed36588029
--- /dev/null
+++ b/packages/charts/chart-web-components/tsconfig.spec.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "NodeNext",
+ "outDir": "dist/esm",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.test.*", "src/**/*.spec.*"]
+}
diff --git a/packages/charts/chart-web-components/tsdoc.json b/packages/charts/chart-web-components/tsdoc.json
new file mode 100644
index 0000000000000..0c30fee865df6
--- /dev/null
+++ b/packages/charts/chart-web-components/tsdoc.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"],
+ "tagDefinitions": [
+ {
+ "tagName": "@slot",
+ "syntaxKind": "block",
+ "allowMultiple": true
+ },
+ {
+ "tagName": "@csspart",
+ "syntaxKind": "block",
+ "allowMultiple": true
+ },
+ {
+ "tagName": "@cssprop",
+ "syntaxKind": "block",
+ "allowMultiple": true
+ },
+ {
+ "tagName": "@cssproperty",
+ "syntaxKind": "block",
+ "allowMultiple": true
+ },
+ {
+ "tagName": "@event",
+ "syntaxKind": "block",
+ "allowMultiple": true
+ },
+ {
+ "tagName": "@fires",
+ "syntaxKind": "block",
+ "allowMultiple": true
+ }
+ ],
+ "supportForTags": {
+ "@slot": true,
+ "@csspart": true,
+ "@cssprop": true,
+ "@cssproperty": true,
+ "@event": true,
+ "@fires": true
+ }
+}
diff --git a/tsconfig.base.wc.json b/tsconfig.base.wc.json
index 5c7d220e1c3a6..c4492bf1b4ddc 100644
--- a/tsconfig.base.wc.json
+++ b/tsconfig.base.wc.json
@@ -11,6 +11,7 @@
"rootDir": ".",
"baseUrl": ".",
"paths": {
+ "@fluentui/chart-web-components": ["packages/charts/chart-web-components/src/index.ts"],
"@fluentui/web-components": ["packages/web-components/src/index.ts"],
"@fluentui/tokens": ["packages/tokens/src/index.ts"]
}
diff --git a/yarn.lock b/yarn.lock
index 63990c4e70db9..693ec6d7f9e55 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1954,6 +1954,23 @@
"@griffel/react" "^1.5.14"
"@swc/helpers" "^0.5.1"
+"@fluentui/tokens@1.0.0-alpha.18":
+ version "1.0.0-alpha.18"
+ resolved "https://registry.yarnpkg.com/@fluentui/tokens/-/tokens-1.0.0-alpha.18.tgz#9a6ca03c26c9223573535beb198a7ff9a58ed8a7"
+ integrity sha512-d7CpB7RJhPlv8r6OjKRsL4mu8dvSiwrGdQuZyRhDjhCa/5u0xSdCxLmwGu4HOTlr9sg9Gf7LbQe2shAlq2J21w==
+ dependencies:
+ "@swc/helpers" "^0.5.1"
+
+"@fluentui/web-components@3.0.0-beta.72":
+ version "3.0.0-beta.72"
+ resolved "https://registry.yarnpkg.com/@fluentui/web-components/-/web-components-3.0.0-beta.72.tgz#d83a2cc377b7376d4e240b8c8c2352c9538e9a90"
+ integrity sha512-JB/JQ1Nqj/rg6yML40qv/n/S5FX49Ep8ybAAe11yBPkcKmWhC29xkkTcOzBfXlc+1d3a5rndcdXS9vYg/cfleA==
+ dependencies:
+ "@fluentui/tokens" "1.0.0-alpha.18"
+ "@microsoft/fast-web-utilities" "^6.0.0"
+ tabbable "^6.2.0"
+ tslib "^2.1.0"
+
"@griffel/babel-preset@1.5.8", "@griffel/babel-preset@^1.5.8":
version "1.5.8"
resolved "https://registry.yarnpkg.com/@griffel/babel-preset/-/babel-preset-1.5.8.tgz#2ee5e404b34350fcb40a2506d73b0467f1a66be0"
@@ -21515,7 +21532,7 @@ string-length@^5.0.1:
char-regex "^2.0.0"
strip-ansi "^7.0.1"
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -21550,15 +21567,6 @@ string-width@^3.0.0, string-width@^3.1.0:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^5.1.0"
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -21659,7 +21667,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -21694,13 +21702,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -23945,7 +23946,7 @@ workspace-tools@^0.27.0:
js-yaml "^4.1.0"
micromatch "^4.0.0"
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -23980,15 +23981,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-wrap-ansi@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"