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` + + `; +} + +/** + * @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` + + `; +} + +/** + * @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 efaf4558c7846..540ce1cce411b 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"