diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index 1fdfc52857b011..eefa02be4f1af8 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -80,10 +80,10 @@ jobs: !**/binary/** - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: airflow,airflow-${{ matrix.extra_pip_extras }} name: pytest-airflow-${{ matrix.python-version }}-${{ matrix.extra_pip_requirements }} diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a5889b2d2f92de..1b10fe6e74372b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -126,6 +126,16 @@ jobs: !**/binary/** - name: Ensure codegen is updated uses: ./.github/actions/ensure-codegen-updated + - name: Upload coverage to Codecov + if: always() + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./build/coverage-reports/ + fail_ci_if_error: false + flags: ${{ matrix.timezone }} + name: ${{ matrix.command }} + verbose: true quickstart-compose-validation: runs-on: ubuntu-latest diff --git a/.github/workflows/dagster-plugin.yml b/.github/workflows/dagster-plugin.yml index 37b6c93ec841ab..f512dcf8f3ffd4 100644 --- a/.github/workflows/dagster-plugin.yml +++ b/.github/workflows/dagster-plugin.yml @@ -66,10 +66,10 @@ jobs: **/junit.*.xml - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: dagster-${{ matrix.python-version }}-${{ matrix.extraPythonRequirement }} name: pytest-dagster diff --git a/.github/workflows/gx-plugin.yml b/.github/workflows/gx-plugin.yml index aa7c3f069c7654..595438bd6e4a90 100644 --- a/.github/workflows/gx-plugin.yml +++ b/.github/workflows/gx-plugin.yml @@ -70,10 +70,10 @@ jobs: **/junit.*.xml - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: gx-${{ matrix.python-version }}-${{ matrix.extraPythonRequirement }} name: pytest-gx diff --git a/.github/workflows/metadata-ingestion.yml b/.github/workflows/metadata-ingestion.yml index c0eafe891fb0aa..49def2a863c565 100644 --- a/.github/workflows/metadata-ingestion.yml +++ b/.github/workflows/metadata-ingestion.yml @@ -94,10 +94,10 @@ jobs: !**/binary/** - name: Upload coverage to Codecov if: ${{ always() && matrix.python-version == '3.10' }} - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: pytest-${{ matrix.command }} name: pytest-${{ matrix.command }} diff --git a/.github/workflows/metadata-io.yml b/.github/workflows/metadata-io.yml index 5ee2223d71b039..2225baecde64c6 100644 --- a/.github/workflows/metadata-io.yml +++ b/.github/workflows/metadata-io.yml @@ -81,6 +81,15 @@ jobs: !**/binary/** - name: Ensure codegen is updated uses: ./.github/actions/ensure-codegen-updated + - name: Upload coverage to Codecov + if: ${{ always()}} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./build/coverage-reports/ + fail_ci_if_error: false + name: metadata-io-test + verbose: true event-file: runs-on: ubuntu-latest diff --git a/.github/workflows/prefect-plugin.yml b/.github/workflows/prefect-plugin.yml index b0af00f92b7727..3c75e8fe9a62ff 100644 --- a/.github/workflows/prefect-plugin.yml +++ b/.github/workflows/prefect-plugin.yml @@ -67,10 +67,10 @@ jobs: !**/binary/** - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: prefect,prefect-${{ matrix.extra_pip_extras }} name: pytest-prefect-${{ matrix.python-version }} diff --git a/datahub-frontend/build.gradle b/datahub-frontend/build.gradle index 7750e169b11fbe..5cc5af50d217ba 100644 --- a/datahub-frontend/build.gradle +++ b/datahub-frontend/build.gradle @@ -4,8 +4,9 @@ plugins { id 'org.gradle.playframework' } -apply from: "../gradle/versioning/versioning.gradle" +apply from: '../gradle/versioning/versioning.gradle' apply from: './play.gradle' +apply from: '../gradle/coverage/java-coverage.gradle' ext { docker_repo = 'datahub-frontend-react' @@ -18,6 +19,13 @@ java { } } +test { + jacoco { + // jacoco instrumentation is failing when dealing with code of this dependency, excluding it. + excludes = ["com/gargoylesoftware/**"] + } +} + model { // Must specify the dependency here as "stage" is added by rule based model. tasks.myTar { diff --git a/datahub-web-react/.storybook/DocTemplate.mdx b/datahub-web-react/.storybook/DocTemplate.mdx new file mode 100644 index 00000000000000..9ea1250075e11f --- /dev/null +++ b/datahub-web-react/.storybook/DocTemplate.mdx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { ThemeProvider } from 'styled-components'; +import { GlobalStyle } from './styledComponents'; + +import { Meta, Title, Subtitle, Description, Primary, Controls, Stories } from '@storybook/blocks'; +import { CodeBlock } from '../src/alchemy-components/.docs/mdx-components'; + +{/* + * 👇 The isTemplate property is required to tell Storybook that this is a template + * See https://storybook.js.org/docs/api/doc-block-meta + * to learn how to use +*/} + + + + + + + + + <Subtitle /> + + <div className="docsDescription"> + <Description /> + </div> + + <br /> + + ### Import + + <CodeBlock /> + + <br/> + + ### Customize + + <Primary /> + <Controls /> + + <Stories /> +</ThemeProvider> \ No newline at end of file diff --git a/datahub-web-react/.storybook/main.js b/datahub-web-react/.storybook/main.js new file mode 100644 index 00000000000000..2b92dffd88eb3a --- /dev/null +++ b/datahub-web-react/.storybook/main.js @@ -0,0 +1,25 @@ +// Docs for badges: https://storybook.js.org/addons/@geometricpanda/storybook-addon-badges + +export default { + framework: '@storybook/react-vite', + features: { + buildStoriesJson: true, + }, + core: { + disableTelemetry: true, + }, + stories: [ + '../src/alchemy-components/.docs/*.mdx', + '../src/alchemy-components/components/**/*.stories.@(js|jsx|mjs|ts|tsx)' + ], + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-links', + '@geometricpanda/storybook-addon-badges', + ], + typescript: { + reactDocgen: 'react-docgen-typescript', + }, +} \ No newline at end of file diff --git a/datahub-web-react/.storybook/manager-head.html b/datahub-web-react/.storybook/manager-head.html new file mode 100644 index 00000000000000..98e6a2895f45c7 --- /dev/null +++ b/datahub-web-react/.storybook/manager-head.html @@ -0,0 +1,33 @@ +<style type="text/css"> + /* Regular */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf') format('truetype'); + } + + /* Medium */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf') format('truetype'); + } + + /* SemiBold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf') format('truetype'); + } + + /* Bold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf') format('truetype'); + } +</style> \ No newline at end of file diff --git a/datahub-web-react/.storybook/manager.js b/datahub-web-react/.storybook/manager.js new file mode 100644 index 00000000000000..6e9c62dd96c23f --- /dev/null +++ b/datahub-web-react/.storybook/manager.js @@ -0,0 +1,15 @@ +import './storybook-theme.css'; + +import { addons } from '@storybook/manager-api'; +import acrylTheme from './storybook-theme.js'; + +// Theme setup +addons.setConfig({ + theme: acrylTheme, +}); + +// Favicon +const link = document.createElement('link'); +link.setAttribute('rel', 'shortcut icon'); +link.setAttribute('href', 'https://www.acryldata.io/icons/favicon.ico'); +document.head.appendChild(link); \ No newline at end of file diff --git a/datahub-web-react/.storybook/preview-head.html b/datahub-web-react/.storybook/preview-head.html new file mode 100644 index 00000000000000..98e6a2895f45c7 --- /dev/null +++ b/datahub-web-react/.storybook/preview-head.html @@ -0,0 +1,33 @@ +<style type="text/css"> + /* Regular */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf') format('truetype'); + } + + /* Medium */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf') format('truetype'); + } + + /* SemiBold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf') format('truetype'); + } + + /* Bold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf') format('truetype'); + } +</style> \ No newline at end of file diff --git a/datahub-web-react/.storybook/preview.js b/datahub-web-react/.storybook/preview.js new file mode 100644 index 00000000000000..a497ce7bccf3c8 --- /dev/null +++ b/datahub-web-react/.storybook/preview.js @@ -0,0 +1,84 @@ +import './storybook-theme.css'; +// FYI: import of antd styles required to show components based on it correctly +import 'antd/dist/antd.css'; + +import { BADGE, defaultBadgesConfig } from '@geometricpanda/storybook-addon-badges'; +import DocTemplate from './DocTemplate.mdx'; + +const preview = { + tags: ['!dev', 'autodocs'], + parameters: { + previewTabs: { + 'storybook/docs/panel': { index: -1 }, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + options: { + storySort: { + method: 'alphabetical', + order: [ + // Order of Docs Pages + 'Introduction', + 'Style Guide', + 'Design Tokens', + 'Style Utilities', + 'Icons', + + // Order of Components + 'Layout', + 'Forms', + 'Data Display', + 'Feedback', + 'Typography', + 'Overlay', + 'Disclosure', + 'Navigation', + 'Media', + 'Other', + ], + locales: '', + }, + }, + docs: { + page: DocTemplate, + toc: { + disable: false, + }, + docs: { + source: { + format: true, + }, + }, + }, + + // Reconfig the premade badges with better titles + badgesConfig: { + stable: { + ...defaultBadgesConfig[BADGE.STABLE], + title: 'Stable', + tooltip: 'This component is stable but may have frequent changes. Use at own discretion.', + }, + productionReady: { + ...defaultBadgesConfig[BADGE.STABLE], + title: 'Production Ready', + tooltip: 'This component is production ready and has been tested in a production environment.', + }, + WIP: { + ...defaultBadgesConfig[BADGE.BETA], + title: 'WIP', + tooltip: 'This component is a work in progress and may not be fully functional or tested.', + }, + readyForDesignReview: { + ...defaultBadgesConfig[BADGE.NEEDS_REVISION], + title: 'Ready for Design Review', + tooltip: 'This component is ready for design review and feedback.', + }, + }, + }, +}; + +export default preview; diff --git a/datahub-web-react/.storybook/storybook-logo.svg b/datahub-web-react/.storybook/storybook-logo.svg new file mode 100644 index 00000000000000..5cc86813b59336 --- /dev/null +++ b/datahub-web-react/.storybook/storybook-logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.71 125.19"><defs><style>.cls-1{fill:#08303a;}.cls-2{fill:#11696b;}.cls-3{fill:#20d3bd;}</style></defs><g id="artwork"><path class="cls-1" d="M96.39,34.23,79.87,11.08a26.43,26.43,0,0,0-43,0L20.32,34.23A26.42,26.42,0,0,0,41.83,76h33A26.42,26.42,0,0,0,96.39,34.23ZM74.87,68h-33a18.42,18.42,0,0,1-15-29.12L43.35,15.72a18.43,18.43,0,0,1,30,0L89.87,38.88A18.42,18.42,0,0,1,74.87,68Z"/><path class="cls-2" d="M105.89,72.32,73,26.24a18,18,0,0,0-29.31,0L10.82,72.32a18,18,0,0,0,14.65,28.46H91.24a18,18,0,0,0,14.65-28.46ZM91.24,92.78H25.47A10,10,0,0,1,17.33,77L50.21,30.88a10,10,0,0,1,16.28,0L99.38,77A10,10,0,0,1,91.24,92.78Z"/><path class="cls-3" d="M114.83,109.26,66.56,41.61a10.07,10.07,0,0,0-16.41,0L1.88,109.26a10.08,10.08,0,0,0,8.2,15.93h96.55a10.08,10.08,0,0,0,8.2-15.93Zm-8.2,7.93H10.08a2.08,2.08,0,0,1-1.69-3.29L56.66,46.25a2.08,2.08,0,0,1,1.69-.87,2.05,2.05,0,0,1,1.69.87l48.28,67.65A2.08,2.08,0,0,1,106.63,117.19Z"/></g></svg> \ No newline at end of file diff --git a/datahub-web-react/.storybook/storybook-theme.css b/datahub-web-react/.storybook/storybook-theme.css new file mode 100644 index 00000000000000..edf93c57cf2086 --- /dev/null +++ b/datahub-web-react/.storybook/storybook-theme.css @@ -0,0 +1,263 @@ +/* Storybook Theme CSS Overrides */ + +/* Regular */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf') format('truetype'); +} + +/* Medium */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf') format('truetype'); +} + +/* SemiBold */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf') format('truetype'); +} + +/* Bold */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf') format('truetype'); +} + +body { + font-family: 'Mulish', sans-serif !important; +} + +::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +*::-webkit-scrollbar-track { + background: rgba(193, 196, 208, 0); + border-radius: 10px; +} + +*::-webkit-scrollbar-thumb { + background: rgba(193, 196, 208, 0); + border-radius: 10px; + transition: 0.3s; +} + +*:hover::-webkit-scrollbar-track { + background: rgba(193, 196, 208, 0.3); +} + +*:hover::-webkit-scrollbar-thumb { + background: rgba(193, 196, 208, 0.8); +} + +.sbdocs-wrapper { + max-width: 95% !important; +} + +.sidebar-header img { + max-height: 25px !important; +} + +.sb-bar { + box-shadow: none !important; + border-bottom: 1px solid hsla(203, 50%, 30%, 0.15) !important; +} + +.sbdocs-preview, +.docblock-argstable-body, +.docblock-source { + box-shadow: none !important; + filter: none !important; +} + +.docblock-source { + max-width: 100% !important; + overflow: auto !important; + margin: 1rem 0 !important; +} + +.sidebar-item, +.sidebar-item[data-selected="true"] { + height: 32px !important; + display: flex !important; + align-items: center !important; + padding-right: 0 !important; + padding: 6px 12px !important; + font-size: 15px !important; + margin-bottom: 4px !important; + color: #000 !important; +} + +.sidebar-item:hover { + background-color: #eff8fc !important; +} + +.sidebar-item>a { + align-items: center !important; + gap: 8px !important; + padding: 0 !important; +} + +.sidebar-item[data-nodetype="group"] { + margin-top: 8px !important; +} + +.sidebar-item[data-nodetype="component"] { + padding-left: 8px !important; +} + +[data-nodetype="root"]>[data-action="collapse-root"]>div:first-child, +[data-nodetype="component"] div { + display: none !important; +} + +[data-nodetype="document"][data-parent-id], +[data-nodetype="story"][data-parent-id] { + padding: 0 !important; + margin-left: 16px !important; + height: 18px !important; + min-height: auto !important; + font-weight: 400 !important; +} + +[data-nodetype="document"][data-parent-id] svg, +[data-nodetype="story"][data-parent-id] svg { + display: none !important; +} + +[data-nodetype="document"][data-parent-id]::before, +[data-nodetype="story"][data-parent-id]::before { + content: '→' !important; +} + +[data-nodetype="document"][data-parent-id]:hover, +[data-nodetype="story"][data-parent-id]:hover, +[data-nodetype="document"][data-parent-id][data-selected="true"]:hover, +[data-nodetype="story"][data-parent-id][data-selected="true"]:hover { + background-color: #fff !important; + color: #4da1bf !important; +} + +[data-nodetype="document"][data-parent-id][data-selected="true"], +[data-nodetype="story"][data-parent-id][data-selected="true"] { + background-color: #fff !important; + height: 18px !important; + min-height: auto !important; + font-weight: 400 !important; +} + +.sbdocs-content div[id*=--sandbox]~div[id*=--sandbox]~div[id*=--sandbox], +li:has(a[href="#sandbox"]) { + display: none !important; +} + +[data-nodetype="document"]:not([data-parent-id]) { + padding-left: 0 !important; +} + +[data-nodetype="document"]:not([data-parent-id]) svg { + display: none !important; +} + +[data-nodetype="document"]:not([data-parent-id])>a { + font-size: 18px !important; + font-weight: 300 !important; +} + +[data-nodetype="component"][aria-expanded="true"], +[data-nodetype="document"][data-selected="true"] { + color: #000 !important; + background-color: transparent !important; + font-weight: 700 !important; +} + +[data-nodetype="root"][data-selected="true"] { + background-color: transparent !important; +} + +[data-nodetype="document"][data-selected="true"], +[data-nodetype="document"][data-parent-id][data-selected="true"] { + color: #4da1bf !important; +} + +.sidebar-subheading { + font-size: 12px !important; + font-weight: 600 !important; + letter-spacing: 1px !important; + color: #a9adbd !important; +} + +.sbdocs-wrapper { + padding: 2rem !important; +} + +table, +tr, +tbody>tr>* { + border-color: hsla(203, 50%, 30%, 0.15) !important; + background-color: transparent; +} + +:where(table:not(.sb-anchor, .sb-unstyled, .sb-unstyled table)) tr:nth-of-type(2n) { + background-color: transparent !important; +} + +tr { + border-top: 0 !important; +} + +th { + border: 0 !important; +} + +h2#stories { + display: none; +} + +.tabbutton { + border-bottom: none !important +} + +.tabbutton.tabbutton-active { + color: rgb(120, 201, 230) !important; +} + +.toc-wrapper { + margin-top: -2.5rem !important; + font-family: 'Mulish', sans-serif !important; +} + +/* Custom Doc Styles */ + +.custom-docs { + position: relative; +} + +.acrylBg { + position: fixed; + bottom: 0; + left: -20px; + background-repeat: repeat; + z-index: 0; +} + +.acrylBg img { + filter: invert(8); +} + +.custom-docs p, +.docsDescription p, +.custom-docs li { + font-size: 16px; + line-height: 1.75; +} \ No newline at end of file diff --git a/datahub-web-react/.storybook/storybook-theme.js b/datahub-web-react/.storybook/storybook-theme.js new file mode 100644 index 00000000000000..462bf2f03da944 --- /dev/null +++ b/datahub-web-react/.storybook/storybook-theme.js @@ -0,0 +1,47 @@ +import { create } from '@storybook/theming'; +import brandImage from './storybook-logo.svg'; + +import theme, { typography } from '../src/alchemy-components/theme'; + +export default create({ + // config + base: 'light', + brandTitle: 'Acryl Design System', + brandUrl: '/?path=/docs/', + brandImage: brandImage, + brandTarget: '_self', + + // styles + fontBase: typography.fontFamily, + fontCode: 'monospace', + + colorPrimary: theme.semanticTokens.colors.primary, + colorSecondary: theme.semanticTokens.colors.secondary, + + // UI + appBg: theme.semanticTokens.colors['body-bg'], + appContentBg: theme.semanticTokens.colors['body-bg'], + appPreviewBg: theme.semanticTokens.colors['body-bg'], + appBorderColor: theme.semanticTokens.colors['border-color'], + appBorderRadius: 4, + + // Text colors + textColor: theme.semanticTokens.colors['body-text'], + textInverseColor: theme.semanticTokens.colors['inverse-text'], + textMutedColor: theme.semanticTokens.colors['subtle-text'], + + // Toolbar default and active colors + barTextColor: theme.semanticTokens.colors['body-text'], + barSelectedColor: theme.semanticTokens.colors['subtle-bg'], + barHoverColor: theme.semanticTokens.colors['subtle-bg'], + barBg: theme.semanticTokens.colors['body-bg'], + + // Form colors + inputBg: theme.semanticTokens.colors['body-bg'], + inputBorder: theme.semanticTokens.colors['border-color'], + inputTextColor: theme.semanticTokens.colors['body-text'], + inputBorderRadius: 4, + + // Grid + gridCellSize: 6, +}); \ No newline at end of file diff --git a/datahub-web-react/.storybook/styledComponents.ts b/datahub-web-react/.storybook/styledComponents.ts new file mode 100644 index 00000000000000..5951c810d89985 --- /dev/null +++ b/datahub-web-react/.storybook/styledComponents.ts @@ -0,0 +1,36 @@ +import { createGlobalStyle } from 'styled-components'; + +import '../src/fonts/Mulish-Regular.ttf'; +import '../src/fonts/Mulish-Medium.ttf'; +import '../src/fonts/Mulish-SemiBold.ttf'; +import '../src/fonts/Mulish-Bold.ttf'; + +export const GlobalStyle = createGlobalStyle` + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf) format('truetype'); + } + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf) format('truetype'); + } + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf) format('truetype'); + } + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf) format('truetype'); + } + body { + font-family: 'Mulish', sans-serif; + } +`; \ No newline at end of file diff --git a/datahub-web-react/.storybook/webpack.config.js b/datahub-web-react/.storybook/webpack.config.js new file mode 100644 index 00000000000000..22e4ec1de63050 --- /dev/null +++ b/datahub-web-react/.storybook/webpack.config.js @@ -0,0 +1,13 @@ +const path = require('path'); + +module.exports = { + module: { + loaders: [ + { + test: /\.(png|woff|woff2|eot|ttf|svg)$/, + loaders: ['file-loader'], + include: path.resolve(__dirname, '../'), + }, + ], + }, +}; \ No newline at end of file diff --git a/datahub-web-react/package.json b/datahub-web-react/package.json index dcaef6004d7022..31c10804482f0c 100644 --- a/datahub-web-react/package.json +++ b/datahub-web-react/package.json @@ -9,8 +9,12 @@ "@ant-design/colors": "^5.0.0", "@ant-design/icons": "^4.3.0", "@apollo/client": "^3.3.19", + "@fontsource/mulish": "^5.0.16", + "@geometricpanda/storybook-addon-badges": "^2.0.2", "@graphql-codegen/fragment-matcher": "^5.0.0", "@monaco-editor/react": "^4.3.1", + "@mui/icons-material": "^5.15.21", + "@mui/material": "^5.15.21", "@react-hook/window-size": "^3.0.7", "@react-spring/web": "^9.7.3", "@remirror/pm": "^2.0.3", @@ -30,6 +34,7 @@ "@uiw/react-md-editor": "^3.3.4", "@visx/axis": "^3.1.0", "@visx/curve": "^3.0.0", + "@visx/gradient": "^3.3.0", "@visx/group": "^3.0.0", "@visx/hierarchy": "^3.0.0", "@visx/legend": "^3.2.0", @@ -93,7 +98,9 @@ "format-check": "prettier --check src", "format": "prettier --write src", "type-check": "tsc --noEmit", - "type-watch": "tsc -w --noEmit" + "type-watch": "tsc -w --noEmit", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "browserslist": { "production": [ @@ -112,6 +119,16 @@ "@graphql-codegen/near-operation-file-preset": "^1.17.13", "@graphql-codegen/typescript-operations": "1.17.13", "@graphql-codegen/typescript-react-apollo": "2.2.1", + "@storybook/addon-essentials": "^8.1.11", + "@storybook/addon-interactions": "^8.1.11", + "@storybook/addon-links": "^8.1.11", + "@storybook/addon-onboarding": "^8.1.11", + "@storybook/blocks": "^8.1.11", + "@storybook/builder-vite": "^8.1.11", + "@storybook/manager-api": "^8.1.11", + "@storybook/react-vite": "^8.1.11", + "@storybook/test": "^8.1.11", + "@storybook/theming": "^8.1.11", "@types/graphql": "^14.5.0", "@types/query-string": "^6.3.0", "@types/styled-components": "^5.1.7", @@ -132,6 +149,7 @@ "less": "^4.2.0", "prettier": "^2.8.8", "source-map-explorer": "^2.5.2", + "storybook": "^8.1.11", "vite": "^4.5.5", "vite-plugin-babel-macros": "^1.0.6", "vite-plugin-static-copy": "^0.17.0", diff --git a/datahub-web-react/src/alchemy-components/.docs/Contributing.mdx b/datahub-web-react/src/alchemy-components/.docs/Contributing.mdx new file mode 100644 index 00000000000000..75a31d011903f8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/Contributing.mdx @@ -0,0 +1,43 @@ +import { Meta } from '@storybook/blocks'; + +<Meta title="Contributing" /> + +<div className="custom-docs"> + ## Contributing + + Building and maintinging a design system is a collaborative effort. We welcome contributions from all team members, regardless of their role or experience level. This document outlines the process for contributing to the Acryl Component Library. + + ### Development + + To run Storybook locally, use the following command: + + ``` + yarn storybook + ``` + + Storybook will start a local development server and open a new browser window with the Storybook interface on port `6006`. When developing new components or updating existing ones, you can use Storybook to preview your changes in real-time. This will ensure that the component looks and behaves as expected before merging your changes. + + ### Crafting New Components + + When creating new components, make sure to follow the established design patterns and coding standards. This will help maintain consistency across all Acryl products and make it easier for other team members to understand and use your components. + + Design new components with <strong>reusability in mind</strong>. Components should be flexible, extensible, and easy to customize. Avoid hardcoding values and use props to pass data and styles to your components. This will make it easier to reuse the component in different contexts and scenarios. + + Our design team works exclusively in Figma, so if questions arise about the design or implementation of a component, please refer to the Figma files for more information. If you have any questions or need clarification, feel free to reach out to the design team for assistance. + + ### Pull Requests + + When submitting a pull request, please follow these guidelines: + + 1. Create a new branch for your changes. + 2. Make sure your code is well-documented and follows the established coding standards. + 3. Write clear and concise commit messages. + 4. Include a detailed description of the changes in your pull request. + + If applicable, include screenshots or GIFs to demonstrate the changes visually. This will help reviewers understand the context of your changes and provide more accurate feedback. If a Figma file exists, include a link to the file in the pull request description. + + ### Review Process + + All pull requests will be reviewed by the UI and design team to ensure that the changes align with the design system guidelines and best practices. The team will provide feedback and suggestions for improvement, and you may be asked to make additional changes before your pull request is merged. + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx b/datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx new file mode 100644 index 00000000000000..0ebdebbf9db4cb --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx @@ -0,0 +1,63 @@ +import { Meta, Source } from '@storybook/blocks'; + +import theme from '@components/theme'; + +import { ColorCard, CopyButton } from './mdx-components'; + +<Meta title="Design Tokens" /> + +<div className="custom-docs"> + ## Design Tokens + + To streamline the design process and ensure consistency across all Acryl products, we use a set of design tokens that define the visual properties of our design system. These tokens include colors, typography, spacing, and other visual elements that can be used to create a cohesive user experience. + + ### Colors + + ```tsx + import theme from '@components/theme'; + + // Accessing a color via object path + <div style={{ color: theme.semanticTokens.colors.primary }}>Hello, World!</div> + + // Using CSS variables + <div style={{ color: 'var(--alch-color-primary)' }}>Hello, World!</div> + ``` + + <table style={{ width: '100%' }}> + <thead style={{ textAlign: 'left' }}> + <tr> + <th>Token Value</th> + <th>Selector</th> + <th>CSS Variable <small>(coming soon)</small></th> + </tr> + </thead> + <tbody> + {Object.keys(theme.semanticTokens.colors).map((color) => { + const objectKey = `colors['${color}']`; + const hexValue = theme.semanticTokens.colors[color]; + const cssVar = `--alch-color-${color}`; + + return ( + <tr key={color}> + <td> + <ColorCard color={hexValue} size="sm"> + <span className="colorChip" /> + <div> + <span className="colorValue">{color}</span> + <span className="hex">{hexValue}</span> + </div> + </ColorCard> + </td> + <td> + <span style={{ display: 'flex', alignItems: 'center', fontSize: 'inherit' }}> + {objectKey} <CopyButton text={objectKey} /> + </span> + </td> + <td>{cssVar}</td> + </tr> + ); + })} + </tbody> + </table> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/Icons.mdx b/datahub-web-react/src/alchemy-components/.docs/Icons.mdx new file mode 100644 index 00000000000000..e3f6ab68461196 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/Icons.mdx @@ -0,0 +1,34 @@ +import { Meta, Source } from '@storybook/blocks'; + +import { AVAILABLE_ICONS } from '@components'; +import { IconGalleryWithSearch } from './mdx-components'; + +<Meta title="Icons" /> + +<div className="custom-docs"> + ## Icons + + Under the hood, we're utilizing the Material Design Icon Library. However, we've crafted out own resuable component to make it easier to use these icons in our application. + + <a href="/?path=/docs/media-icon--docs"> + View the component documentation to learn more + </a> + + In addition to using Materials Design Icons, we've also added a few custom icons to the library. You can access them through the same `<Icon />` component and are represented in the list of available options below. + + ```tsx + import { Icon } from '@components'; + + <Icon icon="AccountCircle" /> + ``` + + <br /> + + ### Gallery + + There are {AVAILABLE_ICONS.length} icons available. <br /> + Name values populate the `icon` prop on the `<Icon />` component. + + <IconGalleryWithSearch icons={AVAILABLE_ICONS} /> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/Intro.mdx b/datahub-web-react/src/alchemy-components/.docs/Intro.mdx new file mode 100644 index 00000000000000..f81d08059c7b44 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/Intro.mdx @@ -0,0 +1,14 @@ +import { Meta, Description } from '@storybook/blocks'; +import ReadMe from '../README.mdx'; + +<Meta title="Introduction" /> + +<div className="custom-docs"> + <div className="acrylBg"> + <img src="https://www.acryldata.io/images/logo-pattern.svg" alt="Acryl Logo" /> + </div> + + {/* To simply, we're rendering the root readme here */} + <ReadMe /> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx b/datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx new file mode 100644 index 00000000000000..43199cbbca62d1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx @@ -0,0 +1,209 @@ +import { Meta, Source } from '@storybook/blocks'; + +import { Heading } from '@components'; +import { colors } from '@components/theme'; + +import { Grid, FlexGrid, ColorCard, CopyButton, Seperator } from './mdx-components'; + +import borderSource from '@components/theme/foundations/borders?raw'; +import colorsSource from '@components/theme/foundations/colors?raw'; +import typographySource from '@components/theme/foundations/typography?raw'; +import radiusSource from '@components/theme/foundations/radius?raw'; +import shadowsSource from '@components/theme/foundations/shadows?raw'; +import sizesSource from '@components/theme/foundations/sizes?raw'; +import spacingSource from '@components/theme/foundations/spacing?raw'; +import transitionSource from '@components/theme/foundations/transition?raw'; +import zIndexSource from '@components/theme/foundations/zIndex?raw'; + +<Meta title="Style Guide" /> + +<div className="custom-docs"> + ## Style Guide + + The purpose of this Style Guide is to establish a unified and cohesive design language that ensures a consistent user experience across all Acryl products. By adhering to these guidelines, we can maintain a high standard of design quality and improve the usability of our applications. + + ### Theme + + You can import the theme object into any component or file in your application and use it to style your components. The theme object is a single source of truth for your application's design system. + + ```tsx + import { typography, colors, spacing } from '@components/theme'; + ``` + + ### Colors + + Colors are managed via the `colors.ts` file in the `theme/foundations` directory. The colors are defined as a nested object with the following structure: + + <Source code={colorsSource} /> + + By default, all `500` values are considered the "default" value of that color range. For example, `gray.500` is the default gray color. The other values are used for shading and highlighting. Color values are defined in hex format and their values range between 25 and 1000. With 25 being the lighest and 1000 being the darkest. + + #### Black & White + <FlexGrid> + <ColorCard color={colors['black']}> + <span className="colorChip" /> + <div> + <span className="colorValue">Black</span> + <span className="hex">{colors['black']}</span> + </div> + </ColorCard> + <ColorCard color={colors['white']}> + <span className="colorChip" /> + <div> + <span className="colorValue">White</span> + <span className="hex">{colors['white']}</span> + </div> + </ColorCard> + </FlexGrid> + + <Seperator /> + + #### Gray + <Grid> + {Object.keys(colors.gray).map((color) => ( + <ColorCard key={color} color={colors['gray'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Gray {color} <CopyButton text={`gray.${color}`} /> + </span> + <span className="hex">{colors['gray'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Violet (Primary) + <Grid> + {Object.keys(colors.violet).map((color) => ( + <ColorCard key={color} color={colors['violet'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Violet {color} <CopyButton text={`violet.${color}`} /> + </span> + <span className="hex">{colors['violet'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Blue + <Grid> + {Object.keys(colors.blue).map((color) => ( + <ColorCard key={color} color={colors['blue'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Blue {color} <CopyButton text={`blue.${color}`} /> + </span> + <span className="hex">{colors['blue'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Green + <Grid> + {Object.keys(colors.green).map((color) => ( + <ColorCard key={color} color={colors['green'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Green {color} <CopyButton text={`green.${color}`} /> + </span> + <span className="hex">{colors['green'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Yellow + <Grid> + {Object.keys(colors.yellow).map((color) => ( + <ColorCard key={color} color={colors['yellow'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Yellow {color} <CopyButton text={`yellow.${color}`} /> + </span> + <span className="hex">{colors['yellow'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Red + <Grid> + {Object.keys(colors.red).map((color) => ( + <ColorCard key={color} color={colors['red'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Red {color} <CopyButton text={`red.${color}`} /> + </span> + <span className="hex">{colors['red'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + ### Typography + + Font styles are managed via the `typography.ts` file in the `theme/foundations` directory. The primary font family in use is `Mulish`. The font styles are defined as a nested object with the following structure: + + <Source code={typographySource} /> + + ### Borders + + A set of border values defined by the border key. + + <Source code={borderSource} /> + + ### Border Radius + + A set smooth corner radius values defined by the radii key. + + <Source code={radiusSource} /> + + ### Shadows + + A set of shadow values defined by the shadows key. + + <Source code={shadowsSource} /> + + ## Sizes + + A set of size values defined by the sizes key. + + <Source code={sizesSource} /> + + ### Spacing + + A set of spacing values defined by the spacing key. + + <Source code={spacingSource} /> + + ### Transitions + + A set of transition values defined by the transition key. + + <Source code={transitionSource} /> + + ### Z-Index + + A set of z-index values defined by the zindex key. + + <Source code={zIndexSource} /> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx new file mode 100644 index 00000000000000..43b9ebfae64149 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Source, DocsContext } from '@storybook/blocks'; + +export const CodeBlock = () => { + const context = React.useContext(DocsContext); + + const { primaryStory } = context as any; + const component = context ? primaryStory.component.__docgenInfo.displayName : ''; + + if (!context || !primaryStory) return null; + + return ( + <div> + <Source + code={` + import { ${component} } from '@components'; + `} + format + dark + /> + </div> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx new file mode 100644 index 00000000000000..c81aa6ed442892 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Button, Icon } from '@components'; +import { copyToClipboard } from './utils'; + +interface Props { + text: string; +} + +export const CopyButton = ({ text }: Props) => ( + <div style={{ display: 'inline-block' }}> + <Button variant="text" color="gray" size="sm" onClick={() => copyToClipboard(text)}> + <Icon icon="ContentCopy" size="xs" /> + </Button> + </div> +); diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx new file mode 100644 index 00000000000000..5cb4bd27e521a4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx @@ -0,0 +1,32 @@ +/* + Docs Only Component that helps to display a list of components in a grid layout. +*/ + +import React, { ReactNode } from 'react'; + +const styles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', +}; + +interface Props { + isVertical?: boolean; + width?: number | string; + children: ReactNode; +} + +export const GridList = ({ isVertical = false, width = '100%', children }: Props) => { + return ( + <div + style={{ + ...styles, + width, + flexDirection: isVertical ? 'column' : 'row', + }} + > + {children} + </div> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx new file mode 100644 index 00000000000000..d8751509bd6a72 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx @@ -0,0 +1,291 @@ +import React, { useState } from 'react'; + +import { Icon, Button, ButtonProps } from '@components'; +import { IconGrid, IconGridItem, IconDisplayBlock } from './components'; + +interface Props { + icons: string[]; +} + +export const IconGalleryWithSearch = ({ icons }: Props) => { + const [iconSet, setIconSet] = useState(icons); + const [search, setSearch] = useState(''); + const [variant, setVariant] = useState('outline'); + + const filteredIcons = iconSet.filter((icon) => icon.toLowerCase().includes(search.toLowerCase())); + + const arrows = [ + 'ArrowBack', + 'ArrowCircleDown', + 'ArrowCircleLeft', + 'ArrowCircleRight', + 'ArrowCircleUp', + 'ArrowDownward', + 'ArrowForward', + 'ArrowOutward', + 'ArrowUpward', + 'CloseFullscreen', + 'Cached', + 'Code', + 'CodeOff', + 'CompareArrows', + 'Compress', + 'ChevronLeft', + 'ChevronRight', + 'DoubleArrow', + 'FastForward', + 'FastRewind', + 'FileDownload', + 'FileUpload', + 'ForkLeft', + 'ForkRight', + 'GetApp', + 'LastPage', + 'Launch', + 'Login', + 'Logout', + 'LowPriority', + 'ManageHistory', + 'Merge', + 'MergeType', + 'MoveUp', + 'MultipleStop', + 'OpenInFull', + 'Outbound', + 'Outbox', + 'Output', + 'PlayArrow', + 'PlayCircle', + 'Publish', + 'ReadMore', + 'ExitToApp', + 'Redo', + 'Refresh', + 'Replay', + 'ReplyAll', + 'Reply', + 'Restore', + 'SaveAlt', + 'Shortcut', + 'SkipNext', + 'SkipPrevious', + 'Start', + 'Straight', + 'SubdirectoryArrowLeft', + 'SubdirectoryArrowRight', + 'SwapHoriz', + 'SwapVert', + 'SwitchLeft', + 'SwitchRight', + 'SyncAlt', + 'SyncDisabled', + 'SyncLock', + 'Sync', + 'Shuffle', + 'SyncProblem', + 'TrendingDown', + 'TrendingFlat', + 'TrendingUp', + 'TurnLeft', + 'TurnRight', + 'TurnSlightLeft', + 'TurnSlightRight', + 'Undo', + 'UnfoldLessDouble', + 'UnfoldLess', + 'UnfoldMoreDouble', + 'UnfoldMore', + 'UpdateDisabled', + 'Update', + 'Upgrade', + 'Upload', + 'ZoomInMap', + 'ZoomOutMap', + ]; + + const dataViz = [ + 'AccountTree', + 'Analytics', + 'ArtTrack', + 'Article', + 'BackupTable', + 'BarChart', + 'BubbleChart', + 'Calculate', + 'Equalizer', + 'List', + 'FormatListBulleted', + 'FormatListNumbered', + 'Grading', + 'InsertChart', + 'Hub', + 'Insights', + 'Lan', + 'Leaderboard', + 'LegendToggle', + 'Map', + 'MultilineChart', + 'Nat', + 'PivotTableChart', + 'Poll', + 'Polyline', + 'QueryStats', + 'Radar', + 'Route', + 'Rule', + 'Schema', + 'Sort', + 'SortByAlpha', + 'ShowChart', + 'Source', + 'SsidChart', + 'StackedBarChart', + 'StackedLineChart', + 'Storage', + 'TableChart', + 'TableRows', + 'TableView', + 'Timeline', + 'ViewAgenda', + 'ViewArray', + 'ViewCarousel', + 'ViewColumn', + 'ViewComfy', + 'ViewCompact', + 'ViewCozy', + 'ViewDay', + 'ViewHeadline', + 'ViewKanban', + 'ViewList', + 'ViewModule', + 'ViewQuilt', + 'ViewSidebar', + 'ViewStream', + 'ViewTimeline', + 'ViewWeek', + 'Visibility', + 'VisibilityOff', + 'Webhook', + 'Window', + ]; + + const social = [ + 'AccountCircle', + 'Badge', + 'Campaign', + 'Celebration', + 'Chat', + 'ChatBubble', + 'CommentBank', + 'Comment', + 'CommentsDisabled', + 'Message', + 'ContactPage', + 'Contacts', + 'GroupAdd', + 'Group', + 'GroupRemove', + 'Groups', + 'Handshake', + 'ManageAccounts', + 'MoodBad', + 'SentimentDissatisfied', + 'SentimentNeutral', + 'SentimentSatisfied', + 'Mood', + 'NoAccounts', + 'People', + 'PersonAddAlt1', + 'PersonOff', + 'Person', + 'PersonRemoveAlt1', + 'PersonSearch', + 'SwitchAccount', + 'StarBorder', + 'StarHalf', + 'Star', + 'ThumbDown', + 'ThumbUp', + 'ThumbsUpDown', + 'Verified', + 'VerifiedUser', + ]; + + const notifs = [ + 'Mail', + 'Drafts', + 'MarkAsUnread', + 'Inbox', + 'Outbox', + 'MoveToInbox', + 'Unsubscribe', + 'Upcoming', + 'NotificationAdd', + 'NotificationImportant', + 'NotificationsActive', + 'NotificationsOff', + 'Notifications', + 'NotificationsPaused', + ]; + + const handleChangeSet = (set) => { + setIconSet(set); + setSearch(''); + }; + + const handleResetSet = () => { + setIconSet(icons); + setSearch(''); + }; + + const smButtonProps: ButtonProps = { + size: 'sm', + color: 'gray', + }; + + return ( + <> + <input + type="search" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search for an icon…" + style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem' }} + /> + <div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}> + <div style={{ display: 'flex', gap: '8px' }}> + <Button onClick={handleResetSet} {...smButtonProps}> + All + </Button> + <Button onClick={() => handleChangeSet(arrows)} {...smButtonProps}> + Arrows + </Button> + <Button onClick={() => handleChangeSet(dataViz)} {...smButtonProps}> + Data Viz + </Button> + <Button onClick={() => handleChangeSet(social)} {...smButtonProps}> + Social + </Button> + <Button onClick={() => handleChangeSet(notifs)} {...smButtonProps}> + Notifications + </Button> + </div> + <div style={{ display: 'flex', gap: '8px' }}> + <Button onClick={() => setVariant(variant === 'outline' ? 'filled' : 'outline')} {...smButtonProps}> + Variant: {variant === 'filled' ? 'Filled' : 'Outline'} + </Button> + </div> + </div> + <IconGrid> + {filteredIcons.map((icon) => ( + <IconGridItem> + <IconDisplayBlock key={icon} title={icon}> + <Icon icon={icon} variant={variant as any} size="2xl" /> + </IconDisplayBlock> + <span>{icon}</span> + </IconGridItem> + ))} + </IconGrid> + </> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts b/datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts new file mode 100644 index 00000000000000..28d428493b17b2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts @@ -0,0 +1,110 @@ +/* + Docs Only Components that helps to display information in info guides. +*/ + +import styled from 'styled-components'; + +import theme from '@components/theme'; + +export const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +`; + +export const FlexGrid = styled.div` + display: flex; + gap: 16px; +`; + +export const VerticalFlexGrid = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const Seperator = styled.div` + height: 16px; +`; + +export const ColorCard = styled.div<{ color: string; size?: string }>` + display: flex; + gap: 16px; + align-items: center; + + ${({ size }) => + size === 'sm' && + ` + gap: 8px; + `} + + & span { + display: block; + line-height: 1.3; + } + + & .colorChip { + background: ${({ color }) => color}; + width: 3rem; + height: 3rem; + + ${({ size }) => + size === 'sm' && + ` + width: 2rem; + height: 2rem; + border-radius: 4px; + `} + + border-radius: 8px; + box-shadow: rgba(0, 0, 0, 0.06) 0px 2px 4px 0px inset; + } + + & .colorValue { + display: flex; + align-items: center; + gap: 0; + font-weight: bold; + font-size: 14px; + } + + & .hex { + font-size: 11px; + opacity: 0.5; + text-transform: uppercase; + } +`; + +export const IconGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 16px; + margin-top: 20px; +`; + +export const IconGridItem = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + + border: 1px solid ${theme.semanticTokens.colors['border-color']}; + border-radius: 8px; + overflow: hidden; + + & span { + width: 100%; + border-top: 1px solid ${theme.semanticTokens.colors['border-color']}; + background-color: ${theme.semanticTokens.colors['subtle-bg']}; + text-align: center; + padding: 4px 8px; + font-size: 10px; + } +`; + +export const IconDisplayBlock = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 50px; +`; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts b/datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts new file mode 100644 index 00000000000000..d1c1848d1eb378 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts @@ -0,0 +1,6 @@ +export * from './CodeBlock'; +export * from './CopyButton'; +export * from './GridList'; +export * from './IconGalleryWithSearch'; +export * from './components'; +export * from './utils'; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts b/datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts new file mode 100644 index 00000000000000..d4fa47dc9e9674 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts @@ -0,0 +1,15 @@ +/* + Docs related utils +*/ + +/** + * Copies the given text to the clipboard. + * @param {string} text - The text to be copied to the clipboard. + * @returns {Promise<void>} A promise that resolves when the text is copied. + */ +export const copyToClipboard = (text: string) => { + return navigator.clipboard + .writeText(text) + .then(() => console.log(`${text} copied to clipboard`)) + .catch(); +}; diff --git a/datahub-web-react/src/alchemy-components/README.mdx b/datahub-web-react/src/alchemy-components/README.mdx new file mode 100644 index 00000000000000..5373432c0ede03 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/README.mdx @@ -0,0 +1,73 @@ +# Alchemy Component Library + +This is a comprehensive library of accessible and reusable React components that streamlines the development of Acryl's applications and websites. The library offers a diverse range of components that can be easily combined to build complex user interfaces while adhering to accessibility best practices. + +### Component Usage + +It's easy to use the components availble in the library. Simply import the component and use it anywhere you're rendering React components. + +```tsx +import { Button } from '@components'; + +function YourComponent() { + return <Button>Click me!</Button>; +} +``` + +In addition to the components themselves, you can also import their types: + +```tsx +import type { ButtonProps } from '@components'; +``` + +### Theme Usage + +This component library comes with a complete theme utility that pre-defines all of our styling atoms and makes them accessible at `@components/theme`. + +```tsx +import { colors } from '@components/theme'; + +function YourComponent() { + return ( + <div style={{ bgColor: colors.green.400 }}> + This div has a green background! + </div> + ) +} +``` + +You can access the theme types at `@components/theme/types` and the theme config at `@components/theme/config`. + +### Writing Docs + +Our docs are generated using [Storybook](https://storybook.js.org/) and deployed to [Cloudfare](https://www.cloudflare.com/). + +- Storybook config is located at `.storybook` +- Static doc files are located at `alchemy-components/.docs` +- Component stories are located in each component directory: <br/>`alchemy-components/components/Component/Component.stories.tsx` + +Storybook serves as our playground for developing components. You can start it locally: + +```bash +yarn storybook +``` + +This launches the docs app at `localhost:6006` and enables everything you need to quickly develop and document components. + +### Contributing + +Building a component library is a collaboriate effort! We're aiming to provide a first-class experience, so here's a list of the standards we'll be looking for: + +- Consitent prop and variant naming conventions: <br /> + -- `variant` is used to define style types, such as `outline` or `filled`. <br /> + -- `color` is used to define the components color, such as `violet` or `blue`. <br /> + -- `size` is used to define the components size, such as `xs` or `4xl`. <br /> + -- Booleans are prefixed with `is`: `isLoading` or `isDisabled`. +- All style props have a correseponding theme type, ie. `FontSizeOptions`. +- All components have an export of default props. +- Styles are defined using `style objects` instead of `tagged template literals`. +- Stories are organized into the correct directory . + +### FAQs + +- **How are components being styled?** <br />Our components are built using [Styled Components](https://styled-components.com/) that dynamically generate styles based on variant selection. diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx new file mode 100644 index 00000000000000..09d0d37f15421a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,133 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { GridList } from '@src/alchemy-components/.docs/mdx-components'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Avatar, avatarDefaults } from './Avatar'; + +const IMAGE_URL = + 'https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/78/cb/e1/78cbe16d-28d9-057e-9f73-524c32eb5fe5/AppIcon-0-0-1x_U007emarketing-0-7-0-85-220.png/512x512bb.jpg'; + +// Auto Docs +const meta = { + title: 'Components / Avatar', + component: Avatar, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'This component allows users to render a user pill with picture and name', + }, + }, + + // Component-level argTypes + argTypes: { + name: { + description: 'Name of the user.', + table: { + defaultValue: { summary: `${avatarDefaults.name}` }, + }, + control: 'text', + }, + imageUrl: { + description: 'URL of the user image.', + control: 'text', + }, + onClick: { + description: 'On click function for the Avatar.', + }, + size: { + description: 'Size of the Avatar.', + table: { + defaultValue: { summary: `${avatarDefaults.size}` }, + }, + control: 'select', + }, + showInPill: { + description: 'Whether Avatar is shown in pill format with name.', + table: { + defaultValue: { summary: `${avatarDefaults.showInPill}` }, + }, + control: 'boolean', + }, + + isOutlined: { + description: 'Whether Avatar is outlined.', + table: { + defaultValue: { summary: `${avatarDefaults.isOutlined}` }, + }, + control: 'boolean', + }, + }, + + // Define defaults + args: { + name: 'John Doe', + size: 'default', + showInPill: false, + isOutlined: false, + }, +} satisfies Meta<typeof Avatar>; + +export default meta; + +// Stories + +type Story = StoryObj<typeof meta>; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Avatar {...props} />, +}; + +export const sizes = () => ( + <GridList> + <Avatar name="John Doe" size="lg" /> + <Avatar name="John Doe" size="md" /> + <Avatar name="John Doe" size="default" /> + <Avatar name="John Doe" size="sm" /> + </GridList> +); + +export const withImage = () => ( + <GridList> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="lg" /> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="md" /> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="default" /> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="sm" /> + </GridList> +); + +export const pills = () => ( + <GridList isVertical> + <GridList> + <Avatar name="John Doe" size="lg" showInPill /> + <Avatar name="John Doe" size="md" showInPill /> + <Avatar name="John Doe" size="default" showInPill /> + <Avatar name="John Doe" size="sm" showInPill /> + </GridList> + <GridList> + <Avatar name="John Doe" size="lg" imageUrl={IMAGE_URL} showInPill /> + <Avatar name="John Doe" size="md" imageUrl={IMAGE_URL} showInPill /> + <Avatar name="John Doe" size="default" imageUrl={IMAGE_URL} showInPill /> + <Avatar name="John Doe" size="sm" imageUrl={IMAGE_URL} showInPill /> + </GridList> + </GridList> +); + +export const outlined = () => ( + <GridList> + <Avatar name="John Doe" size="lg" imageUrl={IMAGE_URL} isOutlined /> + <Avatar name="John Doe" size="lg" showInPill imageUrl={IMAGE_URL} isOutlined /> + </GridList> +); + +export const withOnClick = () => ( + <GridList> + <Avatar name="John Doe" onClick={() => window.alert('Avatar clicked')} /> + <Avatar name="John Doe" onClick={() => window.alert('Avatar clicked')} showInPill /> + </GridList> +); diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx new file mode 100644 index 00000000000000..9e5ec025e08e3d --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { AvatarImage, AvatarImageWrapper, AvatarText, Container } from './components'; +import { AvatarProps } from './types'; +import getAvatarColor, { getNameInitials } from './utils'; + +export const avatarDefaults: AvatarProps = { + name: 'User name', + size: 'default', + showInPill: false, + isOutlined: false, +}; + +export const Avatar = ({ + name = avatarDefaults.name, + imageUrl, + size = avatarDefaults.size, + onClick, + showInPill = avatarDefaults.showInPill, + isOutlined = avatarDefaults.isOutlined, +}: AvatarProps) => { + const [hasError, setHasError] = useState(false); + + return ( + <Container onClick={onClick} $hasOnClick={!!onClick} $showInPill={showInPill}> + <AvatarImageWrapper + $color={getAvatarColor(name)} + $size={size} + $isOutlined={isOutlined} + $hasImage={!!imageUrl} + > + {!hasError && imageUrl ? ( + <AvatarImage src={imageUrl} onError={() => setHasError(true)} /> + ) : ( + <>{getNameInitials(name)} </> + )} + </AvatarImageWrapper> + {showInPill && <AvatarText $size={size}>{name}</AvatarText>} + </Container> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts b/datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts new file mode 100644 index 00000000000000..54bb258acb0d81 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts @@ -0,0 +1,34 @@ +import { getNameInitials } from '../utils'; + +describe('get initials of the name', () => { + it('get initials of name with first name and last name', () => { + expect(getNameInitials('John Doe ')).toEqual('JD'); + }); + it('get initials of name with first name and last name in lower case', () => { + expect(getNameInitials('john doe')).toEqual('JD'); + }); + it('get initials of name with only first name', () => { + expect(getNameInitials('Robert')).toEqual('RO'); + }); + it('get initials of name with only first name in lower case', () => { + expect(getNameInitials('robert')).toEqual('RO'); + }); + it('get initials of name with three names', () => { + expect(getNameInitials('James Edward Brown')).toEqual('JB'); + }); + it('get initials of name with four names', () => { + expect(getNameInitials('Michael James Alexander Scott')).toEqual('MS'); + }); + it('get initials of name with a hyphen', () => { + expect(getNameInitials('Mary-Jane Watson')).toEqual('MW'); + }); + it('get initials of name with an apostrophe', () => { + expect(getNameInitials("O'Connor")).toEqual('OC'); + }); + it('get initials of name with a single letter', () => { + expect(getNameInitials('J')).toEqual('J'); + }); + it('get initials of name with an empty string', () => { + expect(getNameInitials('')).toEqual(''); + }); +}); diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/components.ts b/datahub-web-react/src/alchemy-components/components/Avatar/components.ts new file mode 100644 index 00000000000000..bcd23a8ab086c9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/components.ts @@ -0,0 +1,51 @@ +import { colors } from '@src/alchemy-components/theme'; +import { AvatarSizeOptions } from '@src/alchemy-components/theme/config'; +import styled from 'styled-components'; +import { getAvatarColorStyles, getAvatarNameSizes, getAvatarSizes } from './utils'; + +export const Container = styled.div<{ $hasOnClick: boolean; $showInPill?: boolean }>` + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 20px; + border: ${(props) => props.$showInPill && `1px solid ${colors.gray[100]}`}; + padding: ${(props) => props.$showInPill && '3px 6px 3px 4px'}; + + ${(props) => + props.$hasOnClick && + ` + :hover { + cursor: pointer; + } + `} +`; + +export const AvatarImageWrapper = styled.div<{ + $color: string; + $size?: AvatarSizeOptions; + $isOutlined?: boolean; + $hasImage?: boolean; +}>` + ${(props) => getAvatarSizes(props.$size)} + + border-radius: 50%; + color: ${(props) => props.$color}; + border: ${(props) => props.$isOutlined && `1px solid ${colors.gray[1800]}`}; + display: flex; + align-items: center; + justify-content: center; + ${(props) => !props.$hasImage && getAvatarColorStyles(props.$color)} +`; + +export const AvatarImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +`; + +export const AvatarText = styled.span<{ $size?: AvatarSizeOptions }>` + color: ${colors.gray[1700]}; + font-weight: 600; + font-size: ${(props) => getAvatarNameSizes(props.$size)}; +`; diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/index.ts b/datahub-web-react/src/alchemy-components/components/Avatar/index.ts new file mode 100644 index 00000000000000..d3fb6dfa7c09e1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/index.ts @@ -0,0 +1 @@ +export { Avatar } from './Avatar'; diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/types.ts b/datahub-web-react/src/alchemy-components/components/Avatar/types.ts new file mode 100644 index 00000000000000..98c554b620dcbd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/types.ts @@ -0,0 +1,10 @@ +import { AvatarSizeOptions } from '@src/alchemy-components/theme/config'; + +export interface AvatarProps { + name: string; + imageUrl?: string; + onClick?: () => void; + size?: AvatarSizeOptions; + showInPill?: boolean; + isOutlined?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/utils.ts b/datahub-web-react/src/alchemy-components/components/Avatar/utils.ts new file mode 100644 index 00000000000000..46b2ee25488b89 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/utils.ts @@ -0,0 +1,64 @@ +import { colors } from '@src/alchemy-components/theme'; + +export const getNameInitials = (userName: string) => { + if (!userName) return ''; + const names = userName.trim().split(/[\s']+/); // Split by spaces or apostrophes + if (names.length === 1) { + const firstName = names[0]; + return firstName.length > 1 ? firstName[0]?.toUpperCase() + firstName[1]?.toUpperCase() : firstName[0]; + } + return names[0][0]?.toUpperCase() + names[names.length - 1][0]?.toUpperCase() || ''; +}; + +export function hashString(str: string) { + let hash = 0; + if (str.length === 0) { + return hash; + } + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + // eslint-disable-next-line + hash = (hash << 5) - hash + char; + // eslint-disable-next-line + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); +} + +const colorMap = { + [colors.violet[500]]: { backgroundColor: colors.gray[1000], border: `1px solid ${colors.violet[1000]}` }, + [colors.blue[1000]]: { backgroundColor: colors.gray[1100], border: `1px solid ${colors.blue[200]}` }, + [colors.gray[600]]: { backgroundColor: colors.gray[1500], border: `1px solid ${colors.gray[100]}` }, +}; + +const avatarColors = Object.keys(colorMap); + +export const getAvatarColorStyles = (color) => { + return { + ...colorMap[color], + }; +}; + +export default function getAvatarColor(name: string) { + return avatarColors[hashString(name) % avatarColors.length]; +} + +export const getAvatarSizes = (size) => { + const sizeMap = { + sm: { width: '18px', height: '18px', fontSize: '8px' }, + md: { width: '24px', height: '24px', fontSize: '12px' }, + lg: { width: '28px', height: '28px', fontSize: '14px' }, + default: { width: '20px', height: '20px', fontSize: '10px' }, + }; + + return { + ...sizeMap[size], + }; +}; + +export const getAvatarNameSizes = (size) => { + if (size === 'lg') return '16px'; + if (size === 'sm') return '10px'; + if (size === 'md') return '14px'; + return '12px'; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx b/datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx new file mode 100644 index 00000000000000..88d499226feafd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx @@ -0,0 +1,102 @@ +import React from 'react'; + +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { Badge, badgeDefault } from './Badge'; +import pillMeta from '../Pills/Pill.stories'; +import { omitKeys } from './utils'; + +const pillMetaArgTypes = omitKeys(pillMeta.argTypes, ['label']); +const pillMetaArgs = omitKeys(pillMeta.args, ['label']); + +const meta = { + title: 'Components / Badge', + component: Badge, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to get badge', + }, + }, + + // Component-level argTypes + argTypes: { + count: { + description: 'Count to show.', + table: { + defaultValue: { summary: `${badgeDefault.count}` }, + }, + control: { + type: 'number', + }, + }, + overflowCount: { + description: 'Max count to show.', + table: { + defaultValue: { summary: `${badgeDefault.overflowCount}` }, + }, + control: { + type: 'number', + }, + }, + showZero: { + description: 'Whether to show badge when `count` is zero.', + table: { + defaultValue: { summary: `${badgeDefault.showZero}` }, + }, + control: { + type: 'boolean', + }, + }, + ...pillMetaArgTypes, + }, + + // Define defaults + args: { + count: 100, + overflowCount: badgeDefault.overflowCount, + showZero: badgeDefault.showZero, + ...pillMetaArgs, + }, +} satisfies Meta<typeof Badge>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Badge {...props} />, +}; + +export const sizes = () => ( + <GridList> + <Badge count={100} /> + <Badge count={100} size="sm" /> + <Badge count={100} size="lg" /> + </GridList> +); + +export const colors = () => ( + <GridList> + <Badge count={100} /> + <Badge count={100} colorScheme="violet" /> + <Badge count={100} colorScheme="green" /> + <Badge count={100} colorScheme="red" /> + <Badge count={100} colorScheme="blue" /> + <Badge count={100} colorScheme="gray" /> + </GridList> +); + +export const withIcon = () => ( + <GridList> + <Badge count={100} leftIcon="AutoMode" /> + <Badge count={100} rightIcon="Close" /> + <Badge count={100} leftIcon="AutoMode" rightIcon="Close" /> + </GridList> +); diff --git a/datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx b/datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx new file mode 100644 index 00000000000000..1c934ef120eee8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx @@ -0,0 +1,29 @@ +import { Pill } from '@components'; +import React, { useMemo } from 'react'; + +import { BadgeProps } from './types'; +import { formatBadgeValue } from './utils'; +import { BadgeContainer } from './components'; + +export const badgeDefault: BadgeProps = { + count: 0, + overflowCount: 99, + showZero: false, +}; + +export function Badge({ + count = badgeDefault.count, + overflowCount = badgeDefault.overflowCount, + showZero = badgeDefault.showZero, + ...props +}: BadgeProps) { + const label = useMemo(() => formatBadgeValue(count, overflowCount), [count, overflowCount]); + + if (!showZero && count === 0) return null; + + return ( + <BadgeContainer title={`${count}`}> + <Pill label={label} {...props} /> + </BadgeContainer> + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/Badge/components.ts b/datahub-web-react/src/alchemy-components/components/Badge/components.ts new file mode 100644 index 00000000000000..a7791cd4f5ff88 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/components.ts @@ -0,0 +1,6 @@ +import styled from 'styled-components'; + +export const BadgeContainer = styled.div({ + // Base root styles + display: 'inline-flex', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Badge/index.ts b/datahub-web-react/src/alchemy-components/components/Badge/index.ts new file mode 100644 index 00000000000000..26a9e305c7ffd5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/index.ts @@ -0,0 +1 @@ +export { Badge } from './Badge'; diff --git a/datahub-web-react/src/alchemy-components/components/Badge/types.ts b/datahub-web-react/src/alchemy-components/components/Badge/types.ts new file mode 100644 index 00000000000000..21348f2a083419 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/types.ts @@ -0,0 +1,8 @@ +import { HTMLAttributes } from 'react'; +import { PillProps } from '../Pills/types'; + +export interface BadgeProps extends HTMLAttributes<HTMLElement>, Omit<PillProps, 'label'> { + count: number; + overflowCount?: number; + showZero?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/components/Badge/utils.ts b/datahub-web-react/src/alchemy-components/components/Badge/utils.ts new file mode 100644 index 00000000000000..e59ec2af998e74 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/utils.ts @@ -0,0 +1,15 @@ +export const formatBadgeValue = (value: number, overflowCount?: number): string => { + if (overflowCount === undefined || value < overflowCount) return String(value); + + return `${overflowCount}+`; +}; + +export function omitKeys<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> { + const { ...rest } = obj; + + keys.forEach((key) => { + delete rest[key]; + }); + + return rest; +} diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx new file mode 100644 index 00000000000000..1258ff398c0a7e --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import { BarChart } from './BarChart'; +import { getMockedProps } from './utils'; + +const meta = { + title: 'Charts / BarChart', + component: BarChart, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to show BarChart', + }, + }, + + // Component-level argTypes + argTypes: { + data: { + description: 'Array of datum to show', + }, + xAccessor: { + description: 'A function to convert datum to value of X', + }, + yAccessor: { + description: 'A function to convert datum to value of Y', + }, + renderTooltipContent: { + description: 'A function to replace default rendering of toolbar', + }, + margin: { + description: 'Add margins to chart', + }, + leftAxisTickFormat: { + description: 'A function to format labels of left axis', + }, + leftAxisTickLabelProps: { + description: 'Props for label of left axis', + }, + bottomAxisTickFormat: { + description: 'A function to format labels of bottom axis', + }, + bottomAxisTickLabelProps: { + description: 'Props for label of bottom axis', + }, + barColor: { + description: 'Color of bar', + control: { + type: 'color', + }, + }, + barSelectedColor: { + description: 'Color of selected bar', + control: { + type: 'color', + }, + }, + gridColor: { + description: "Color of grid's lines", + control: { + type: 'color', + }, + }, + renderGradients: { + description: 'A function to render different gradients that can be used as colors', + }, + }, + + // Define defaults + args: { + ...getMockedProps(), + renderTooltipContent: (datum) => <>DATUM: {JSON.stringify(datum)}</>, + }, +} satisfies Meta<typeof BarChart>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => ( + <div style={{ width: '900px', height: '350px' }}> + <BarChart {...props} /> + </div> + ), +}; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx new file mode 100644 index 00000000000000..eb5465a1d1217b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import { colors } from '@src/alchemy-components/theme'; +import { TickLabelProps } from '@visx/axis'; +import { LinearGradient } from '@visx/gradient'; +import { ParentSize } from '@visx/responsive'; +import { Axis, AxisScale, BarSeries, Grid, Tooltip, XYChart } from '@visx/xychart'; +import dayjs from 'dayjs'; +import { Popover } from '../Popover'; +import { ChartWrapper, StyledBarSeries } from './components'; +import { BarChartProps } from './types'; +import { abbreviateNumber } from '../dataviz/utils'; + +const commonTickLabelProps: TickLabelProps<any> = { + fontSize: 10, + fontFamily: 'Mulish', + fill: colors.gray[1700], +}; + +export const barChartDefault: BarChartProps<any> = { + data: [], + xAccessor: (datum) => datum?.x, + yAccessor: (datum) => datum?.y, + leftAxisTickFormat: abbreviateNumber, + leftAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'end', + }, + bottomAxisTickFormat: (value) => dayjs(value).format('DD MMM'), + bottomAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'middle', + verticalAnchor: 'start', + width: 20, + }, + barColor: 'url(#bar-gradient)', + barSelectedColor: colors.violet[500], + gridColor: '#e0e0e0', + renderGradients: () => <LinearGradient id="bar-gradient" from={colors.violet[500]} to="#917FFF" toOpacity={0.6} />, +}; + +export function BarChart<DatumType extends object = any>({ + data, + xAccessor = barChartDefault.xAccessor, + yAccessor = barChartDefault.yAccessor, + renderTooltipContent, + margin, + leftAxisTickFormat = barChartDefault.leftAxisTickFormat, + leftAxisTickLabelProps = barChartDefault.leftAxisTickLabelProps, + bottomAxisTickFormat = barChartDefault.bottomAxisTickFormat, + bottomAxisTickLabelProps = barChartDefault.bottomAxisTickLabelProps, + barColor = barChartDefault.barColor, + barSelectedColor = barChartDefault.barSelectedColor, + gridColor = barChartDefault.gridColor, + renderGradients = barChartDefault.renderGradients, +}: BarChartProps<DatumType>) { + const [hasSelectedBar, setHasSelectedBar] = useState<boolean>(false); + + // FYI: additional margins to show left and bottom axises + const internalMargin = { + top: (margin?.top ?? 0) + 30, + right: margin?.right ?? 0, + bottom: (margin?.bottom ?? 0) + 35, + left: (margin?.left ?? 0) + 40, + }; + + const accessors = { xAccessor, yAccessor }; + + return ( + <ChartWrapper> + <ParentSize> + {({ width, height }) => { + return ( + <XYChart + width={width} + height={height} + xScale={{ type: 'band', paddingInner: 0.4, paddingOuter: 0.1 }} + yScale={{ type: 'linear', nice: true, round: true }} + margin={internalMargin} + captureEvents={false} + > + {renderGradients?.()} + + <Axis + orientation="left" + hideAxisLine + hideTicks + tickFormat={leftAxisTickFormat} + tickLabelProps={leftAxisTickLabelProps} + /> + + <Axis + orientation="bottom" + numTicks={data.length} + tickFormat={bottomAxisTickFormat} + tickLabelProps={bottomAxisTickLabelProps} + hideAxisLine + hideTicks + /> + + <line + x1={internalMargin.left} + x2={internalMargin.left} + y1={0} + y2={height - internalMargin.bottom} + stroke={gridColor} + /> + + <Grid rows columns={false} stroke={gridColor} strokeWidth={1} lineStyle={{}} /> + + <StyledBarSeries + as={BarSeries<AxisScale, AxisScale, DatumType>} + $hasSelectedItem={hasSelectedBar} + $color={barColor} + $selectedColor={barSelectedColor} + dataKey="bar-seria-0" + data={data} + radius={4} + radiusTop + onBlur={() => setHasSelectedBar(false)} + onFocus={() => setHasSelectedBar(true)} + // Internally the library doesn't emmit these events if handlers are empty + // They are requred to show/hide/move tooltip + onPointerMove={() => null} + onPointerUp={() => null} + onPointerOut={() => null} + {...accessors} + /> + + <Tooltip<DatumType> + snapTooltipToDatumX + snapTooltipToDatumY + unstyled + applyPositionStyle + renderTooltip={({ tooltipData }) => { + return ( + tooltipData?.nearestDatum && ( + <Popover + open + placement="topLeft" + content={renderTooltipContent?.(tooltipData.nearestDatum.datum)} + /> + ) + ); + }} + /> + </XYChart> + ); + }} + </ParentSize> + </ChartWrapper> + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/components.tsx b/datahub-web-react/src/alchemy-components/components/BarChart/components.tsx new file mode 100644 index 00000000000000..aa8f1320ef21dd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/components.tsx @@ -0,0 +1,34 @@ +import { colors } from '@src/alchemy-components/theme'; +import { BarSeries } from '@visx/xychart'; +import styled from 'styled-components'; + +export const ChartWrapper = styled.div` + width: 100%; + height: 100%; + position: relative; +`; + +export const StyledBarSeries = styled(BarSeries)<{ + $hasSelectedItem?: boolean; + $color?: string; + $selectedColor?: string; +}>` + & { + cursor: pointer; + + fill: ${(props) => (props.$hasSelectedItem ? props.$selectedColor : props.$color) || colors.violet[500]}; + ${(props) => props.$hasSelectedItem && 'opacity: 0.3;'} + + :hover { + fill: ${(props) => props.$selectedColor || colors.violet[500]}; + filter: drop-shadow(0px -2px 5px rgba(33, 23, 95, 0.3)); + opacity: 1; + } + + :focus { + fill: ${(props) => props.$selectedColor || colors.violet[500]}; + outline: none; + opacity: 1; + } + } +`; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/index.ts b/datahub-web-react/src/alchemy-components/components/BarChart/index.ts new file mode 100644 index 00000000000000..fdfc3f3ab44a89 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/index.ts @@ -0,0 +1 @@ +export { BarChart } from './BarChart'; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/types.ts b/datahub-web-react/src/alchemy-components/components/BarChart/types.ts new file mode 100644 index 00000000000000..5fd7e2e63e2411 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/types.ts @@ -0,0 +1,18 @@ +import { TickFormatter, TickLabelProps } from '@visx/axis'; +import { Margin } from '@visx/xychart'; + +export type BarChartProps<DatumType extends object> = { + data: DatumType[]; + xAccessor: (datum: DatumType) => string | number; + yAccessor: (datum: DatumType) => number; + renderTooltipContent?: (datum: DatumType) => React.ReactNode; + margin?: Margin; + leftAxisTickFormat?: TickFormatter<DatumType>; + leftAxisTickLabelProps?: TickLabelProps<DatumType>; + bottomAxisTickFormat?: TickFormatter<DatumType>; + bottomAxisTickLabelProps?: TickLabelProps<DatumType>; + barColor?: string; + barSelectedColor?: string; + gridColor?: string; + renderGradients?: () => React.ReactNode; +}; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/utils.ts b/datahub-web-react/src/alchemy-components/components/BarChart/utils.ts new file mode 100644 index 00000000000000..0b592da7f59b08 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/utils.ts @@ -0,0 +1,26 @@ +import dayjs from 'dayjs'; + +export function generateMockData(length = 30, maxValue = 50_000, minValue = 0) { + return Array(length) + .fill(0) + .map((_, index) => { + const date = dayjs() + .startOf('day') + .add(index - length, 'days') + .toDate(); + const value = Math.max(Math.random() * maxValue, minValue); + + return { + x: date, + y: value, + }; + }); +} + +export function getMockedProps() { + return { + data: generateMockData(), + xAccessor: (datum) => datum.x, + yAccessor: (datum) => Math.max(datum.y, 1000), + }; +} diff --git a/datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx b/datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx new file mode 100644 index 00000000000000..e2d7c2852da519 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx @@ -0,0 +1,203 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; +import { AVAILABLE_ICONS } from '@components'; + +import { Button, buttonDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Forms / Button', + component: Button, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: + 'Buttons are used to trigger an action or event, such as submitting a form, opening a dialog, canceling an action, or performing a delete operation.', + }, + }, + + // Component-level argTypes + argTypes: { + children: { + description: 'The content of the Button.', + control: { + type: 'text', + }, + }, + variant: { + description: 'The variant of the Button.', + options: ['filled', 'outline', 'text'], + table: { + defaultValue: { summary: buttonDefaults.variant }, + }, + control: { + type: 'radio', + }, + }, + color: { + description: 'The color of the Button.', + options: ['violet', 'green', 'red', 'blue', 'gray'], + table: { + defaultValue: { summary: buttonDefaults.color }, + }, + control: { + type: 'select', + }, + }, + size: { + description: 'The size of the Button.', + options: ['sm', 'md', 'lg', 'xl'], + table: { + defaultValue: { summary: buttonDefaults.size }, + }, + control: { + type: 'select', + }, + }, + icon: { + description: 'The icon to display in the Button.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + iconPosition: { + description: 'The position of the icon in the Button.', + options: ['left', 'right'], + table: { + defaultValue: { summary: buttonDefaults.iconPosition }, + }, + control: { + type: 'radio', + }, + }, + isCircle: { + description: + 'Whether the Button should be a circle. If this is selected, the Button will ignore children content, so add an Icon to the Button.', + table: { + defaultValue: { summary: buttonDefaults?.isCircle?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isLoading: { + description: 'Whether the Button is in a loading state.', + table: { + defaultValue: { summary: buttonDefaults?.isLoading?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Button is disabled.', + table: { + defaultValue: { summary: buttonDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isActive: { + description: 'Whether the Button is active.', + table: { + defaultValue: { summary: buttonDefaults?.isActive?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + onClick: { + description: 'Function to call when the button is clicked', + table: { + defaultValue: { summary: 'undefined' }, + }, + action: 'clicked', + }, + }, + + // Define defaults + args: { + children: 'Button Content', + variant: buttonDefaults.variant, + color: buttonDefaults.color, + size: buttonDefaults.size, + icon: undefined, + iconPosition: buttonDefaults.iconPosition, + isCircle: buttonDefaults.isCircle, + isLoading: buttonDefaults.isLoading, + isDisabled: buttonDefaults.isDisabled, + isActive: buttonDefaults.isActive, + onClick: () => console.log('Button clicked'), + }, +} satisfies Meta<typeof Button>; + +export default meta; + +// Stories + +type Story = StoryObj<typeof meta>; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Button {...props}>Button</Button>, +}; + +export const states = () => ( + <GridList> + <Button>Default</Button> + <Button isLoading>Loading State</Button> + <Button isActive>Active/Focus State</Button> + <Button isDisabled>Disabled State</Button> + </GridList> +); + +export const colors = () => ( + <GridList> + <Button>Violet Button</Button> + <Button color="green">Green Button</Button> + <Button color="red">Red Button</Button> + <Button color="blue">Blue Button</Button> + <Button color="gray">Gray Button</Button> + </GridList> +); + +export const sizes = () => ( + <GridList> + <Button size="sm">Small Button</Button> + <Button size="md">Regular Button</Button> + <Button size="lg">Large Button</Button> + <Button size="xl">XLarge Button</Button> + </GridList> +); + +export const withIcon = () => ( + <GridList> + <Button icon="Add">Icon Left</Button> + <Button icon="Add" iconPosition="right"> + Icon Right + </Button> + </GridList> +); + +export const circleShape = () => ( + <GridList> + <Button icon="Add" size="sm" isCircle /> + <Button icon="Add" isCircle /> + <Button icon="Add" size="lg" isCircle /> + </GridList> +); diff --git a/datahub-web-react/src/alchemy-components/components/Button/Button.tsx b/datahub-web-react/src/alchemy-components/components/Button/Button.tsx new file mode 100644 index 00000000000000..a727b0faf97a99 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/Button.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { LoadingOutlined } from '@ant-design/icons'; + +import { Icon } from '@components'; + +import { ButtonBase } from './components'; +import { ButtonProps } from './types'; + +export const buttonDefaults: ButtonProps = { + variant: 'filled', + color: 'violet', + size: 'md', + iconPosition: 'left', + isCircle: false, + isLoading: false, + isDisabled: false, + isActive: false, +}; + +export const Button = ({ + variant = buttonDefaults.variant, + color = buttonDefaults.color, + size = buttonDefaults.size, + icon, // default undefined + iconPosition = buttonDefaults.iconPosition, + isCircle = buttonDefaults.isCircle, + isLoading = buttonDefaults.isLoading, + isDisabled = buttonDefaults.isDisabled, + isActive = buttonDefaults.isActive, + children, + ...props +}: ButtonProps) => { + const sharedProps = { + variant, + color, + size, + isCircle, + isLoading, + isActive, + isDisabled, + disabled: isDisabled, + }; + + if (isLoading) { + return ( + <ButtonBase {...sharedProps} {...props}> + <LoadingOutlined rotate={10} /> {!isCircle && children} + </ButtonBase> + ); + } + + return ( + <ButtonBase {...sharedProps} {...props}> + {icon && iconPosition === 'left' && <Icon icon={icon} size={size} />} + {!isCircle && children} + {icon && iconPosition === 'right' && <Icon icon={icon} size={size} />} + </ButtonBase> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Button/components.ts b/datahub-web-react/src/alchemy-components/components/Button/components.ts new file mode 100644 index 00000000000000..49fa9a12ede6e2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/components.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +import { spacing } from '@components/theme'; +import { ButtonProps } from './types'; +import { getButtonStyle } from './utils'; + +export const ButtonBase = styled.button( + // Dynamic styles + (props: ButtonProps) => ({ ...getButtonStyle(props as ButtonProps) }), + { + // Base root styles + display: 'flex', + alignItems: 'center', + gap: spacing.xsm, + cursor: 'pointer', + transition: `all 0.15s ease`, + + // For transitions between focus/active and hover states + outlineColor: 'transparent', + outlineStyle: 'solid', + + // Base Disabled styles + '&:disabled': { + cursor: 'not-allowed', + }, + }, +); diff --git a/datahub-web-react/src/alchemy-components/components/Button/index.ts b/datahub-web-react/src/alchemy-components/components/Button/index.ts new file mode 100644 index 00000000000000..745d8377f9fbb4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/index.ts @@ -0,0 +1,2 @@ +export { Button, buttonDefaults } from './Button'; +export type { ButtonProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Button/types.ts b/datahub-web-react/src/alchemy-components/components/Button/types.ts new file mode 100644 index 00000000000000..f510ff4c6c13c5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/types.ts @@ -0,0 +1,16 @@ +import { ButtonHTMLAttributes } from 'react'; + +import type { IconNames } from '@components'; +import type { SizeOptions, ColorOptions } from '@components/theme/config'; + +export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { + variant?: 'filled' | 'outline' | 'text'; + color?: ColorOptions; + size?: SizeOptions; + icon?: IconNames; + iconPosition?: 'left' | 'right'; + isCircle?: boolean; + isLoading?: boolean; + isDisabled?: boolean; + isActive?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/components/Button/utils.ts b/datahub-web-react/src/alchemy-components/components/Button/utils.ts new file mode 100644 index 00000000000000..c08f4f067304d1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/utils.ts @@ -0,0 +1,238 @@ +/* + * Button Style Utilities + */ + +import { typography, colors, shadows, radius, spacing } from '@components/theme'; +import { getColor, getFontSize } from '@components/theme/utils'; +import { ButtonProps } from './types'; + +// Utility function to get color styles for button - does not generate CSS +const getButtonColorStyles = (variant, color) => { + const color500 = getColor(color, 500); // value of 500 shade + const isViolet = color === 'violet'; + + const base = { + // Backgrounds + bgColor: color500, + hoverBgColor: getColor(color, 600), + activeBgColor: getColor(color, 700), + disabledBgColor: getColor('gray', 100), + + // Borders + borderColor: color500, + activeBorderColor: getColor(color, 300), + disabledBorderColor: getColor('gray', 200), + + // Text + textColor: colors.white, + disabledTextColor: getColor('gray', 300), + }; + + // Specific color override for white + if (color === 'white') { + base.textColor = colors.black; + base.disabledTextColor = getColor('gray', 500); + } + + // Specific color override for gray + if (color === 'gray') { + base.textColor = getColor('gray', 500); + base.bgColor = getColor('gray', 100); + base.borderColor = getColor('gray', 100); + + base.hoverBgColor = getColor('gray', 100); + base.activeBgColor = getColor('gray', 200); + } + + // Override styles for outline variant + if (variant === 'outline') { + return { + ...base, + bgColor: colors.transparent, + borderColor: color500, + textColor: color500, + + hoverBgColor: isViolet ? getColor(color, 100) : getColor(color, 100), + activeBgColor: isViolet ? getColor(color, 100) : getColor(color, 200), + + disabledBgColor: 'transparent', + }; + } + + // Override styles for text variant + if (variant === 'text') { + return { + ...base, + textColor: color500, + + bgColor: colors.transparent, + borderColor: colors.transparent, + hoverBgColor: colors.transparent, + activeBgColor: colors.transparent, + disabledBgColor: colors.transparent, + disabledBorderColor: colors.transparent, + }; + } + + // Filled variable is the base style + return base; +}; + +// Generate color styles for button +const getButtonVariantStyles = (variant, color) => { + const variantStyles = { + filled: { + backgroundColor: color.bgColor, + border: `1px solid ${color.borderColor}`, + color: color.textColor, + '&:hover': { + backgroundColor: color.hoverBgColor, + border: `1px solid ${color.hoverBgColor}`, + boxShadow: shadows.sm, + }, + '&:disabled': { + backgroundColor: color.disabledBgColor, + border: `1px solid ${color.disabledBorderColor}`, + color: color.disabledTextColor, + boxShadow: shadows.xs, + }, + }, + outline: { + backgroundColor: 'transparent', + border: `1px solid ${color.borderColor}`, + color: color.textColor, + '&:hover': { + backgroundColor: color.hoverBgColor, + boxShadow: 'none', + }, + '&:disabled': { + backgroundColor: color.disabledBgColor, + border: `1px solid ${color.disabledBorderColor}`, + color: color.disabledTextColor, + boxShadow: shadows.xs, + }, + }, + text: { + backgroundColor: 'transparent', + border: 'none', + color: color.textColor, + '&:hover': { + backgroundColor: color.hoverBgColor, + }, + '&:disabled': { + backgroundColor: color.disabledBgColor, + color: color.disabledTextColor, + }, + }, + }; + + return variantStyles[variant]; +}; + +// Generate font styles for button +const getButtonFontStyles = (size) => { + const baseFontStyles = { + fontFamily: typography.fonts.body, + fontWeight: typography.fontWeights.normal, + lineHeight: typography.lineHeights.none, + }; + + const sizeStyles = { + sm: { + ...baseFontStyles, + fontSize: getFontSize(size), // 12px + }, + md: { + ...baseFontStyles, + fontSize: getFontSize(size), // 14px + }, + lg: { + ...baseFontStyles, + fontSize: getFontSize(size), // 16px + }, + xl: { + ...baseFontStyles, + fontSize: getFontSize(size), // 18px + }, + }; + + return sizeStyles[size]; +}; + +// Generate radii styles for button +const getButtonRadiiStyles = (isCircle) => { + if (isCircle) return { borderRadius: radius.full }; + return { borderRadius: radius.sm }; // radius is the same for all button sizes +}; + +// Generate padding styles for button +const getButtonPadding = (size, isCircle) => { + if (isCircle) return { padding: spacing.xsm }; + + const paddingStyles = { + sm: { + padding: '8px 12px', + }, + md: { + padding: '10px 12px', + }, + lg: { + padding: '10px 16px', + }, + xl: { + padding: '12px 20px', + }, + }; + + return paddingStyles[size]; +}; + +// Generate active styles for button +const getButtonActiveStyles = (styleColors) => ({ + borderColor: 'transparent', + backgroundColor: styleColors.activeBgColor, + // TODO: Figure out how to make the #fff interior border transparent + boxShadow: `0 0 0 2px #fff, 0 0 0 4px ${styleColors.activeBgColor}`, +}); + +// Generate loading styles for button +const getButtonLoadingStyles = () => ({ + pointerEvents: 'none', + opacity: 0.75, +}); + +/* + * Main function to generate styles for button + */ +export const getButtonStyle = (props: ButtonProps) => { + const { variant, color, size, isCircle, isActive, isLoading } = props; + + // Get map of colors + const colorStyles = getButtonColorStyles(variant, color) || ({} as any); + + // Define styles for button + const variantStyles = getButtonVariantStyles(variant, colorStyles); + const fontStyles = getButtonFontStyles(size); + const radiiStyles = getButtonRadiiStyles(isCircle); + const paddingStyles = getButtonPadding(size, isCircle); + + // Base of all generated styles + let styles = { + ...variantStyles, + ...fontStyles, + ...radiiStyles, + ...paddingStyles, + }; + + // Focus & Active styles are the same, but active styles are applied conditionally & override prevs styles + const activeStyles = { ...getButtonActiveStyles(colorStyles) }; + styles['&:focus'] = activeStyles; + styles['&:active'] = activeStyles; + if (isActive) styles = { ...styles, ...activeStyles }; + + // Loading styles + if (isLoading) styles = { ...styles, ...getButtonLoadingStyles() }; + + // Return generated styles + return styles; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx b/datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx new file mode 100644 index 00000000000000..336831fd15cfab --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx @@ -0,0 +1,141 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { GridList } from '@src/alchemy-components/.docs/mdx-components'; +import { colors } from '@src/alchemy-components/theme'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Card, cardDefaults } from '.'; +import { Icon } from '../Icon'; + +// Auto Docs +const meta = { + title: 'Components / Card', + component: Card, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render a card.', + }, + }, + + // Component-level argTypes + argTypes: { + title: { + description: 'The title of the card', + table: { + defaultValue: { summary: `${cardDefaults.title}` }, + }, + control: { + type: 'text', + }, + }, + subTitle: { + description: 'The subtitle of the card', + control: { + type: 'text', + }, + }, + icon: { + description: 'The icon on the card', + control: { + type: 'text', + }, + }, + iconAlignment: { + description: 'Whether the alignment of icon is horizontal or vertical', + table: { + defaultValue: { summary: `${cardDefaults.iconAlignment}` }, + }, + control: { + type: 'select', + }, + }, + percent: { + description: 'The percent value on the pill of the card', + control: { + type: 'number', + }, + }, + button: { + description: 'The button on the card', + control: { + type: 'text', + }, + }, + width: { + description: 'The width of the card', + control: { + type: 'text', + }, + }, + onClick: { + description: 'The on click function for the card', + }, + }, + + // Define default args + args: { + title: 'Title', + subTitle: 'Subtitle', + iconAlignment: 'horizontal', + width: '150px', + }, +} satisfies Meta<typeof Card>; + +export default meta; + +// Stories + +type Story = StoryObj<typeof meta>; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Card {...props} />, +}; + +export const withChildren = () => ( + <Card title="Title" subTitle="Subtitle"> + <div style={{ backgroundColor: colors.gray[1000], padding: '8px 32px' }}>Children of the card (Swap me)</div> + </Card> +); + +export const withoutSubtitle = () => ( + <Card title="Title"> + <div style={{ backgroundColor: colors.gray[1000], padding: '8px 32px' }}>Children of the card (Swap me)</div> + </Card> +); + +export const withIcon = () => ( + <GridList> + <Card title="Title" subTitle="Subtitle" icon={<Icon icon="Cloud" color="gray" />} /> + <Card title="Title" subTitle="Subtitle" icon={<Icon icon="Cloud" color="gray" />} iconAlignment="vertical" /> + </GridList> +); + +export const withButton = () => ( + <Card + title="Title" + subTitle="Subtitle" + button={<Icon icon="Download" color="gray" size="2xl" />} + onClick={() => window.alert('Card clicked')} + /> +); + +export const withPercentPill = () => <Card title="Title" subTitle="Subtitle" percent={2} />; + +export const withAllTheElements = () => ( + <Card + title="Title" + subTitle="Subtitle" + percent={2} + icon={<Icon icon="Cloud" color="gray" />} + button={<Icon icon="Download" color="gray" size="2xl" />} + onClick={() => window.alert('Card clicked')} + > + <div style={{ backgroundColor: colors.gray[1000], padding: '8px 32px' }}>Children of the card (Swap me)</div> + </Card> +); diff --git a/datahub-web-react/src/alchemy-components/components/Card/Card.tsx b/datahub-web-react/src/alchemy-components/components/Card/Card.tsx new file mode 100644 index 00000000000000..55c581251bea99 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/Card.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { CardProps } from './types'; +import { CardContainer, Header, SubTitle, SubTitleContainer, Title, TitleContainer } from './components'; +import { Pill } from '../Pills'; + +export const cardDefaults: CardProps = { + title: 'Title', + iconAlignment: 'horizontal', +}; + +export const Card = ({ + title = cardDefaults.title, + iconAlignment = cardDefaults.iconAlignment, + subTitle, + percent, + button, + onClick, + icon, + children, + width, +}: CardProps) => { + return ( + <CardContainer hasButton={!!button} onClick={onClick} width={width}> + <Header iconAlignment={iconAlignment}> + {icon && <div>{icon}</div>} + <TitleContainer> + <Title> + {title} + {!!percent && ( + <Pill + label={`${Math.abs(percent)}%`} + size="sm" + colorScheme={percent < 0 ? 'red' : 'green'} + leftIcon={percent < 0 ? 'TrendingDown' : 'TrendingUp'} + clickable={false} + /> + )} + + + {subTitle} + {button} + + + + {children} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Card/components.ts b/datahub-web-react/src/alchemy-components/components/Card/components.ts new file mode 100644 index 00000000000000..bb3821fffc7f58 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/components.ts @@ -0,0 +1,59 @@ +import { colors, radius, spacing, typography } from '@src/alchemy-components/theme'; +import { IconAlignmentOptions } from '@src/alchemy-components/theme/config'; +import styled from 'styled-components'; + +export const CardContainer = styled.div<{ hasButton: boolean; width?: string }>(({ hasButton, width }) => ({ + border: `1px solid ${colors.gray[100]}`, + borderRadius: radius.lg, + padding: spacing.md, + minWidth: '150px', + boxShadow: '0px 1px 2px 0px rgba(33, 23, 95, 0.07)', + backgroundColor: colors.white, + display: 'flex', + flexDirection: 'column', + gap: spacing.md, + width, + + '&:hover': hasButton + ? { + border: `1px solid ${colors.violet[500]}`, + cursor: 'pointer', + } + : {}, +})); + +export const Header = styled.div<{ iconAlignment?: IconAlignmentOptions }>(({ iconAlignment }) => ({ + display: 'flex', + flexDirection: iconAlignment === 'horizontal' ? 'row' : 'column', + alignItems: iconAlignment === 'horizontal' ? 'center' : 'start', + gap: spacing.sm, + width: '100%', +})); + +export const TitleContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 2, + width: '100%', +}); + +export const Title = styled.div({ + fontSize: typography.fontSizes.lg, + fontWeight: typography.fontWeights.bold, + color: colors.gray[600], + display: 'flex', + alignItems: 'center', + gap: spacing.xsm, +}); + +export const SubTitleContainer = styled.div({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const SubTitle = styled.div({ + fontSize: typography.fontSizes.md, + fontWeight: typography.fontWeights.normal, + color: colors.gray[1700], +}); diff --git a/datahub-web-react/src/alchemy-components/components/Card/index.ts b/datahub-web-react/src/alchemy-components/components/Card/index.ts new file mode 100644 index 00000000000000..b0eed059aafd8a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/index.ts @@ -0,0 +1,2 @@ +export { Card, cardDefaults } from './Card'; +export type { CardProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Card/types.ts b/datahub-web-react/src/alchemy-components/components/Card/types.ts new file mode 100644 index 00000000000000..e5b0e36f83e4ce --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/types.ts @@ -0,0 +1,13 @@ +import { IconAlignmentOptions } from '@src/alchemy-components/theme/config'; + +export interface CardProps { + title: string; + subTitle?: string; + percent?: number; + button?: React.ReactNode; + onClick?: () => void; + icon?: React.ReactNode; + iconAlignment?: IconAlignmentOptions; + children?: React.ReactNode; + width?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx new file mode 100644 index 00000000000000..e546c2ea526cbc --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { GridList } from '@components/.docs/mdx-components'; +import { Checkbox, checkboxDefaults, CheckboxGroup } from './Checkbox'; +import { CheckboxProps } from './types'; +import { Heading } from '../Heading'; + +const MOCK_CHECKBOXES: CheckboxProps[] = [ + { + label: 'Label 1', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 2', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 3', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, +]; + +const meta = { + title: 'Forms / Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + docs: { + subtitle: 'A component that is used to get user input in the state of a check box.', + }, + }, + argTypes: { + label: { + description: 'Label for the Checkbox.', + table: { + defaultValue: { summary: checkboxDefaults.label }, + }, + control: { + type: 'text', + }, + }, + error: { + description: 'Enforce error state on the Checkbox.', + table: { + defaultValue: { summary: checkboxDefaults.error }, + }, + control: { + type: 'text', + }, + }, + isChecked: { + description: 'Whether the Checkbox is checked.', + table: { + defaultValue: { summary: checkboxDefaults?.isChecked?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Checkbox is in disabled state.', + table: { + defaultValue: { summary: checkboxDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isIntermediate: { + description: 'Whether the Checkbox is in intermediate state.', + table: { + defaultValue: { summary: checkboxDefaults?.isIntermediate?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Checkbox is a required field.', + table: { + defaultValue: { summary: checkboxDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + args: { + label: checkboxDefaults.label, + error: checkboxDefaults.error, + isChecked: checkboxDefaults.isChecked, + isDisabled: checkboxDefaults.isDisabled, + isIntermediate: checkboxDefaults.isIntermediate, + isRequired: checkboxDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const states = () => ( + + + + + + +); + +export const intermediate = () => { + return ( + + + + + ); +}; + +export const disabledStates = () => ( + + + + + +); + +export const checkboxGroups = () => ( + +
+ Horizontal Checkbox Group + +
+
+ Vertical Checkbox Group + +
+
+); diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx new file mode 100644 index 00000000000000..6ab4db74610e49 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { CheckboxProps, CheckboxGroupProps } from './types'; +import { + CheckboxBase, + CheckboxContainer, + CheckboxGroupContainer, + Checkmark, + HoverState, + Label, + Required, + StyledCheckbox, +} from './components'; + +export const checkboxDefaults: CheckboxProps = { + label: 'Label', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + setIsChecked: () => {}, +}; + +export const Checkbox = ({ + label = checkboxDefaults.label, + error = checkboxDefaults.error, + isChecked = checkboxDefaults.isChecked, + isDisabled = checkboxDefaults.isDisabled, + isIntermediate = checkboxDefaults.isIntermediate, + isRequired = checkboxDefaults.isRequired, + setIsChecked = checkboxDefaults.setIsChecked, + ...props +}: CheckboxProps) => { + const [checked, setChecked] = useState(isChecked || false); + const [isHovering, setIsHovering] = useState(false); + + useEffect(() => { + setChecked(isChecked || false); + }, [isChecked]); + + const id = props.id || `checkbox-${label}`; + + return ( + + + { + if (!isDisabled) { + setChecked(!checked); + setIsChecked?.(!checked); + } + }} + > + null} + aria-labelledby={id} + aria-checked={checked} + {...props} + /> + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + /> + + + + ); +}; + +export const CheckboxGroup = ({ isVertical, checkboxes }: CheckboxGroupProps) => { + if (!checkboxes.length) { + return <>; + } + + return ( + + {checkboxes.map((checkbox) => { + const props = { ...checkbox }; + return ( + + + + ); + })} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts new file mode 100644 index 00000000000000..6a4ad08c9c4ce6 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts @@ -0,0 +1,91 @@ +import { borders, colors, spacing, transform, zIndices, radius } from '@components/theme'; +import styled from 'styled-components'; +import { getCheckboxColor, getCheckboxHoverBackgroundColor } from './utils'; +import { formLabelTextStyles } from '../commonStyles'; + +export const CheckboxContainer = styled.div({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const Label = styled.div({ + ...formLabelTextStyles, +}); + +export const Required = styled.span({ + color: colors.red[500], + marginLeft: spacing.xxsm, +}); + +export const CheckboxBase = styled.div({ + position: 'relative', + width: '30px', + height: '30px', +}); + +export const StyledCheckbox = styled.input<{ + checked: boolean; + error: string; + disabled: boolean; +}>(({ error, checked, disabled }) => ({ + position: 'absolute', + opacity: 0, + height: 0, + width: 0, + '&:checked + div': { + backgroundColor: getCheckboxColor(checked, error, disabled, 'background'), + }, + '&:checked + div:after': { + display: 'block', + }, +})); + +export const Checkmark = styled.div<{ intermediate?: boolean; error: string; checked: boolean; disabled: boolean }>( + ({ intermediate, checked, error, disabled }) => ({ + position: 'absolute', + top: '4px', + left: '11px', + zIndex: zIndices.docked, + height: '18px', + width: '18px', + borderRadius: '3px', + border: `${borders['2px']} ${getCheckboxColor(checked, error, disabled, undefined)}`, + transition: 'all 0.2s ease-in-out', + cursor: 'pointer', + '&:after': { + content: '""', + position: 'absolute', + display: 'none', + left: !intermediate ? '6px' : '8px', + top: !intermediate ? '1px' : '3px', + width: !intermediate ? '5px' : '0px', + height: '10px', + border: 'solid white', + borderWidth: '0 3px 3px 0', + transform: !intermediate ? 'rotate(45deg)' : transform.rotate[90], + }, + }), +); + +export const HoverState = styled.div<{ isHovering: boolean; error: string; checked: boolean; disabled: boolean }>( + ({ isHovering, error, checked }) => ({ + width: '40px', + height: '40px', + backgroundColor: !isHovering ? 'transparent' : getCheckboxHoverBackgroundColor(checked, error), + position: 'absolute', + borderRadius: radius.full, + top: '-5px', + left: '2px', + zIndex: zIndices.hide, + }), +); + +export const CheckboxGroupContainer = styled.div<{ isVertical?: boolean }>(({ isVertical }) => ({ + display: 'flex', + flexDirection: isVertical ? 'column' : 'row', + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + margin: spacing.xxsm, +})); diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/index.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/index.ts new file mode 100644 index 00000000000000..57e3d6d27856a5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox, CheckboxGroup, checkboxDefaults } from './Checkbox'; +export type { CheckboxProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts new file mode 100644 index 00000000000000..7ee10011689397 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts @@ -0,0 +1,16 @@ +import { InputHTMLAttributes } from 'react'; + +export interface CheckboxProps extends InputHTMLAttributes { + label: string; + error?: string; + isChecked?: boolean; + setIsChecked?: React.Dispatch>; + isDisabled?: boolean; + isIntermediate?: boolean; + isRequired?: boolean; +} + +export interface CheckboxGroupProps { + isVertical?: boolean; + checkboxes: CheckboxProps[]; +} diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts new file mode 100644 index 00000000000000..edf5d24596e1b4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts @@ -0,0 +1,27 @@ +import theme, { colors } from '@components/theme'; + +const checkboxBackgroundDefault = { + default: colors.white, + checked: theme.semanticTokens.colors.primary, + error: theme.semanticTokens.colors.error, + disabled: colors.gray[300], +}; + +const checkboxHoverColors = { + default: colors.gray[100], + error: colors.red[100], + checked: colors.violet[100], +}; + +export function getCheckboxColor(checked: boolean, error: string, disabled: boolean, mode: 'background' | undefined) { + if (disabled) return checkboxBackgroundDefault.disabled; + if (error) return checkboxBackgroundDefault.error; + if (checked) return checkboxBackgroundDefault.checked; + return mode === 'background' ? checkboxBackgroundDefault.default : colors.gray[500]; +} + +export function getCheckboxHoverBackgroundColor(checked: boolean, error: string) { + if (error) return checkboxHoverColors.error; + if (checked) return checkboxHoverColors.checked; + return checkboxHoverColors.default; +} diff --git a/datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx b/datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx new file mode 100644 index 00000000000000..b8bd9f6420c006 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx @@ -0,0 +1,98 @@ +import React from 'react'; + +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { VerticalFlexGrid } from '@components/.docs/mdx-components'; +import { Heading, headingDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Typography / Heading', + component: Heading, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render semantic HTML heading elements.', + }, + }, + + // Component-level argTypes + argTypes: { + children: { + description: 'The content to display within the heading.', + table: { + type: { summary: 'string' }, + }, + control: { + type: 'text', + }, + }, + type: { + description: 'The type of heading to display.', + table: { + defaultValue: { summary: headingDefaults.type }, + }, + }, + size: { + description: 'Override the size of the heading.', + table: { + defaultValue: { summary: `${headingDefaults.size}` }, + }, + }, + color: { + description: 'Override the color of the heading.', + table: { + defaultValue: { summary: headingDefaults.color }, + }, + }, + weight: { + description: 'Override the weight of the heading.', + table: { + defaultValue: { summary: `${headingDefaults.weight}` }, + }, + }, + }, + + // Define defaults + args: { + children: 'The content to display within the heading.', + type: headingDefaults.type, + size: headingDefaults.size, + color: headingDefaults.color, + weight: headingDefaults.weight, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => {props.children}, +}; + +export const sizes: StoryFn = (props: any) => ( + + H1 {props.children} + H2 {props.children} + H3 {props.children} + H4 {props.children} + H5 {props.children} + H6 {props.children} + +); + +export const withLink = () => ( + + The content to display within the heading + +); diff --git a/datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx b/datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx new file mode 100644 index 00000000000000..6449ff512adacc --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { HeadingProps } from './types'; +import { H1, H2, H3, H4, H5, H6 } from './components'; + +export const headingDefaults: HeadingProps = { + type: 'h1', + color: 'inherit', + size: '2xl', + weight: 'medium', +}; + +export const Heading = ({ + type = headingDefaults.type, + size = headingDefaults.size, + color = headingDefaults.color, + weight = headingDefaults.weight, + children, +}: HeadingProps) => { + const sharedProps = { size, color, weight }; + + switch (type) { + case 'h1': + return

{children}

; + case 'h2': + return

{children}

; + case 'h3': + return

{children}

; + case 'h4': + return

{children}

; + case 'h5': + return
{children}
; + case 'h6': + return
{children}
; + default: + return

{children}

; + } +}; diff --git a/datahub-web-react/src/alchemy-components/components/Heading/components.ts b/datahub-web-react/src/alchemy-components/components/Heading/components.ts new file mode 100644 index 00000000000000..beea5338585d83 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/components.ts @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +import { typography, colors } from '@components/theme'; +import { getColor, getFontSize } from '@components/theme/utils'; +import { HeadingProps } from './types'; + +const headingStyles = { + H1: { + fontSize: typography.fontSizes['4xl'], + lineHeight: typography.lineHeights['2xl'], + }, + H2: { + fontSize: typography.fontSizes['3xl'], + lineHeight: typography.lineHeights.xl, + }, + H3: { + fontSize: typography.fontSizes['2xl'], + lineHeight: typography.lineHeights.lg, + }, + H4: { + fontSize: typography.fontSizes.xl, + lineHeight: typography.lineHeights.lg, + }, + H5: { + fontSize: typography.fontSizes.lg, + lineHeight: typography.lineHeights.md, + }, + H6: { + fontSize: typography.fontSizes.md, + lineHeight: typography.lineHeights.xs, + }, +}; + +// Default styles +const baseStyles = { + fontFamily: typography.fonts.heading, + margin: 0, + + '& a': { + color: colors.violet[400], + textDecoration: 'none', + transition: 'color 0.15s ease', + + '&:hover': { + color: colors.violet[500], + }, + }, +}; + +// Prop Driven Styles +const propStyles = (props, isText = false) => { + const styles = {} as any; + if (props.size) styles.fontSize = getFontSize(props.size); + if (props.color) styles.color = getColor(props.color); + if (props.weight) styles.fontWeight = typography.fontWeights[props.weight]; + if (isText) styles.lineHeight = typography.lineHeights[props.size]; + return styles; +}; + +// Generate Headings +const headings = {} as any; + +['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].forEach((heading) => { + const component = styled[heading.toLowerCase()]; + headings[heading] = component({ ...baseStyles, ...headingStyles[heading] }, (props: HeadingProps) => ({ + ...propStyles(props as HeadingProps), + })); +}); + +export const { H1, H2, H3, H4, H5, H6 } = headings; diff --git a/datahub-web-react/src/alchemy-components/components/Heading/index.ts b/datahub-web-react/src/alchemy-components/components/Heading/index.ts new file mode 100644 index 00000000000000..c414de6cc92f79 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/index.ts @@ -0,0 +1,2 @@ +export { Heading, headingDefaults } from './Heading'; +export type { HeadingProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Heading/types.ts b/datahub-web-react/src/alchemy-components/components/Heading/types.ts new file mode 100644 index 00000000000000..96fcf1ea292bf7 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/types.ts @@ -0,0 +1,9 @@ +import { HTMLAttributes } from 'react'; +import type { FontSizeOptions, FontColorOptions, FontWeightOptions } from '@components/theme/config'; + +export interface HeadingProps extends HTMLAttributes { + type?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + size?: FontSizeOptions; + color?: FontColorOptions; + weight?: FontWeightOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx b/datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx new file mode 100644 index 00000000000000..3dcbd74ceb0b71 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { Icon, iconDefaults, AVAILABLE_ICONS } from '.'; + +// Auto Docs +const meta = { + title: 'Media / Icon', + component: Icon, + + // Display Properties + parameters: { + layout: 'centered', + badges: ['productionReady'], + docs: { + subtitle: 'A singular component for rendering the icons used throughout the application.', + description: { + component: '👉 See the [Icons Gallery](/docs/icons--docs) for more information.', + }, + }, + }, + + // Component-level argTypes + argTypes: { + icon: { + description: `The name of the icon to display.`, + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + variant: { + description: 'The variant of the icon to display.', + defaultValue: 'outline', + options: ['outline', 'filled'], + table: { + defaultValue: { summary: iconDefaults.variant }, + }, + }, + size: { + description: 'The size of the icon to display.', + defaultValue: 'lg', + table: { + defaultValue: { summary: iconDefaults.size }, + }, + }, + color: { + description: 'The color of the icon to display.', + options: ['inherit', 'white', 'black', 'violet', 'green', 'red', 'blue', 'gray'], + type: 'string', + table: { + defaultValue: { summary: iconDefaults.color }, + }, + control: { + type: 'select', + }, + }, + rotate: { + description: 'The rotation of the icon. Applies a CSS transformation.', + table: { + defaultValue: { summary: iconDefaults.rotate }, + }, + }, + }, + + // Define defaults for required args + args: { + icon: iconDefaults.icon, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const filled = () => ( + + + + + +); + +export const sizes = () => ( + + + + + + + + + + +); + +export const colors = () => ( + + + + + + + + + +); + +export const rotation = () => ( + + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx b/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx new file mode 100644 index 00000000000000..50c30d7203aed8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { getFontSize, getColor, getRotationTransform } from '@components/theme/utils'; + +import { IconProps } from './types'; +import { IconWrapper } from './components'; +import { getIconNames, getIconComponent } from './utils'; + +export const iconDefaults: IconProps = { + icon: 'AccountCircle', + variant: 'outline', + size: '4xl', + color: 'inherit', + rotate: '0', +}; + +export const Icon = ({ + icon, + variant = iconDefaults.variant, + size = iconDefaults.size, + color = iconDefaults.color, + rotate = iconDefaults.rotate, + ...props +}: IconProps) => { + const { filled, outlined } = getIconNames(); + + // Return early if no icon is provided + if (!icon) return null; + + // Get outlined icon component name + const isOutlined = variant === 'outline'; + const outlinedIconName = `${icon}Outlined`; + + // Warn if the icon does not have the specified variant + if (variant === 'outline' && !outlined.includes(outlinedIconName)) { + console.warn(`Icon "${icon}" does not have an outlined variant.`); + return null; + } + + // Warn if the icon does not have the specified variant + if (variant === 'filled' && !filled.includes(icon)) { + console.warn(`Icon "${icon}" does not have a filled variant.`); + return null; + } + + // Get outlined icon component + const IconComponent = getIconComponent(isOutlined ? outlinedIconName : icon); + + return ( + + + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/components.ts b/datahub-web-react/src/alchemy-components/components/Icon/components.ts new file mode 100644 index 00000000000000..82e9c9a8fcae00 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/components.ts @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +export const IconWrapper = styled.div<{ size: string; rotate?: string }>` + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: ${({ size }) => size}; + height: ${({ size }) => size}; + + & svg { + width: 100%; + height: 100%; + + transform: ${({ rotate }) => rotate}; + } +`; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/constants.ts b/datahub-web-react/src/alchemy-components/components/Icon/constants.ts new file mode 100644 index 00000000000000..25145a5970f0f2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/constants.ts @@ -0,0 +1,547 @@ +export const AVAILABLE_ICONS = [ + 'AccountCircle', + 'AccountTree', + 'AddCircle', + 'AddLink', + 'Add', + 'AddTask', + 'AddToPhotos', + 'Adjust', + 'AllInclusive', + 'Analytics', + 'Anchor', + 'Animation', + 'Announcement', + 'Api', + 'Approval', + 'Archive', + 'ArrowBack', + 'ArrowCircleDown', + 'ArrowCircleLeft', + 'ArrowCircleRight', + 'ArrowCircleUp', + 'ArrowDownward', + 'ArrowForward', + 'ArrowOutward', + 'ArrowUpward', + 'ArtTrack', + 'Article', + 'Assistant', + 'AttachFile', + 'Attachment', + 'AutoAwesome', + 'AutoFixHigh', + 'AutoFixOff', + 'AutoGraph', + 'AutoMode', + 'AutoStories', + 'AvTimer', + 'Backspace', + 'Backup', + 'BackupTable', + 'Badge', + 'Balance', + 'BarChart', + 'BatchPrediction', + 'Block', + 'Bolt', + 'Book', + 'BookmarkAdd', + 'BookmarkAdded', + 'BookmarkBorder', + 'Bookmark', + 'BookmarkRemove', + 'Bookmarks', + 'Brush', + 'BubbleChart', + 'BugReport', + 'BuildCircle', + 'Build', + 'BusinessCenter', + 'Business', + 'Cable', + 'Cached', + 'Calculate', + 'CalendarMonth', + 'CalendarToday', + 'CalendarViewDay', + 'Campaign', + 'Cancel', + 'CandlestickChart', + 'CardGiftcard', + 'CardMembership', + 'Cases', + 'Cast', + 'Category', + 'Celebration', + 'CellTower', + 'ChangeHistory', + 'ChatBubble', + 'Chat', + 'CheckBox', + 'CheckCircle', + 'Check', + 'Checklist', + 'ChevronLeft', + 'ChevronRight', + 'Class', + 'CloseFullscreen', + 'Close', + 'CloudCircle', + 'CloudDone', + 'CloudDownload', + 'CloudOff', + 'Cloud', + 'CloudQueue', + 'CloudSync', + 'CloudUpload', + 'CoPresent', + 'CodeOff', + 'Code', + 'ColorLens', + 'Colorize', + 'CommentBank', + 'Comment', + 'CommentsDisabled', + 'Commit', + 'CompareArrows', + 'Compare', + 'Compress', + 'Computer', + 'Construction', + 'ContactPage', + 'ContactSupport', + 'Contacts', + 'ContentCopy', + 'ContentCut', + 'Contrast', + 'ControlPoint', + 'Cookie', + 'CopyAll', + 'Copyright', + 'CorporateFare', + 'Cottage', + 'CreateNewFolder', + 'CrisisAlert', + 'Cyclone', + 'Dangerous', + 'DarkMode', + 'DashboardCustomize', + 'Dashboard', + 'DataArray', + 'DataObject', + 'DataThresholding', + 'DataUsage', + 'DatasetLinked', + 'Dataset', + 'DateRange', + 'DeleteForever', + 'Delete', + 'DeleteSweep', + 'Description', + 'Deselect', + 'DesignServices', + 'Details', + 'DeviceHub', + 'DeviceThermostat', + 'Diamond', + 'Difference', + 'DisabledByDefault', + 'DiscFull', + 'Discount', + 'DisplaySettings', + 'Diversity2', + 'Dns', + 'DoNotDisturb', + 'DocumentScanner', + 'DomainAdd', + 'DomainDisabled', + 'Domain', + 'DomainVerification', + 'DoneAll', + 'DonutLarge', + 'DonutSmall', + 'DoubleArrow', + 'DownloadDone', + 'DownloadForOffline', + 'Download', + 'Downloading', + 'Drafts', + 'DragHandle', + 'DragIndicator', + 'Draw', + 'DriveFileMove', + 'DriveFolderUpload', + 'DynamicFeed', + 'DynamicForm', + 'EditCalendar', + 'EditLocation', + 'EditNote', + 'EditOff', + 'Edit', + 'Eject', + 'ElectricBolt', + 'EmergencyShare', + 'EnhancedEncryption', + 'Equalizer', + 'Error', + 'EventAvailable', + 'EventBusy', + 'EventNote', + 'Event', + 'EventRepeat', + 'ExitToApp', + 'Expand', + 'ExploreOff', + 'Explore', + 'Exposure', + 'ExtensionOff', + 'Extension', + 'FastForward', + 'FastRewind', + 'FavoriteBorder', + 'Favorite', + 'FeaturedPlayList', + 'Feed', + 'Feedback', + 'FileCopy', + 'FileDownloadOff', + 'FileDownload', + 'FileOpen', + 'FilePresent', + 'FileUpload', + 'FilterAltOff', + 'FilterAlt', + 'FilterListOff', + 'FindInPage', + 'FindReplace', + 'FirstPage', + 'FitScreen', + 'FlagCircle', + 'Flag', + 'Flaky', + 'Flare', + 'FlashOff', + 'FlashOn', + 'FlightLand', + 'Flight', + 'FlightTakeoff', + 'FmdBad', + 'FmdGood', + 'FolderCopy', + 'FolderDelete', + 'FolderOff', + 'FolderOpen', + 'Folder', + 'FolderShared', + 'FolderSpecial', + 'FolderZip', + 'ForkLeft', + 'ForkRight', + 'FormatListBulleted', + 'FormatListNumbered', + 'Forum', + 'FullscreenExit', + 'Fullscreen', + 'Functions', + 'GetApp', + 'GppBad', + 'GppGood', + 'GppMaybe', + 'GpsFixed', + 'GpsNotFixed', + 'GpsOff', + 'Grading', + 'Grain', + 'GraphicEq', + 'Grid3x3', + 'Grid4x4', + 'GridGoldenratio', + 'GridOff', + 'GridOn', + 'GridView', + 'GroupAdd', + 'Group', + 'GroupRemove', + 'GroupWork', + 'Groups', + 'Handshake', + 'Handyman', + 'Hardware', + 'HealthAndSafety', + 'HelpCenter', + 'Help', + 'Hexagon', + 'HideSource', + 'Highlight', + 'History', + 'HistoryToggleOff', + 'Home', + 'Hub', + 'Image', + 'ImageSearch', + 'Inbox', + 'Info', + 'Input', + 'InsertChart', + 'InsertComment', + 'InsertDriveFile', + 'Insights', + 'Interests', + 'Inventory2', + 'Inventory', + 'KeyOff', + 'Key', + 'LabelImportant', + 'LabelOff', + 'Label', + 'Lan', + 'Landscape', + 'Language', + 'LastPage', + 'Launch', + 'LayersClear', + 'Layers', + 'Leaderboard', + 'LegendToggle', + 'LibraryAddCheck', + 'LibraryAdd', + 'LightMode', + 'Lightbulb', + 'LineAxis', + 'LineStyle', + 'LineWeight', + 'LinearScale', + 'LinkOff', + 'Link', + 'List', + 'LockOpen', + 'Lock', + 'LockReset', + 'Login', + 'Logout', + 'Loupe', + 'LowPriority', + 'Loyalty', + 'Mail', + 'ManageAccounts', + 'ManageHistory', + 'ManageSearch', + 'Map', + 'MapsUgc', + 'MarkAsUnread', + 'MeetingRoom', + 'Memory', + 'MenuBook', + 'MenuOpen', + 'Menu', + 'Merge', + 'MergeType', + 'Message', + 'MiscellaneousServices', + 'MoodBad', + 'Mood', + 'MoreHoriz', + 'MoreTime', + 'MoreVert', + 'MoveDown', + 'MoveToInbox', + 'MoveUp', + 'MultilineChart', + 'MultipleStop', + 'Nat', + 'NewReleases', + 'NightsStay', + 'NoAccounts', + 'NoEncryption', + 'NotStarted', + 'NoteAdd', + 'NotificationAdd', + 'NotificationImportant', + 'NotificationsActive', + 'NotificationsOff', + 'Notifications', + 'NotificationsPaused', + 'OpenInFull', + 'OpenInNew', + 'Outbound', + 'Outbox', + 'Output', + 'Pageview', + 'Password', + 'PauseCircle', + 'PendingActions', + 'Pending', + 'People', + 'PersonAddAlt1', + 'PersonOff', + 'Person', + 'PersonRemoveAlt1', + 'PersonSearch', + 'PinDrop', + 'PivotTableChart', + 'Place', + 'PlayArrow', + 'PlayCircle', + 'Policy', + 'Poll', + 'Polyline', + 'PostAdd', + 'Preview', + 'PrivacyTip', + 'PublicOff', + 'Public', + 'Publish', + 'PushPin', + 'QueryStats', + 'QuestionAnswer', + 'Queue', + 'Radar', + 'ReadMore', + 'Redo', + 'Refresh', + 'RemoveCircle', + 'Replay', + 'ReplyAll', + 'Reply', + 'Report', + 'ReportProblem', + 'Restore', + 'RocketLaunch', + 'Rocket', + 'Route', + 'RssFeed', + 'Rule', + 'RunningWithErrors', + 'SatelliteAlt', + 'SaveAlt', + 'Schedule', + 'Schema', + 'Science', + 'SearchOff', + 'Search', + 'Security', + 'Sell', + 'Sensors', + 'SentimentDissatisfied', + 'SentimentNeutral', + 'SentimentSatisfied', + 'Settings', + 'Share', + 'Shield', + 'ShortText', + 'Shortcut', + 'ShowChart', + 'Shuffle', + 'Signpost', + 'SkipNext', + 'SkipPrevious', + 'SortByAlpha', + 'Sort', + 'Source', + 'SpaceDashboard', + 'Speed', + 'SsidChart', + 'StackedBarChart', + 'StackedLineChart', + 'StarBorder', + 'StarHalf', + 'Star', + 'Start', + 'StickyNote2', + 'StopCircle', + 'Storage', + 'Storm', + 'Straight', + 'Stream', + 'Style', + 'SubdirectoryArrowLeft', + 'SubdirectoryArrowRight', + 'Subject', + 'Subscriptions', + 'SubtitlesOff', + 'Support', + 'SwapHoriz', + 'SwapVert', + 'SwitchAccount', + 'SwitchLeft', + 'SwitchRight', + 'SyncAlt', + 'SyncDisabled', + 'SyncLock', + 'Sync', + 'SyncProblem', + 'TableChart', + 'TableRows', + 'TableView', + 'Tag', + 'TaskAlt', + 'Terminal', + 'ThumbDown', + 'ThumbUp', + 'ThumbsUpDown', + 'Timelapse', + 'Timeline', + 'TipsAndUpdates', + 'Toc', + 'TrackChanges', + 'TrendingDown', + 'TrendingFlat', + 'TrendingUp', + 'Tune', + 'Tungsten', + 'TurnLeft', + 'TurnRight', + 'TurnSlightLeft', + 'TurnSlightRight', + 'Unarchive', + 'Undo', + 'UnfoldLessDouble', + 'UnfoldLess', + 'UnfoldMoreDouble', + 'UnfoldMore', + 'Unsubscribe', + 'Upcoming', + 'UpdateDisabled', + 'Update', + 'Upgrade', + 'UploadFile', + 'Upload', + 'Verified', + 'VerifiedUser', + 'ViewAgenda', + 'ViewArray', + 'ViewCarousel', + 'ViewColumn', + 'ViewComfy', + 'ViewCompact', + 'ViewCozy', + 'ViewDay', + 'ViewHeadline', + 'ViewKanban', + 'ViewList', + 'ViewModule', + 'ViewQuilt', + 'ViewSidebar', + 'ViewStream', + 'ViewTimeline', + 'ViewWeek', + 'VisibilityOff', + 'Visibility', + 'Warehouse', + 'Warning', + 'Webhook', + 'Whatshot', + 'Widgets', + 'Wifi', + 'Window', + 'WorkHistory', + 'WorkOff', + 'Work', + 'WorkspacePremium', + 'Workspaces', + 'Wysiwyg', + 'ZoomInMap', + 'ZoomIn', + 'ZoomOutMap', +]; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/index.ts b/datahub-web-react/src/alchemy-components/components/Icon/index.ts new file mode 100644 index 00000000000000..23ca0a7ef7da2f --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/index.ts @@ -0,0 +1,3 @@ +export { Icon, iconDefaults } from './Icon'; +export type { IconProps, IconNames } from './types'; +export { AVAILABLE_ICONS } from './constants'; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/types.ts b/datahub-web-react/src/alchemy-components/components/Icon/types.ts new file mode 100644 index 00000000000000..f5a050e9338a71 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/types.ts @@ -0,0 +1,23 @@ +import { HTMLAttributes } from 'react'; + +import type { FontSizeOptions, FontColorOptions, RotationOptions } from '@components/theme/config'; +import { AVAILABLE_ICONS } from './constants'; + +// Utility function to create an enum from an array of strings +function createEnum(values: T[]): { [K in T]: K } { + return values.reduce((acc, value) => { + acc[value] = value; + return acc; + }, Object.create(null)); +} + +const names = createEnum(AVAILABLE_ICONS); +export type IconNames = keyof typeof names; + +export interface IconProps extends HTMLAttributes { + icon: IconNames; + variant?: 'filled' | 'outline'; + size?: FontSizeOptions; + color?: FontColorOptions; + rotate?: RotationOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/Icon/utils.ts b/datahub-web-react/src/alchemy-components/components/Icon/utils.ts new file mode 100644 index 00000000000000..1137b3da28bc7a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/utils.ts @@ -0,0 +1,29 @@ +import * as materialIcons from '@mui/icons-material'; + +export const getIconNames = () => { + // We only want "Filled" (mui default) and "Outlined" icons + const filtered = Object.keys(materialIcons).filter( + (key) => + !key.includes('Filled') && !key.includes('TwoTone') && !key.includes('Rounded') && !key.includes('Sharp'), + ); + + const filled: string[] = []; + const outlined: string[] = []; + + filtered.forEach((key) => { + if (key.includes('Outlined')) { + outlined.push(key); + } else if (!key.includes('Outlined')) { + filled.push(key); + } + }); + + return { + filled, + outlined, + }; +}; + +export const getIconComponent = (icon: string) => { + return materialIcons[icon]; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx b/datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx new file mode 100644 index 00000000000000..053e952b62a2e9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx @@ -0,0 +1,177 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; +import { AVAILABLE_ICONS } from '../Icon'; + +import { Input, inputDefaults } from './Input'; + +const meta = { + title: 'Forms / Input', + component: Input, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get user input in a single line field.', + }, + }, + + // Component-level argTypes + argTypes: { + value: { + description: 'Value for the Input.', + table: { + defaultValue: { summary: inputDefaults.value as string }, + }, + control: { + type: 'text', + }, + }, + label: { + description: 'Label for the Input.', + table: { + defaultValue: { summary: inputDefaults.label }, + }, + control: { + type: 'text', + }, + }, + placeholder: { + description: 'Placeholder for the Input.', + table: { + defaultValue: { summary: inputDefaults.placeholder }, + }, + control: { + type: 'text', + }, + }, + icon: { + description: 'The icon to display in the Input.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + error: { + description: 'Enforce error state on the Input.', + table: { + defaultValue: { summary: inputDefaults.error }, + }, + control: { + type: 'text', + }, + }, + warning: { + description: 'Enforce warning state on the Input.', + table: { + defaultValue: { summary: inputDefaults.warning }, + }, + control: { + type: 'text', + }, + }, + isSuccess: { + description: 'Enforce success state on the Input.', + table: { + defaultValue: { summary: inputDefaults?.isSuccess?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Input is in disabled state.', + table: { + defaultValue: { summary: inputDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isInvalid: { + description: 'Whether the Input is an invalid state.', + table: { + defaultValue: { summary: inputDefaults?.isInvalid?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isReadOnly: { + description: 'Whether the Input is in readonly mode.', + table: { + defaultValue: { summary: inputDefaults?.isReadOnly?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isPassword: { + description: 'Whether the Input has a password type.', + table: { + defaultValue: { summary: inputDefaults?.isPassword?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Input is a required field.', + table: { + defaultValue: { summary: inputDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + args: { + value: inputDefaults.value, + label: inputDefaults.label, + placeholder: inputDefaults.placeholder, + icon: inputDefaults.icon, + error: inputDefaults.error, + warning: inputDefaults.warning, + isSuccess: inputDefaults.isSuccess, + isDisabled: inputDefaults.isDisabled, + isInvalid: inputDefaults.isInvalid, + isReadOnly: inputDefaults.isReadOnly, + isPassword: inputDefaults.isPassword, + isRequired: inputDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const status = () => ( + + + + + +); + +export const states = () => ( + + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Input/Input.tsx b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx new file mode 100644 index 00000000000000..976fc47ffc5948 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx @@ -0,0 +1,97 @@ +import { Tooltip } from '@components'; +import React from 'react'; + +import { InputProps } from './types'; + +import { ErrorMessage, InputContainer, InputField, InputWrapper, Label, Required, WarningMessage } from './components'; + +import { Icon } from '../Icon'; +import { getInputType } from './utils'; + +export const inputDefaults: InputProps = { + value: '', + setValue: () => {}, + label: 'Label', + placeholder: 'Placeholder', + error: '', + warning: '', + isSuccess: false, + isDisabled: false, + isInvalid: false, + isReadOnly: false, + isPassword: false, + isRequired: false, + errorOnHover: false, + type: 'text', +}; + +export const Input = ({ + value = inputDefaults.value, + setValue = inputDefaults.setValue, + label = inputDefaults.label, + placeholder = inputDefaults.placeholder, + icon, // default undefined + error = inputDefaults.error, + warning = inputDefaults.warning, + isSuccess = inputDefaults.isSuccess, + isDisabled = inputDefaults.isDisabled, + isInvalid = inputDefaults.isInvalid, + isReadOnly = inputDefaults.isReadOnly, + isPassword = inputDefaults.isPassword, + isRequired = inputDefaults.isRequired, + errorOnHover = inputDefaults.errorOnHover, + type = inputDefaults.type, + id, + ...props +}: InputProps) => { + // Invalid state is always true if error is present + let invalid = isInvalid; + if (error) invalid = true; + + // Show/hide password text + const [showPassword, setShowPassword] = React.useState(false); + const passwordIcon = showPassword ? 'Visibility' : 'VisibilityOff'; + + // Input base props + const inputBaseProps = { + label, + isSuccess, + error, + warning, + isDisabled, + isInvalid: invalid, + }; + + return ( + + {label && ( + + )} + + {icon && } + setValue?.(e.target.value)} + type={getInputType(type, isPassword, showPassword)} + placeholder={placeholder} + readOnly={isReadOnly} + disabled={isDisabled} + required={isRequired} + id={id} + /> + {!isPassword && ( + + {invalid && } + {isSuccess && } + {warning && } + + )} + {isPassword && setShowPassword(!showPassword)} icon={passwordIcon} size="lg" />} + + {invalid && error && !errorOnHover && {error}} + {warning && {warning}} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Input/components.ts b/datahub-web-react/src/alchemy-components/components/Input/components.ts new file mode 100644 index 00000000000000..d1c337642d9cd8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/components.ts @@ -0,0 +1,92 @@ +import styled from 'styled-components'; + +import theme, { borders, colors, radius, spacing, typography } from '@components/theme'; +import { getStatusColors } from '@components/theme/utils'; + +import { + INPUT_MAX_HEIGHT, + formLabelTextStyles, + inputValueTextStyles, + inputPlaceholderTextStyles, +} from '../commonStyles'; + +import type { InputProps } from './types'; + +const defaultFlexStyles = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const defaultMessageStyles = { + marginTop: spacing.xxsm, + fontSize: typography.fontSizes.sm, +}; + +export const InputWrapper = styled.div({ + ...defaultFlexStyles, + alignItems: 'flex-start', + flexDirection: 'column', + width: '100%', +}); + +export const InputContainer = styled.div( + ({ isSuccess, warning, isDisabled, isInvalid }: InputProps) => ({ + border: `${borders['1px']} ${getStatusColors(isSuccess, warning, isInvalid)}`, + backgroundColor: isDisabled ? colors.gray[100] : colors.white, + paddingRight: spacing.md, + }), + { + ...defaultFlexStyles, + width: '100%', + maxHeight: INPUT_MAX_HEIGHT, + overflow: 'hidden', + borderRadius: radius.md, + flex: 1, + color: colors.gray[400], // 1st icon color + + '&:focus-within': { + borderColor: colors.violet[200], + outline: `${borders['1px']} ${colors.violet[200]}`, + }, + }, +); + +export const InputField = styled.input({ + padding: `${spacing.sm} ${spacing.md}`, + lineHeight: typography.lineHeights.normal, + maxHeight: INPUT_MAX_HEIGHT, + border: borders.none, + width: '100%', + + // Shared common input text styles + ...inputValueTextStyles(), + + '&::placeholder': { + ...inputPlaceholderTextStyles, + }, + + '&:focus': { + outline: 'none', + }, +}); + +export const Required = styled.span({ + color: colors.red[500], +}); + +export const Label = styled.div({ + ...formLabelTextStyles, + marginBottom: spacing.xsm, + textAlign: 'left', +}); + +export const ErrorMessage = styled.div({ + ...defaultMessageStyles, + color: theme.semanticTokens.colors.error, +}); + +export const WarningMessage = styled.div({ + ...defaultMessageStyles, + color: theme.semanticTokens.colors.warning, +}); diff --git a/datahub-web-react/src/alchemy-components/components/Input/index.ts b/datahub-web-react/src/alchemy-components/components/Input/index.ts new file mode 100644 index 00000000000000..336a9b4dd08e97 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/index.ts @@ -0,0 +1,2 @@ +export { Input, inputDefaults } from './Input'; +export type { InputProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Input/types.ts b/datahub-web-react/src/alchemy-components/components/Input/types.ts new file mode 100644 index 00000000000000..1b2abf132d3283 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/types.ts @@ -0,0 +1,22 @@ +import { InputHTMLAttributes } from 'react'; + +import { IconNames } from '../Icon'; + +export interface InputProps extends InputHTMLAttributes { + value?: string | number | readonly string[] | undefined; + setValue?: React.Dispatch>; + label: string; + placeholder?: string; + icon?: IconNames; + error?: string; + warning?: string; + isSuccess?: boolean; + isDisabled?: boolean; + isInvalid?: boolean; + isReadOnly?: boolean; + isPassword?: boolean; + isRequired?: boolean; + errorOnHover?: boolean; + id?: string; + type?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/Input/utils.ts b/datahub-web-react/src/alchemy-components/components/Input/utils.ts new file mode 100644 index 00000000000000..142a93232485b3 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/utils.ts @@ -0,0 +1,5 @@ +export const getInputType = (type?: string, isPassword?: boolean, showPassword?: boolean) => { + if (type) return type; + if (isPassword && !showPassword) return 'password'; + return 'text'; +}; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx new file mode 100644 index 00000000000000..8cce0369918a2e --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import { LineChart } from './LineChart'; +import { getMockedProps } from '../BarChart/utils'; + +const meta = { + title: 'Charts / LineChart', + component: LineChart, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to show LineChart', + }, + }, + + // Component-level argTypes + argTypes: { + data: { + description: 'Array of datum to show', + }, + xAccessor: { + description: 'A function to convert datum to value of X', + }, + yAccessor: { + description: 'A function to convert datum to value of Y', + }, + renderTooltipContent: { + description: 'A function to replace default rendering of toolbar', + }, + margin: { + description: 'Add margins to chart', + }, + leftAxisTickFormat: { + description: 'A function to format labels of left axis', + }, + leftAxisTickLabelProps: { + description: 'Props for label of left axis', + }, + bottomAxisTickFormat: { + description: 'A function to format labels of bottom axis', + }, + bottomAxisTickLabelProps: { + description: 'Props for label of bottom axis', + }, + lineColor: { + description: 'Color of line on chart', + control: { + type: 'color', + }, + }, + areaColor: { + description: 'Color of area under line', + control: { + type: 'color', + }, + }, + gridColor: { + description: "Color of grid's lines", + control: { + type: 'color', + }, + }, + renderGradients: { + description: 'A function to render different gradients that can be used as colors', + }, + toolbarVerticalCrosshairStyle: { + description: "Styles of toolbar's vertical line", + }, + renderTooltipGlyph: { + description: 'A function to render a glyph', + }, + }, + + // Define defaults + args: { + ...getMockedProps(), + renderTooltipContent: (datum) => <>DATUM: {JSON.stringify(datum)}, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => ( +
+ +
+ ), +}; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx new file mode 100644 index 00000000000000..22580122ccf84f --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx @@ -0,0 +1,178 @@ +import { colors } from '@src/alchemy-components/theme'; +// import { abbreviateNumber } from '@src/app/dataviz/utils'; +import { TickLabelProps } from '@visx/axis'; +import { curveMonotoneX } from '@visx/curve'; +import { LinearGradient } from '@visx/gradient'; +import { ParentSize } from '@visx/responsive'; +import { AreaSeries, Axis, AxisScale, Grid, LineSeries, Tooltip, XYChart } from '@visx/xychart'; +import dayjs from 'dayjs'; +import React, { useState } from 'react'; +import { Popover } from '../Popover'; +import { ChartWrapper } from './components'; +import { LineChartProps } from './types'; +import { abbreviateNumber } from '../dataviz/utils'; + +const commonTickLabelProps: TickLabelProps = { + fontSize: 10, + fontFamily: 'Mulish', + fill: colors.gray[1700], +}; + +const GLYPH_DROP_SHADOW_FILTER = ` + drop-shadow(0px 1px 3px rgba(33, 23, 95, 0.30)) + drop-shadow(0px 2px 5px rgba(33, 23, 95, 0.25)) + drop-shadow(0px -2px 5px rgba(33, 23, 95, 0.25) +`; + +export const lineChartDefault: LineChartProps = { + data: [], + xAccessor: (datum) => datum?.x, + yAccessor: (datum) => datum?.y, + leftAxisTickFormat: abbreviateNumber, + leftAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'end', + }, + bottomAxisTickFormat: (x) => dayjs(x).format('D MMM'), + bottomAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'middle', + verticalAnchor: 'start', + }, + lineColor: colors.violet[500], + areaColor: 'url(#line-gradient)', + gridColor: '#e0e0e0', + renderGradients: () => ( + + ), + toolbarVerticalCrosshairStyle: { + stroke: colors.white, + strokeWidth: 2, + filter: GLYPH_DROP_SHADOW_FILTER, + }, + renderTooltipGlyph: (props) => { + return ( + <> + + + + ); + }, +}; + +export function LineChart({ + data, + xAccessor = lineChartDefault.xAccessor, + yAccessor = lineChartDefault.yAccessor, + renderTooltipContent, + margin, + leftAxisTickFormat = lineChartDefault.leftAxisTickFormat, + leftAxisTickLabelProps = lineChartDefault.leftAxisTickLabelProps, + bottomAxisTickFormat = lineChartDefault.bottomAxisTickFormat, + bottomAxisTickLabelProps = lineChartDefault.bottomAxisTickLabelProps, + lineColor = lineChartDefault.lineColor, + areaColor = lineChartDefault.areaColor, + gridColor = lineChartDefault.gridColor, + renderGradients = lineChartDefault.renderGradients, + toolbarVerticalCrosshairStyle = lineChartDefault.toolbarVerticalCrosshairStyle, + renderTooltipGlyph = lineChartDefault.renderTooltipGlyph, +}: LineChartProps) { + const [showGrid, setShowGrid] = useState(false); + + // FYI: additional margins to show left and bottom axises + const internalMargin = { + top: (margin?.top ?? 0) + 30, + right: (margin?.right ?? 0) + 20, + bottom: (margin?.bottom ?? 0) + 35, + left: (margin?.left ?? 0) + 40, + }; + + const accessors = { xAccessor, yAccessor }; + + return ( + setShowGrid(true)} onMouseLeave={() => setShowGrid(false)}> + + {({ width, height }) => { + return ( + + {renderGradients?.()} + + + + + + + + {showGrid && ( + + )} + + + dataKey="line-chart-seria-01" + data={data} + fill={areaColor} + curve={curveMonotoneX} + {...accessors} + /> + + dataKey="line-chart-seria-01" + data={data} + stroke={lineColor} + curve={curveMonotoneX} + {...accessors} + /> + + + snapTooltipToDatumX + snapTooltipToDatumY + showVerticalCrosshair + applyPositionStyle + showSeriesGlyphs + verticalCrosshairStyle={toolbarVerticalCrosshairStyle} + renderGlyph={renderTooltipGlyph} + unstyled + renderTooltip={({ tooltipData }) => { + return ( + tooltipData?.nearestDatum && ( + + ) + ); + }} + /> + + ); + }} + + + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/components.tsx b/datahub-web-react/src/alchemy-components/components/LineChart/components.tsx new file mode 100644 index 00000000000000..fb6c0cf1ced784 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/components.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const ChartWrapper = styled.div` + width: 100%; + height: 100%; + position: relative; + cursor: pointer; +`; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/index.ts b/datahub-web-react/src/alchemy-components/components/LineChart/index.ts new file mode 100644 index 00000000000000..7fca9300d578ca --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/index.ts @@ -0,0 +1 @@ +export { LineChart } from './LineChart'; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/types.ts b/datahub-web-react/src/alchemy-components/components/LineChart/types.ts new file mode 100644 index 00000000000000..cf45662ba7cf90 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/types.ts @@ -0,0 +1,22 @@ +import { TickFormatter, TickLabelProps } from '@visx/axis'; +import { Margin } from '@visx/xychart'; +import { RenderTooltipGlyphProps } from '@visx/xychart/lib/components/Tooltip'; +import React from 'react'; + +export type LineChartProps = { + data: DatumType[]; + xAccessor: (datum: DatumType) => string | number; + yAccessor: (datum: DatumType) => number; + renderTooltipContent?: (datum: DatumType) => React.ReactNode; + margin?: Margin; + leftAxisTickFormat?: TickFormatter; + leftAxisTickLabelProps?: TickLabelProps; + bottomAxisTickFormat?: TickFormatter; + bottomAxisTickLabelProps?: TickLabelProps; + lineColor?: string; + areaColor?: string; + gridColor?: string; + renderGradients?: () => React.ReactNode; + toolbarVerticalCrosshairStyle?: React.SVGProps; + renderTooltipGlyph?: (props: RenderTooltipGlyphProps) => React.ReactNode | undefined; +}; diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx new file mode 100644 index 00000000000000..7016ecbc7c90a0 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { PageTitle } from '.'; + +// Auto Docs +const meta = { + title: 'Pages / Page Title', + component: PageTitle, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render the title and subtitle for a page.', + }, + }, + + // Component-level argTypes + argTypes: { + title: { + description: 'The title text', + }, + subTitle: { + description: 'The subtitle text', + }, + variant: { + description: 'The variant of header based on its usage', + }, + }, + + // Define default args + args: { + title: 'Automations', + subTitle: 'Create & manage automations', + variant: 'pageHeader', + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const withLink = () => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas aliquet nulla id felis vehicula, et + posuere dui dapibus. Nullam rhoncus massa non tortor convallis, in blandit turpis + rutrum. Morbi tempus velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel + mollis eros. + + } + /> +); + +export const sectionHeader = () => ( + +); diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx new file mode 100644 index 00000000000000..3dcf42ff2fc0e2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { PageTitleProps } from './types'; +import { Container, SubTitle, Title } from './components'; +import { Pill } from '../Pills'; + +export const PageTitle = ({ title, subTitle, pillLabel, variant = 'pageHeader' }: PageTitleProps) => { + return ( + + + {title} + {pillLabel ? <Pill label={pillLabel} size="sm" clickable={false} /> : null} + + + {subTitle ? {subTitle} : null} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/components.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/components.ts new file mode 100644 index 00000000000000..328323434e0403 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/components.ts @@ -0,0 +1,52 @@ +import styled from 'styled-components'; +import { typography, colors } from '@components/theme'; +import { getHeaderSubtitleStyles, getHeaderTitleStyles } from './utils'; + +// Text Styles +const titleStyles = { + display: 'flex', + alignItems: 'center', + gap: 8, + fontWeight: typography.fontWeights.bold, + color: colors.gray[600], +}; + +const subTitleStyles = { + fontWeight: typography.fontWeights.normal, + color: colors.gray[1700], +}; + +// Default styles +const baseStyles = { + fontFamily: typography.fonts.body, + margin: 0, + + '& a': { + color: colors.violet[400], + textDecoration: 'none', + transition: 'color 0.15s ease', + + '&:hover': { + color: colors.violet[500], + }, + }, +}; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: start; + justify-content: start; +`; + +export const Title = styled.div<{ variant: string }>(({ variant }) => ({ + ...baseStyles, + ...titleStyles, + ...getHeaderTitleStyles(variant), +})); + +export const SubTitle = styled.div<{ variant: string }>(({ variant }) => ({ + ...baseStyles, + ...subTitleStyles, + ...getHeaderSubtitleStyles(variant), +})); diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/index.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/index.ts new file mode 100644 index 00000000000000..2888306f7c9a66 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/index.ts @@ -0,0 +1 @@ +export { PageTitle } from './PageTitle'; diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/types.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/types.ts new file mode 100644 index 00000000000000..fb1e207d0bbd7b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/types.ts @@ -0,0 +1,8 @@ +import React from 'react'; + +export interface PageTitleProps { + title: string; + subTitle?: string | React.ReactNode; + pillLabel?: string; + variant?: 'pageHeader' | 'sectionHeader'; +} diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts new file mode 100644 index 00000000000000..fe6d18688f31f1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts @@ -0,0 +1,27 @@ +import { typography } from '@components/theme'; + +export const getHeaderTitleStyles = (variant) => { + if (variant === 'sectionHeader') { + return { + fontSize: typography.fontSizes.lg, + lineHeight: typography.lineHeights.lg, + }; + } + return { + fontSize: typography.fontSizes['3xl'], + lineHeight: typography.lineHeights['3xl'], + }; +}; + +export const getHeaderSubtitleStyles = (variant) => { + if (variant === 'sectionHeader') { + return { + fontSize: typography.fontSizes.md, + lineHeight: typography.lineHeights.md, + }; + } + return { + fontSize: typography.fontSizes.lg, + lineHeight: typography.lineHeights.lg, + }; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx b/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx new file mode 100644 index 00000000000000..d5cdffef6d6bd3 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { AVAILABLE_ICONS } from '../Icon'; +import { Pill, pillDefault } from './Pill'; + +const meta = { + title: 'Components / Pill', + component: Pill, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to get pill', + }, + }, + + // Component-level argTypes + argTypes: { + label: { + description: 'Label for the Pill.', + table: { + defaultValue: { summary: pillDefault.label }, + }, + control: { + type: 'text', + }, + }, + leftIcon: { + description: 'The icon to display in the Pill icon.', + type: 'string', + options: AVAILABLE_ICONS, + control: { + type: 'select', + }, + }, + rightIcon: { + description: 'The icon to display in the Pill icon.', + type: 'string', + options: AVAILABLE_ICONS, + control: { + type: 'select', + }, + }, + size: { + description: 'The size of the pill.', + options: ['sm', 'md', 'lg', 'xl'], + table: { + defaultValue: { summary: pillDefault.size }, + }, + control: { + type: 'select', + }, + }, + variant: { + description: 'The size of the Pill.', + options: ['filled', 'outline'], + table: { + defaultValue: { summary: pillDefault.variant }, + }, + control: { + type: 'select', + }, + }, + colorScheme: { + description: 'The color of the Pill.', + options: ['violet', 'green', 'red', 'blue', 'gray'], + table: { + defaultValue: { summary: pillDefault.color }, + }, + control: { + type: 'select', + }, + }, + }, + + // Define defaults + args: { + label: pillDefault.label, + leftIcon: pillDefault.leftIcon, + rightIcon: pillDefault.rightIcon, + size: pillDefault.size, + variant: pillDefault.variant, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const sizes = () => ( + + + + + +); + +export const colors = () => ( + + + + + + + + +); + +export const withIcon = () => ( + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx b/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx new file mode 100644 index 00000000000000..898ec89fce5957 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx @@ -0,0 +1,42 @@ +import { Icon } from '@components'; +import React from 'react'; + +import { PillContainer, PillText } from './components'; +import { PillProps } from './types'; + +export const pillDefault: PillProps = { + label: 'Label', + size: 'md', + variant: 'filled', + clickable: true, +}; + +export function Pill({ + label = pillDefault.label, + size = pillDefault.size, + leftIcon, + rightIcon, + colorScheme, + variant = pillDefault.variant, + clickable = pillDefault.clickable, + id, + onClickRightIcon, + onClickLeftIcon, + onPillClick, +}: PillProps) { + return ( + + {leftIcon && } + {label} + {rightIcon && } + + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/Pills/components.ts b/datahub-web-react/src/alchemy-components/components/Pills/components.ts new file mode 100644 index 00000000000000..79734561a92da6 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/components.ts @@ -0,0 +1,33 @@ +import { spacing } from '@components/theme'; +import styled from 'styled-components'; + +import { PillStyleProps } from './types'; +import { getPillStyle } from './utils'; + +export const PillContainer = styled.div( + // Dynamic styles + (props: PillStyleProps) => ({ ...getPillStyle(props as PillStyleProps) }), + { + // Base root styles + display: 'inline-flex', + alignItems: 'center', + gap: spacing.xxsm, + cursor: 'pointer', + padding: '0px 8px', + borderRadius: '200px', + maxWidth: '100%', + + // Base Disabled styles + '&:disabled': { + cursor: 'not-allowed', + }, + }, +); + +export const PillText = styled.span({ + maxWidth: '100%', + display: 'block', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Pills/index.ts b/datahub-web-react/src/alchemy-components/components/Pills/index.ts new file mode 100644 index 00000000000000..85a76193db2670 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/index.ts @@ -0,0 +1 @@ +export { Pill } from './Pill'; diff --git a/datahub-web-react/src/alchemy-components/components/Pills/types.ts b/datahub-web-react/src/alchemy-components/components/Pills/types.ts new file mode 100644 index 00000000000000..17d4d12465e1ef --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/types.ts @@ -0,0 +1,18 @@ +import { ColorOptions, SizeOptions, VariantOptions } from '@src/alchemy-components/theme/config'; +import { HTMLAttributes } from 'react'; + +export interface PillStyleProps { + colorScheme?: ColorOptions; // need to keep colorScheme because HTMLAttributes also have color property + variant?: VariantOptions; + size?: SizeOptions; + clickable?: boolean; +} + +export interface PillProps extends HTMLAttributes, PillStyleProps { + label: string; + rightIcon?: string; + leftIcon?: string; + onClickRightIcon?: (e: React.MouseEvent) => void; + onClickLeftIcon?: (e: React.MouseEvent) => void; + onPillClick?: (e: React.MouseEvent) => void; +} diff --git a/datahub-web-react/src/alchemy-components/components/Pills/utils.ts b/datahub-web-react/src/alchemy-components/components/Pills/utils.ts new file mode 100644 index 00000000000000..832bf95640982b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/utils.ts @@ -0,0 +1,147 @@ +import { colors, typography } from '@src/alchemy-components/theme'; +import { getColor, getFontSize } from '@src/alchemy-components/theme/utils'; +import { PillStyleProps } from './types'; + +// Utility function to get color styles for pill - does not generate CSS +const getPillColorStyles = (variant, color) => { + const defaultStyles = { + bgColor: getColor(color, 100), + hoverBgColor: getColor('gray', 100), + borderColor: '', + activeBorderColor: getColor('violet', 500), + textColor: getColor(color, 600), + }; + + const colorOverrides = { + violet: { + textColor: getColor(color, 500), + bgColor: getColor('gray', 1000), + borderColor: 'transparent', + hoverBgColor: getColor(color, 100), + activeBorderColor: getColor(color, 500), + }, + blue: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1100), + borderColor: 'transparent', + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + red: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1200), + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + green: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1300), + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + yellow: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1400), + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + }; + + const styles = colorOverrides[color] || defaultStyles; + + if (variant === 'outline') { + return { + bgColor: colors.transparent, + borderColor: getColor('gray', 1400), + textColor: getColor(color, 600), + }; + } + + return styles; +}; + +// Generate variant styles for pill +const getPillVariantStyles = (variant, colorStyles) => + ({ + filled: { + backgroundColor: colorStyles.bgColor, + border: `1px solid transparent`, + color: colorStyles.textColor, + '&:hover': { + backgroundColor: colorStyles.hoverBgColor, + }, + }, + outline: { + backgroundColor: 'transparent', + border: `1px solid ${colorStyles.borderColor}`, + color: colorStyles.textColor, + '&:hover': { + backgroundColor: colorStyles.hoverBgColor, + border: `1px solid transparent`, + }, + '&:disabled': { + border: `1px solid transparent`, + }, + }, + text: { + color: colorStyles.textColor, + }, + }[variant]); + +// Generate font styles for pill +const getPillFontStyles = (size) => { + const baseFontStyles = { + fontFamily: typography.fonts.body, + fontWeight: typography.fontWeights.normal, + lineHeight: typography.lineHeights.none, + }; + + const sizeMap = { + xs: { fontSize: getFontSize(size), lineHeight: '16px' }, + sm: { fontSize: getFontSize(size), lineHeight: '22px' }, + md: { fontSize: getFontSize(size), lineHeight: '24px' }, + lg: { fontSize: getFontSize(size), lineHeight: '30px' }, + xl: { fontSize: getFontSize(size), lineHeight: '34px' }, + }; + + return { + ...baseFontStyles, + ...sizeMap[size], + }; +}; + +// Generate active styles for pill +const getPillActiveStyles = (styleColors) => ({ + borderColor: styleColors.activeBorderColor, +}); + +/* + * Main function to generate styles for pill + */ +export const getPillStyle = (props: PillStyleProps) => { + const { variant, colorScheme = 'gray', size, clickable = true } = props; + + // Get map of colors + const colorStyles = getPillColorStyles(variant, colorScheme); + + // Define styles for pill + let styles = { + ...getPillVariantStyles(variant, colorStyles), + ...getPillFontStyles(size), + '&:focus': { + ...getPillActiveStyles(colorStyles), + outline: 'none', // Remove default browser focus outline if needed + }, + '&:active': { + ...getPillActiveStyles(colorStyles), + }, + }; + if (!clickable) { + styles = { + ...styles, + pointerEvents: 'none', + }; + } + + return styles; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx b/datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx new file mode 100644 index 00000000000000..8f6ca61976b206 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx @@ -0,0 +1,6 @@ +import { Popover, PopoverProps } from 'antd'; +import * as React from 'react'; + +export default function DataHubPopover(props: PopoverProps & React.RefAttributes) { + return ; +} diff --git a/datahub-web-react/src/alchemy-components/components/Popover/index.ts b/datahub-web-react/src/alchemy-components/components/Popover/index.ts new file mode 100644 index 00000000000000..02df6c38e8c4ea --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Popover/index.ts @@ -0,0 +1 @@ +export { default as Popover } from './Popover'; diff --git a/datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx b/datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx new file mode 100644 index 00000000000000..cb3116d7b8941b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { GridList } from '@components/.docs/mdx-components'; +import { Radio, radioDefaults, RadioGroup } from './Radio'; +import { Heading } from '../Heading'; +import { RadioProps } from './types'; + +const MOCK_RADIOS: RadioProps[] = [ + { + label: 'Label 1', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 2', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 3', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, +]; + +const meta = { + title: 'Forms / Radio', + component: Radio, + parameters: { + layout: 'centered', + docs: { + subtitle: 'A component that is used to get user input in the state of a radio button.', + }, + }, + argTypes: { + label: { + description: 'Label for the Radio.', + table: { + defaultValue: { summary: radioDefaults.label }, + }, + control: { + type: 'text', + }, + }, + error: { + description: 'Enforce error state on the Radio.', + table: { + defaultValue: { summary: radioDefaults.error }, + }, + control: { + type: 'text', + }, + }, + isChecked: { + description: 'Whether the Radio is checked.', + table: { + defaultValue: { summary: radioDefaults?.isChecked?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Radio is in disabled state.', + table: { + defaultValue: { summary: radioDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Radio is a required field.', + table: { + defaultValue: { summary: radioDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + args: { + label: radioDefaults.label, + error: radioDefaults.error, + isChecked: radioDefaults.isChecked, + isDisabled: radioDefaults.isDisabled, + isRequired: radioDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const states = () => ( + + + + + + +); + +export const disabledStates = () => ( + + + + +); + +export const radioGroups = () => ( + +
+ Horizontal Radio Group + +
+
+ Vertical Radio Group + +
+
+); diff --git a/datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx b/datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx new file mode 100644 index 00000000000000..592c10ec88de8a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from 'react'; +import { RadioGroupProps, RadioProps } from './types'; +import { + RadioWrapper, + Checkmark, + HiddenInput, + Label, + Required, + RadioLabel, + RadioBase, + RadioGroupContainer, +} from './components'; + +export const radioDefaults = { + label: 'Label', + error: '', + isChecked: false, + isDisabled: false, + isRequired: false, + isVertical: false, + setIsChecked: () => {}, +}; + +export const Radio = ({ + label = radioDefaults.label, + error = radioDefaults.error, + isChecked = radioDefaults.isChecked, + isDisabled = radioDefaults.isDisabled, + isRequired = radioDefaults.isRequired, + setIsChecked = radioDefaults.setIsChecked, + ...props +}: RadioProps) => { + const [checked, setChecked] = useState(isChecked || false); + + useEffect(() => { + setChecked(isChecked || false); + }, [isChecked]); + + const id = props.id || `checkbox-${label}`; + + return ( + + + { + setChecked(true); + setIsChecked?.(true); + }} + aria-label={label} + aria-labelledby={id} + aria-checked={checked} + {...props} + /> + + + {label && ( + + + + )} + + ); +}; + +export const RadioGroup = ({ isVertical, radios }: RadioGroupProps) => { + if (!radios.length) { + return <>; + } + + return ( + + {radios.map((checkbox) => { + const props = { ...checkbox }; + return ( + + + + ); + })} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Radio/components.ts b/datahub-web-react/src/alchemy-components/components/Radio/components.ts new file mode 100644 index 00000000000000..027971be179584 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/components.ts @@ -0,0 +1,83 @@ +import { borders, colors, radius, spacing } from '@components/theme'; +import styled from 'styled-components'; +import { formLabelTextStyles } from '../commonStyles'; +import { getRadioBorderColor, getRadioCheckmarkColor } from './utils'; + +export const RadioWrapper = styled.div<{ disabled: boolean; error: string }>(({ disabled, error }) => ({ + position: 'relative', + margin: '20px', + width: '20px', + height: '20px', + border: `${borders['2px']} ${getRadioBorderColor(disabled, error)}`, + backgroundColor: colors.white, + borderRadius: radius.full, + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + marginRight: '40px', + cursor: !disabled ? 'pointer' : 'none', + transition: 'border 0.3s ease, outline 0.3s ease', + '&:hover': { + border: `${borders['2px']} ${!disabled && !error ? colors.violet[500] : getRadioBorderColor(disabled, error)}`, + outline: !disabled && !error ? `${borders['2px']} ${colors.gray[200]}` : 'none', + }, +})); + +export const RadioBase = styled.div({}); + +export const Label = styled.div({ + ...formLabelTextStyles, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const RadioLabel = styled.div({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const Required = styled.span({ + color: colors.red[500], + marginLeft: spacing.xxsm, +}); + +export const RadioHoverState = styled.div({ + border: `${borders['2px']} ${colors.violet[500]}`, + width: 'calc(100% - -3px)', + height: 'calc(100% - -3px)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: radius.full, +}); + +export const Checkmark = styled.div<{ checked: boolean; disabled: boolean; error: string }>( + ({ checked, disabled, error }) => ({ + width: 'calc(100% - 6px)', + height: 'calc(100% - 6px)', + borderRadius: radius.full, + background: getRadioCheckmarkColor(checked, disabled, error), + display: checked ? 'inline-block' : 'none', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }), +); + +export const HiddenInput = styled.input<{ checked: boolean }>({ + opacity: 0, + width: '20px', + height: '20px', +}); + +export const RadioGroupContainer = styled.div<{ isVertical?: boolean }>(({ isVertical }) => ({ + display: 'flex', + flexDirection: isVertical ? 'column' : 'row', + justifyContent: 'center', + alignItems: 'center', + gap: !isVertical ? spacing.md : spacing.none, + margin: !isVertical ? spacing.xxsm : spacing.none, +})); diff --git a/datahub-web-react/src/alchemy-components/components/Radio/types.ts b/datahub-web-react/src/alchemy-components/components/Radio/types.ts new file mode 100644 index 00000000000000..59fd15654f916a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/types.ts @@ -0,0 +1,16 @@ +import { InputHTMLAttributes } from 'react'; + +export interface RadioProps extends InputHTMLAttributes { + label?: string; + error?: string; + isChecked?: boolean; + setIsChecked?: React.Dispatch>; + isDisabled?: boolean; + isIntermediate?: boolean; + isRequired?: boolean; +} + +export interface RadioGroupProps { + isVertical?: boolean; + radios: RadioProps[]; +} diff --git a/datahub-web-react/src/alchemy-components/components/Radio/utils.ts b/datahub-web-react/src/alchemy-components/components/Radio/utils.ts new file mode 100644 index 00000000000000..ed9dcc35d303b4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/utils.ts @@ -0,0 +1,27 @@ +import { colors } from '@components/theme'; + +const radioBorderColors = { + default: colors.gray[400], + disabled: colors.gray[300], + error: colors.red[500], +}; + +const radioCheckmarkColors = { + default: colors.white, + disabled: colors.gray[300], + checked: colors.violet[500], + error: colors.red[500], +}; + +export function getRadioBorderColor(disabled: boolean, error: string) { + if (disabled) return radioBorderColors.disabled; + if (error) return radioCheckmarkColors.error; + return radioBorderColors.default; +} + +export function getRadioCheckmarkColor(checked: boolean, disabled: boolean, error: string) { + if (disabled) return radioCheckmarkColors.disabled; + if (error) return radioCheckmarkColors.error; + if (checked) return radioCheckmarkColors.checked; + return radioCheckmarkColors.default; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx new file mode 100644 index 00000000000000..b49159ba38a758 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx @@ -0,0 +1,339 @@ +import { Button, Icon, Pill, Text } from '@components'; +import { isEqual } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionButtonsContainer, + Container, + DescriptionContainer, + Dropdown, + FooterBase, + LabelContainer, + LabelsWrapper, + OptionContainer, + OptionLabel, + OptionList, + Placeholder, + SearchIcon, + SearchInput, + SearchInputContainer, + SelectAllOption, + SelectBase, + SelectLabel, + SelectValue, + StyledCancelButton, + StyledCheckbox, + StyledClearButton, +} from './components'; +import { ActionButtonsProps, SelectLabelDisplayProps, SelectOption, SelectProps } from './types'; +import { getFooterButtonSize } from './utils'; + +const SelectLabelDisplay = ({ + selectedValues, + options, + placeholder, + isMultiSelect, + removeOption, + disabledValues, + showDescriptions, +}: SelectLabelDisplayProps) => { + const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value)); + return ( + + {!!selectedOptions.length && + isMultiSelect && + selectedOptions.map((o) => { + const isDisabled = disabledValues?.includes(o.value); + return ( + { + e.stopPropagation(); + removeOption?.(o); + }} + clickable={!isDisabled} + /> + ); + })} + {!selectedValues.length && {placeholder}} + {!isMultiSelect && ( + <> + {selectedOptions[0]?.label} + {showDescriptions && !!selectedValues.length && ( + {selectedOptions[0]?.description} + )} + + )} + + ); +}; + +const SelectActionButtons = ({ + selectedValues, + isOpen, + isDisabled, + isReadOnly, + showClear, + handleClearSelection, + fontSize = 'md', +}: ActionButtonsProps) => { + return ( + + {showClear && selectedValues.length > 0 && !isDisabled && !isReadOnly && ( + + )} + + + ); +}; + +// Updated main component +export const selectDefaults: SelectProps = { + options: [], + label: '', + size: 'md', + showSearch: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + isMultiSelect: false, + showClear: false, + placeholder: 'Select an option', + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, +}; + +export const BasicSelect = ({ + options = selectDefaults.options, + label = selectDefaults.label, + values = [], + onCancel, + onUpdate, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + showClear = selectDefaults.showClear, + size = selectDefaults.size, + isMultiSelect = selectDefaults.isMultiSelect, + placeholder = selectDefaults.placeholder, + disabledValues = [], + showSelectAll = selectDefaults.showSelectAll, + selectAllLabel = selectDefaults.selectAllLabel, + showDescriptions = selectDefaults.showDescriptions, + ...props +}: SelectProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState(values); + const [tempValues, setTempValues] = useState(values); + const selectRef = useRef(null); + const [areAllSelected, setAreAllSelected] = useState(false); + + useEffect(() => { + if (values?.length > 0 && !isEqual(selectedValues, values)) { + setSelectedValues(values); + } + }, [values, selectedValues]); + + useEffect(() => { + setAreAllSelected(tempValues.length === options.length); + }, [options, tempValues]); + + const filteredOptions = useMemo( + () => options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), + [options, searchQuery], + ); + + const handleDocumentClick = useCallback((e: MouseEvent) => { + if (selectRef.current && !selectRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [handleDocumentClick]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + setTempValues(selectedValues); + setIsOpen((prev) => !prev); + } + }, [isDisabled, isReadOnly, selectedValues]); + + const handleOptionChange = useCallback( + (option: SelectOption) => { + const updatedValues = tempValues.includes(option.value) + ? tempValues.filter((val) => val !== option.value) + : [...tempValues, option.value]; + + setTempValues(isMultiSelect ? updatedValues : [option.value]); + }, + [tempValues, isMultiSelect], + ); + + const removeOption = useCallback( + (option: SelectOption) => { + const updatedValues = selectedValues.filter((val) => val !== option.value); + setSelectedValues(updatedValues); + }, + [selectedValues], + ); + + const handleUpdateClick = useCallback(() => { + setSelectedValues(tempValues); + setIsOpen(false); + if (onUpdate) { + onUpdate(tempValues); + } + }, [tempValues, onUpdate]); + + const handleCancelClick = useCallback(() => { + setIsOpen(false); + setTempValues(selectedValues); + if (onCancel) { + onCancel(); + } + }, [selectedValues, onCancel]); + + const handleClearSelection = useCallback(() => { + setSelectedValues([]); + setAreAllSelected(false); + setTempValues([]); + setIsOpen(false); + if (onUpdate) { + onUpdate([]); + } + }, [onUpdate]); + + const handleSelectAll = () => { + if (areAllSelected) { + setTempValues([]); + onUpdate?.([]); + } else { + const allValues = options.map((option) => option.value); + setTempValues(allValues); + onUpdate?.(allValues); + } + setAreAllSelected(!areAllSelected); + }; + + return ( + + {label && {label}} + + + + + {isOpen && ( + + {showSearch && ( + + setSearchQuery(e.target.value)} + style={{ fontSize: size || 'md' }} + /> + + + )} + + {showSelectAll && isMultiSelect && ( + !(disabledValues.length === options.length) && handleSelectAll()} + isDisabled={disabledValues.length === options.length} + > + + {selectAllLabel} + + + + )} + {filteredOptions.map((option) => ( + !isMultiSelect && handleOptionChange(option)} + isSelected={tempValues.includes(option.value)} + isMultiSelect={isMultiSelect} + isDisabled={disabledValues?.includes(option.value)} + > + {isMultiSelect ? ( + + {option.label} + handleOptionChange(option)} + checked={tempValues.includes(option.value)} + disabled={disabledValues?.includes(option.value)} + /> + + ) : ( + + + {option.label} + + {!!option.description && ( + + {option.description} + + )} + + )} + + ))} + + + + Cancel + + + + + )} + + ); +}; + +export default BasicSelect; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx new file mode 100644 index 00000000000000..8a7d3670b2b1b9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx @@ -0,0 +1,309 @@ +import React, { useState, useMemo, useEffect } from 'react'; + +import { colors, Icon } from '@components'; +import theme from '@components/theme'; +import styled from 'styled-components'; +import { Checkbox } from 'antd'; + +import { OptionLabel } from '../components'; +import { SelectOption } from './types'; + +const ParentOption = styled.div` + display: flex; + align-items: center; +`; + +const ChildOptions = styled.div` + padding-left: 20px; +`; + +const StyledCheckbox = styled(Checkbox)<{ checked: boolean; indeterminate?: boolean }>` + .ant-checkbox-inner { + border: 1px solid ${colors.gray[300]} !important; + border-radius: 3px; + } + margin-left: auto; + ${(props) => + props.checked && + !props.indeterminate && + ` + .ant-checkbox-inner { + background-color: ${theme.semanticTokens.colors.primary}; + border-color: ${theme.semanticTokens.colors.primary} !important; + } + `} + ${(props) => + props.indeterminate && + ` + .ant-checkbox-inner { + &:after { + background-color: ${theme.semanticTokens.colors.primary}; + } + } + `} + ${(props) => + props.disabled && + ` + .ant-checkbox-inner { + background-color: ${colors.gray[200]} !important; + } + `} +`; + +function getChildrenRecursively( + directChildren: SelectOption[], + parentValueToOptions: { [parentValue: string]: SelectOption[] }, +) { + const visitedParents = new Set(); + let allChildren: SelectOption[] = []; + + function getChildren(parentValue: string) { + const newChildren = parentValueToOptions[parentValue] || []; + if (visitedParents.has(parentValue) || !newChildren.length) { + return; + } + + visitedParents.add(parentValue); + allChildren = [...allChildren, ...newChildren]; + newChildren.forEach((child) => getChildren(child.value || child.value)); + } + + directChildren.forEach((c) => getChildren(c.value || c.value)); + + return allChildren; +} + +interface OptionProps { + option: SelectOption; + selectedOptions: SelectOption[]; + parentValueToOptions: { [parentValue: string]: SelectOption[] }; + areParentsSelectable: boolean; + handleOptionChange: (node: SelectOption) => void; + addOptions: (nodes: SelectOption[]) => void; + removeOptions: (nodes: SelectOption[]) => void; + loadData?: (node: SelectOption) => void; + isMultiSelect?: boolean; + isLoadingParentChildList?: boolean; + setSelectedOptions: React.Dispatch>; +} + +export const NestedOption = ({ + option, + selectedOptions, + parentValueToOptions, + handleOptionChange, + addOptions, + removeOptions, + loadData, + isMultiSelect, + areParentsSelectable, + isLoadingParentChildList, + setSelectedOptions, +}: OptionProps) => { + const [autoSelectChildren, setAutoSelectChildren] = useState(false); + const [loadingParentUrns, setLoadingParentUrns] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const directChildren = useMemo( + () => parentValueToOptions[option.value] || [], + [parentValueToOptions, option.value], + ); + + const recursiveChildren = useMemo( + () => getChildrenRecursively(directChildren, parentValueToOptions), + [directChildren, parentValueToOptions], + ); + + const children = useMemo(() => [...directChildren, ...recursiveChildren], [directChildren, recursiveChildren]); + const selectableChildren = useMemo( + () => (areParentsSelectable ? children : children.filter((c) => !c.isParent)), + [areParentsSelectable, children], + ); + const parentChildren = useMemo(() => children.filter((c) => c.isParent), [children]); + + useEffect(() => { + if (autoSelectChildren && selectableChildren.length) { + addOptions(selectableChildren); + setAutoSelectChildren(false); + } + }, [autoSelectChildren, selectableChildren, addOptions]); + + const areAllChildrenSelected = useMemo( + () => selectableChildren.every((child) => selectedOptions.find((o) => o.value === child.value)), + [selectableChildren, selectedOptions], + ); + + const areAnyChildrenSelected = useMemo( + () => selectableChildren.some((child) => selectedOptions.find((o) => o.value === child.value)), + [selectableChildren, selectedOptions], + ); + + const areAnyUnselectableChildrenUnexpanded = !!parentChildren.find( + (parent) => !selectableChildren.find((child) => child.parentValue === parent.value), + ); + + const isSelected = useMemo( + () => + !!selectedOptions.find((o) => o.value === option.value) || + (!areParentsSelectable && + !!option.isParent && + !!selectableChildren.length && + areAllChildrenSelected && + !areAnyUnselectableChildrenUnexpanded), + [ + selectedOptions, + areAllChildrenSelected, + areAnyUnselectableChildrenUnexpanded, + areParentsSelectable, + option.isParent, + option.value, + selectableChildren.length, + ], + ); + + const isImplicitlySelected = useMemo( + () => !option.isParent && !!selectedOptions.find((o) => o.value === option.parentValue), + [selectedOptions, option.isParent, option.parentValue], + ); + + const isParentMissingChildren = useMemo(() => !!option.isParent && !children.length, [children, option.isParent]); + + const isPartialSelected = useMemo( + () => + (!areAllChildrenSelected && areAnyChildrenSelected) || + (isSelected && isParentMissingChildren) || + (isSelected && areAnyUnselectableChildrenUnexpanded) || + (areAnyUnselectableChildrenUnexpanded && areAnyChildrenSelected) || + (isSelected && !!children.length && !areAnyChildrenSelected), + [ + isSelected, + children, + areAllChildrenSelected, + areAnyChildrenSelected, + areAnyUnselectableChildrenUnexpanded, + isParentMissingChildren, + ], + ); + + const selectOption = () => { + if (areParentsSelectable && option.isParent) { + const existingSelectedOptions = new Set(selectedOptions.map((opt) => opt.value)); + const existingChildSelectedOptions = + selectedOptions.filter((opt) => opt.parentValue === option.value) || []; + if (existingSelectedOptions.has(option.value)) { + removeOptions([option]); + } else { + // filter out the childrens of parent selection as we are allowing implicitly selection + const filteredOptions = selectedOptions.filter( + (selectedOption) => !existingChildSelectedOptions.find((o) => o.value === selectedOption.value), + ); + const newSelectedOptions = [...filteredOptions, option]; + + setSelectedOptions(newSelectedOptions); + } + } else if (isPartialSelected || (!isSelected && !areAnyChildrenSelected)) { + const optionsToAdd = option.isParent && !areParentsSelectable ? selectableChildren : [option]; + addOptions(optionsToAdd); + } else if (areAllChildrenSelected) { + removeOptions([option, ...selectableChildren]); + } else { + handleOptionChange(option); + } + }; + + // one loader variable for fetching data for expanded parents and their respective child nodes + useEffect(() => { + // once loading has been done just remove all the parent node urn + if (!isLoadingParentChildList) { + setLoadingParentUrns([]); + } + }, [isLoadingParentChildList]); + + return ( +
+ + { + e.preventDefault(); + if (isImplicitlySelected) { + return; + } + if (isParentMissingChildren) { + setLoadingParentUrns((previousIds) => [...previousIds, option.value]); + loadData?.(option); + } + if (option.isParent) { + setIsOpen(!isOpen); + } else { + selectOption(); + } + }} + isSelected={!isMultiSelect && isSelected} + // added hack to show cursor in wait untill we get the inline spinner + style={{ width: '100%', cursor: loadingParentUrns.includes(option.value) ? 'wait' : 'pointer' }} + > + {option.isParent && {option.label}} + {!option.isParent && <>{option.label}} + {option.isParent && ( + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + if (!isOpen && isParentMissingChildren) { + setLoadingParentUrns((previousIds) => [...previousIds, option.value]); + loadData?.(option); + } + }} + icon="ChevronLeft" + rotate={isOpen ? '90' : '270'} + size="xl" + color="gray" + style={{ cursor: 'pointer', marginLeft: '4px' }} + /> + )} + { + e.preventDefault(); + if (isImplicitlySelected) { + return; + } + e.stopPropagation(); + if (isParentMissingChildren) { + loadData?.(option); + if (!areParentsSelectable) { + setAutoSelectChildren(true); + } + } + selectOption(); + }} + disabled={isImplicitlySelected} + /> + + + {isOpen && ( + + {directChildren.map((child) => ( + + ))} + + )} +
+ ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx new file mode 100644 index 00000000000000..744c7bfcfec0d2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx @@ -0,0 +1,312 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import { Icon, Pill } from '@components'; + +import { + ActionButtonsContainer, + Container, + Dropdown, + OptionList, + Placeholder, + SearchIcon, + SearchInput, + SearchInputContainer, + SelectBase, + SelectLabel, + StyledClearButton, +} from '../components'; + +import { SelectSizeOptions } from '../types'; +import { NestedOption } from './NestedOption'; +import { SelectOption } from './types'; + +const NO_PARENT_VALUE = 'no_parent_value'; + +const LabelDisplayWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px; + max-height: 125px; + min-height: 16px; +`; + +interface SelectLabelDisplayProps { + selectedOptions: SelectOption[]; + placeholder: string; + handleOptionChange: (node: SelectOption) => void; +} + +const SelectLabelDisplay = ({ selectedOptions, placeholder, handleOptionChange }: SelectLabelDisplayProps) => { + return ( + + {!!selectedOptions.length && + selectedOptions.map((o) => ( + { + e.stopPropagation(); + handleOptionChange(o); + }} + /> + ))} + {!selectedOptions.length && {placeholder}} + + ); +}; + +export interface ActionButtonsProps { + fontSize?: SelectSizeOptions; + selectedOptions: SelectOption[]; + isOpen: boolean; + isDisabled: boolean; + isReadOnly: boolean; + handleClearSelection: () => void; +} + +const SelectActionButtons = ({ + selectedOptions, + isOpen, + isDisabled, + isReadOnly, + handleClearSelection, + fontSize = 'md', +}: ActionButtonsProps) => { + return ( + + {!!selectedOptions.length && !isDisabled && !isReadOnly && ( + + )} + + + ); +}; + +export interface SelectProps { + options: SelectOption[]; + label: string; + value?: string; + initialValues?: SelectOption[]; + onCancel?: () => void; + onUpdate?: (selectedValues: SelectOption[]) => void; + size?: SelectSizeOptions; + showSearch?: boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + isMultiSelect?: boolean; + areParentsSelectable?: boolean; + loadData?: (node: SelectOption) => void; + onSearch?: (query: string) => void; + width?: number | 'full'; + height?: number; + placeholder?: string; + searchPlaceholder?: string; + isLoadingParentChildList?: boolean; +} + +export const selectDefaults: SelectProps = { + options: [], + label: '', + size: 'md', + showSearch: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + isMultiSelect: false, + width: 255, + height: 425, +}; + +export const NestedSelect = ({ + options = selectDefaults.options, + label = selectDefaults.label, + initialValues = [], + onUpdate, + loadData, + onSearch, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + isMultiSelect = selectDefaults.isMultiSelect, + size = selectDefaults.size, + areParentsSelectable = true, + placeholder, + searchPlaceholder, + height = selectDefaults.height, + isLoadingParentChildList = false, + ...props +}: SelectProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedOptions, setSelectedOptions] = useState(initialValues); + const selectRef = useRef(null); + + // TODO: handle searching inside of a nested component on the FE only + + const handleDocumentClick = useCallback((e: MouseEvent) => { + if (selectRef.current && !selectRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [handleDocumentClick]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + setIsOpen((prev) => !prev); + } + }, [isDisabled, isReadOnly]); + + const handleSearch = useCallback( + (query: string) => { + setSearchQuery(query); + onSearch?.(query); + }, + [onSearch], + ); + + // Instead of calling the update function individually whenever selectedOptions changes, + // we use the useEffect hook to trigger the onUpdate function automatically when selectedOptions is updated. + useEffect(() => { + if (onUpdate) { + onUpdate(selectedOptions); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedOptions]); + + const handleOptionChange = useCallback( + (option: SelectOption) => { + let newSelectedOptions: SelectOption[]; + if (selectedOptions.find((o) => o.value === option.value)) { + newSelectedOptions = selectedOptions.filter((o) => o.value !== option.value); + } else { + newSelectedOptions = [...selectedOptions, option]; + } + setSelectedOptions(newSelectedOptions); + if (!isMultiSelect) { + setIsOpen(false); + } + }, + [selectedOptions, isMultiSelect], + ); + + const addOptions = useCallback( + (optionsToAdd: SelectOption[]) => { + const existingValues = new Set(selectedOptions.map((option) => option.value)); + const filteredOptionsToAdd = optionsToAdd.filter((option) => !existingValues.has(option.value)); + if (filteredOptionsToAdd.length) { + const newSelectedOptions = [...selectedOptions, ...filteredOptionsToAdd]; + setSelectedOptions(newSelectedOptions); + } + }, + [selectedOptions], + ); + + const removeOptions = useCallback( + (optionsToRemove: SelectOption[]) => { + const newValues = selectedOptions.filter( + (selectedOption) => !optionsToRemove.find((o) => o.value === selectedOption.value), + ); + setSelectedOptions(newValues); + }, + [selectedOptions], + ); + + const handleClearSelection = useCallback(() => { + setSelectedOptions([]); + setIsOpen(false); + if (onUpdate) { + onUpdate([]); + } + }, [onUpdate]); + + // generate map for options to quickly fetch children + const parentValueToOptions: { [parentValue: string]: SelectOption[] } = {}; + options.forEach((o) => { + const parentValue = o.parentValue || NO_PARENT_VALUE; + parentValueToOptions[parentValue] = parentValueToOptions[parentValue] + ? [...parentValueToOptions[parentValue], o] + : [o]; + }); + + const rootOptions = parentValueToOptions[NO_PARENT_VALUE] || []; + + return ( + + {label && {label}} + + + + + {isOpen && ( + + {showSearch && ( + + handleSearch(e.target.value)} + style={{ fontSize: size || 'md', width: '100%' }} + /> + + + )} + + {rootOptions.map((option) => ( + + ))} + + + )} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts new file mode 100644 index 00000000000000..62d4541fce0d3d --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts @@ -0,0 +1,9 @@ +import { Entity } from '@src/types.generated'; + +export interface SelectOption { + value: string; + label: string; + parentValue?: string; + isParent?: boolean; + entity?: Entity; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx new file mode 100644 index 00000000000000..0ec20b15e771ab --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx @@ -0,0 +1,431 @@ +import { GridList } from '@components/.docs/mdx-components'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Select, selectDefaults } from './Select'; +import { SimpleSelect } from './SimpleSelect'; +import { SelectSizeOptions } from './types'; + +// Auto Docs +const meta: Meta = { + title: 'Forms / Select', + component: Select, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'This component allows users to select one or multiple input options from a dropdown list.', + }, + }, + + // Component-level argTypes + argTypes: { + options: { + description: 'Array of options for the Select component.', + control: { + type: 'object', + }, + table: { + defaultValue: { summary: JSON.stringify(selectDefaults.options) }, + }, + }, + label: { + description: 'Label for the Select component.', + control: { + type: 'text', + }, + table: { + defaultValue: { summary: selectDefaults.label }, + }, + }, + values: { + description: 'Selected values for the Select component.', + control: { + type: 'object', + }, + table: { + defaultValue: { summary: selectDefaults.values?.toString() }, + }, + }, + showSearch: { + description: 'Whether to show the search input.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.showSearch?.toString() }, + }, + }, + isDisabled: { + description: 'Whether the Select component is disabled.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isDisabled?.toString() }, + }, + }, + isReadOnly: { + description: 'Whether the Select component is read-only.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isReadOnly?.toString() }, + }, + }, + isRequired: { + description: 'Whether the Select component is required.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isRequired?.toString() }, + }, + }, + size: { + description: 'Size of the Select component.', + control: { + type: 'select', + options: ['sm', 'md', 'lg'], + }, + table: { + defaultValue: { summary: selectDefaults.size }, + }, + }, + width: { + description: 'Width of the Select component.', + control: { + type: 'number', + }, + table: { + defaultValue: { summary: `${selectDefaults.width}` }, + }, + }, + isMultiSelect: { + description: 'Whether the Select component allows multiple values to be selected.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isMultiSelect?.toString() }, + }, + }, + placeholder: { + description: 'Placeholder for the Select component.', + control: { + type: 'text', + }, + table: { + defaultValue: { summary: selectDefaults.placeholder }, + }, + }, + disabledValues: { + description: 'Disabled values for the multi-select component.', + control: { + type: 'object', + }, + table: { + defaultValue: { summary: selectDefaults.disabledValues?.toString() }, + }, + }, + showSelectAll: { + description: 'Whether the multi select component shows Select All button.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.showSelectAll?.toString() }, + }, + }, + selectAllLabel: { + description: 'Label for Select All button.', + control: { + type: 'text', + }, + table: { + defaultValue: { summary: selectDefaults.selectAllLabel }, + }, + }, + showDescriptions: { + description: 'Whether to show descriptions with the select options.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.showDescriptions?.toString() }, + }, + }, + }, + + // Define defaults + args: { + options: [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ], + label: 'Select Label', + values: undefined, + showSearch: selectDefaults.showSearch, + isDisabled: selectDefaults.isDisabled, + isReadOnly: selectDefaults.isReadOnly, + isRequired: selectDefaults.isRequired, + onCancel: () => console.log('Cancel clicked'), + onUpdate: (selectedValues: string[]) => console.log('Update clicked', selectedValues), + size: 'md', // Default size + width: 255, + isMultiSelect: selectDefaults.isMultiSelect, + placeholder: selectDefaults.placeholder, + disabledValues: undefined, + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +const sizeOptions: SelectSizeOptions[] = ['sm', 'md', 'lg']; + +export const simpleSelectSandbox: Story = { + tags: ['dev'], + + render: (props) => ( + + ), +}; + +export const simpleSelectStates = () => ( + + <> + + + + + +); + +export const simpleSelectWithSearch = () => ( + +); + +export const simpleSelectWithMultiSelect = () => ( + +); + +export const simpleSelectWithDisabledValues = () => ( + +); + +export const simpleSelectWithSelectAll = () => ( + +); + +export const simpleSelectWithDescriptions = () => ( + +); + +export const simpleSelectSizes = () => ( + + {sizeOptions.map((size, index) => ( + + ))} + +); + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const BasicSelectSandbox: Story = { + tags: ['dev'], + + render: (props) => ( + + + + +); + +export const withSearch = () => ( + +); + +export const sizes = () => ( + + {sizeOptions.map((size, index) => ( + alert('Cancel clicked')} + onUpdate={(selectedValues) => alert(`Update clicked with values: ${selectedValues}`)} + size="md" + /> + +); diff --git a/datahub-web-react/src/alchemy-components/components/Select/Select.tsx b/datahub-web-react/src/alchemy-components/components/Select/Select.tsx new file mode 100644 index 00000000000000..da28f090565431 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Select.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { BasicSelect } from './BasicSelect'; +import { SelectProps } from './types'; + +export const selectDefaults: SelectProps = { + options: [], + label: '', + showSearch: false, + values: undefined, + size: 'md', + isDisabled: false, + isReadOnly: false, + isRequired: false, + width: 255, + isMultiSelect: false, + placeholder: 'Select an option', + disabledValues: undefined, + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, +}; + +export const Select = ({ + options = selectDefaults.options, + label = selectDefaults.label, + values = [], + onCancel, + onUpdate, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + size = selectDefaults.size, + width = selectDefaults.width, + isMultiSelect = selectDefaults.isMultiSelect, + placeholder = selectDefaults.placeholder, + disabledValues = selectDefaults.disabledValues, + showSelectAll = selectDefaults.showSelectAll, + selectAllLabel = selectDefaults.selectAllLabel, + showDescriptions = selectDefaults.showDescriptions, + ...props +}: SelectProps) => { + return ( + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx new file mode 100644 index 00000000000000..be1184cee9e9f5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx @@ -0,0 +1,299 @@ +import { Icon, Pill, Text } from '@components'; +import { isEqual } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionButtonsContainer, + Container, + DescriptionContainer, + Dropdown, + LabelContainer, + LabelsWrapper, + OptionContainer, + OptionLabel, + OptionList, + Placeholder, + SearchIcon, + SearchInput, + SearchInputContainer, + SelectAllOption, + SelectBase, + SelectLabel, + SelectValue, + StyledCheckbox, + StyledClearButton, +} from './components'; +import { ActionButtonsProps, SelectLabelDisplayProps, SelectOption, SelectProps } from './types'; + +const SelectLabelDisplay = ({ + selectedValues, + options, + placeholder, + isMultiSelect, + removeOption, + disabledValues, + showDescriptions, +}: SelectLabelDisplayProps) => { + const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value)); + return ( + + {!!selectedOptions.length && + isMultiSelect && + selectedOptions.map((o) => { + const isDisabled = disabledValues?.includes(o.value); + return ( + { + e.stopPropagation(); + removeOption?.(o); + }} + clickable={!isDisabled} + /> + ); + })} + {!selectedValues.length && {placeholder}} + {!isMultiSelect && ( + <> + {selectedOptions[0]?.label} + {showDescriptions && !!selectedValues.length && ( + {selectedOptions[0]?.description} + )} + + )} + + ); +}; + +const SelectActionButtons = ({ + selectedValues, + isOpen, + isDisabled, + isReadOnly, + showClear, + handleClearSelection, + fontSize = 'md', +}: ActionButtonsProps) => { + return ( + + {showClear && selectedValues.length > 0 && !isDisabled && !isReadOnly && ( + + )} + + + ); +}; + +export const selectDefaults: SelectProps = { + options: [], + label: '', + size: 'md', + showSearch: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + showClear: true, + width: 255, + isMultiSelect: false, + placeholder: 'Select an option ', + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, +}; + +export const SimpleSelect = ({ + options = selectDefaults.options, + label = selectDefaults.label, + values = [], + onUpdate, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + showClear = selectDefaults.showClear, + size = selectDefaults.size, + isMultiSelect = selectDefaults.isMultiSelect, + placeholder = selectDefaults.placeholder, + disabledValues = [], + showSelectAll = selectDefaults.showSelectAll, + selectAllLabel = selectDefaults.selectAllLabel, + optionListTestId, + showDescriptions = selectDefaults.showDescriptions, + ...props +}: SelectProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState(values); + const selectRef = useRef(null); + const [areAllSelected, setAreAllSelected] = useState(false); + + useEffect(() => { + if (values?.length > 0 && !isEqual(selectedValues, values)) { + setSelectedValues(values); + } + }, [values, selectedValues]); + + useEffect(() => { + setAreAllSelected(selectedValues.length === options.length); + }, [options, selectedValues]); + + const filteredOptions = useMemo( + () => options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), + [options, searchQuery], + ); + + const handleDocumentClick = useCallback((e: MouseEvent) => { + if (selectRef.current && !selectRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [handleDocumentClick]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + setIsOpen((prev) => !prev); + } + }, [isDisabled, isReadOnly]); + + const handleOptionChange = useCallback( + (option: SelectOption) => { + const updatedValues = selectedValues.includes(option.value) + ? selectedValues.filter((val) => val !== option.value) + : [...selectedValues, option.value]; + + setSelectedValues(isMultiSelect ? updatedValues : [option.value]); + if (onUpdate) { + onUpdate(isMultiSelect ? updatedValues : [option.value]); + } + if (!isMultiSelect) setIsOpen(false); + }, + [onUpdate, isMultiSelect, selectedValues], + ); + + const handleClearSelection = useCallback(() => { + setSelectedValues([]); + setAreAllSelected(false); + setIsOpen(false); + if (onUpdate) { + onUpdate([]); + } + }, [onUpdate]); + + const handleSelectAll = () => { + if (areAllSelected) { + setSelectedValues([]); + onUpdate?.([]); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onUpdate?.(allValues); + } + setAreAllSelected(!areAllSelected); + }; + + return ( + + {label && {label}} + + + + + {isOpen && ( + + {showSearch && ( + + setSearchQuery(e.target.value)} + style={{ fontSize: size || 'md' }} + /> + + + )} + + {showSelectAll && isMultiSelect && ( + !(disabledValues.length === options.length) && handleSelectAll()} + isDisabled={disabledValues.length === options.length} + > + + {selectAllLabel} + + + + )} + {filteredOptions.map((option) => ( + !isMultiSelect && handleOptionChange(option)} + isSelected={selectedValues.includes(option.value)} + isMultiSelect={isMultiSelect} + isDisabled={disabledValues?.includes(option.value)} + > + {isMultiSelect ? ( + + {option.label} + handleOptionChange(option)} + checked={selectedValues.includes(option.value)} + disabled={disabledValues?.includes(option.value)} + /> + + ) : ( + + + {option.label} + + {!!option.description && ( + + {option.description} + + )} + + )} + + ))} + + + )} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/components.ts b/datahub-web-react/src/alchemy-components/components/Select/components.ts new file mode 100644 index 00000000000000..a360238fef4923 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/components.ts @@ -0,0 +1,235 @@ +import { Button, Icon } from '@components'; +import { borders, colors, radius, shadows, spacing, transition, typography } from '@components/theme'; +import { Checkbox } from 'antd'; +import styled from 'styled-components'; +import { formLabelTextStyles, inputPlaceholderTextStyles, inputValueTextStyles } from '../commonStyles'; +import { SelectSizeOptions, SelectStyleProps } from './types'; +import { getOptionLabelStyle, getSelectFontStyles, getSelectStyle } from './utils'; + +const sharedTransition = `${transition.property.colors} ${transition.easing['ease-in-out']} ${transition.duration.normal}`; + +/** + * Base Select component styling + */ +export const SelectBase = styled.div(({ isDisabled, isReadOnly, fontSize, isOpen }) => ({ + ...getSelectStyle({ isDisabled, isReadOnly, fontSize, isOpen }), + display: 'flex', + flexDirection: 'row' as const, + gap: spacing.xsm, + transition: sharedTransition, + justifyContent: 'space-between', + alignItems: 'center', + overflow: 'auto', + backgroundColor: isDisabled ? colors.gray[100] : 'white', +})); + +/** + * Styled components specific to the Basic version of the Select component + */ + +// Container for the Basic Select component +interface ContainerProps { + size: SelectSizeOptions; + width?: number | 'full'; +} + +export const Container = styled.div(({ size, width }) => ({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + width: width === 'full' ? '100%' : `${width}px`, + gap: '4px', + transition: sharedTransition, + minWidth: '175px', + ...getSelectFontStyles(size), + ...inputValueTextStyles(size), +})); + +export const Dropdown = styled.div({ + position: 'absolute', + top: '100%', + left: 0, + right: 0, + borderRadius: radius.md, + background: colors.white, + zIndex: 1, + transition: sharedTransition, + boxShadow: shadows.dropdown, + padding: spacing.xsm, + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginTop: '4px', + maxHeight: '360px', + overflow: 'auto', +}); + +export const SearchInputContainer = styled.div({ + position: 'relative', + width: '100%', + display: 'flex', + justifyContent: 'center', +}); + +export const SearchInput = styled.input({ + width: '100%', + borderRadius: radius.md, + border: `1px solid ${colors.gray[200]}`, + color: colors.gray[500], + fontFamily: typography.fonts.body, + fontSize: typography.fontSizes.sm, + padding: spacing.xsm, + paddingRight: spacing.xlg, + + '&:focus': { + borderColor: colors.violet[200], + outline: `${borders['1px']} ${colors.violet[200]}`, + }, +}); + +export const SearchIcon = styled(Icon)({ + position: 'absolute', + right: spacing.sm, + top: '50%', + transform: 'translateY(-50%)', + pointerEvents: 'none', +}); + +// Styled components for SelectValue (Selected value display) +export const SelectValue = styled.span({ + ...inputValueTextStyles(), +}); + +export const Placeholder = styled.span({ + ...inputPlaceholderTextStyles, +}); + +export const ActionButtonsContainer = styled.div({ + display: 'flex', + gap: '6px', + flexDirection: 'row', + alignItems: 'center', +}); + +/** + * Components that can be reused to create new Select variants + */ + +export const FooterBase = styled.div({ + display: 'flex', + justifyContent: 'flex-end', + gap: spacing.sm, + paddingTop: spacing.sm, + borderTop: `1px solid ${colors.gray[100]}`, +}); + +export const OptionList = styled.div({ + display: 'flex', + flexDirection: 'column' as const, +}); + +export const LabelContainer = styled.div({ + display: 'flex', + justifyContent: 'space-between', + width: '100%', +}); + +export const OptionContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const DescriptionContainer = styled.span({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: '100%', + color: colors.gray[500], + lineHeight: 'normal', + fontSize: typography.fontSizes.sm, + marginTop: spacing.xxsm, +}); + +export const LabelsWrapper = styled.div({ + display: 'flex', + flexWrap: 'wrap', + gap: spacing.xxsm, + maxHeight: '150px', + maxWidth: 'calc(100% - 54px)', +}); + +export const OptionLabel = styled.label<{ isSelected: boolean; isMultiSelect?: boolean; isDisabled?: boolean }>( + ({ isSelected, isMultiSelect, isDisabled }) => ({ + ...getOptionLabelStyle(isSelected, isMultiSelect, isDisabled), + }), +); + +export const SelectAllOption = styled.div<{ isSelected: boolean; isDisabled?: boolean }>( + ({ isSelected, isDisabled }) => ({ + cursor: isDisabled ? 'not-allowed' : 'pointer', + padding: spacing.xsm, + color: isSelected ? colors.violet[700] : colors.gray[500], + fontWeight: typography.fontWeights.semiBold, + fontSize: typography.fontSizes.md, + display: 'flex', + alignItems: 'center', + }), +); + +export const SelectLabel = styled.label({ + ...formLabelTextStyles, + marginBottom: spacing.xxsm, + textAlign: 'left', +}); + +export const StyledCancelButton = styled(Button)({ + backgroundColor: colors.violet[100], + color: colors.violet[500], + borderColor: colors.violet[100], + + '&:hover': { + backgroundColor: colors.violet[200], + borderColor: colors.violet[200], + }, +}); + +export const StyledClearButton = styled(Button)({ + backgroundColor: colors.gray[200], + border: `1px solid ${colors.gray[200]}`, + color: colors.black, + padding: '1px', + + '&:hover': { + backgroundColor: colors.violet[100], + color: colors.violet[700], + borderColor: colors.violet[100], + boxShadow: shadows.none, + }, + + '&:focus': { + backgroundColor: colors.violet[100], + color: colors.violet[700], + boxShadow: `0 0 0 2px ${colors.white}, 0 0 0 4px ${colors.violet[50]}`, + }, +}); + +export const ClearIcon = styled.span({ + cursor: 'pointer', + marginLeft: '8px', +}); + +export const ArrowIcon = styled.span<{ isOpen: boolean }>(({ isOpen }) => ({ + marginLeft: 'auto', + border: 'solid black', + borderWidth: '0 1px 1px 0', + display: 'inline-block', + padding: '3px', + transform: isOpen ? 'rotate(-135deg)' : 'rotate(45deg)', +})); + +export const StyledCheckbox = styled(Checkbox)({ + '.ant-checkbox-checked:not(.ant-checkbox-disabled) .ant-checkbox-inner': { + backgroundColor: colors.violet[500], + borderColor: `${colors.violet[500]} !important`, + }, +}); diff --git a/datahub-web-react/src/alchemy-components/components/Select/index.ts b/datahub-web-react/src/alchemy-components/components/Select/index.ts new file mode 100644 index 00000000000000..eb469d0edc0046 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/index.ts @@ -0,0 +1,3 @@ +export { Select, selectDefaults } from './Select'; +export { SimpleSelect } from './SimpleSelect'; +export type { SelectProps, SelectOption } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Select/types.ts b/datahub-web-react/src/alchemy-components/components/Select/types.ts new file mode 100644 index 00000000000000..5ccde408b76999 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/types.ts @@ -0,0 +1,61 @@ +export type SelectSizeOptions = 'sm' | 'md' | 'lg'; + +export interface SelectOption { + value: string; + label: string; + description?: string; +} + +export interface SelectProps { + options: SelectOption[]; + label?: string; + values?: string[]; + onCancel?: () => void; + onUpdate?: (selectedValues: string[]) => void; + size?: SelectSizeOptions; + showSearch?: boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + showClear?: boolean; + width?: number | 'full'; + isMultiSelect?: boolean; + placeholder?: string; + disabledValues?: string[]; + showSelectAll?: boolean; + selectAllLabel?: string; + optionListTestId?: string; + showDescriptions?: boolean; +} + +export interface SelectStyleProps { + fontSize?: SelectSizeOptions; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + isOpen?: boolean; +} + +export interface ActionButtonsProps { + fontSize?: SelectSizeOptions; + selectedValues: string[]; + isOpen: boolean; + isDisabled: boolean; + isReadOnly: boolean; + showClear: boolean; + handleClearSelection: () => void; +} + +export interface SelectLabelDisplayProps { + selectedValues: string[]; + options: SelectOption[]; + placeholder: string; + isMultiSelect?: boolean; + removeOption?: (option: SelectOption) => void; + disabledValues?: string[]; + showDescriptions?: boolean; +} + +export interface SearchInputProps extends React.InputHTMLAttributes { + fontSize: SelectSizeOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/utils.ts b/datahub-web-react/src/alchemy-components/components/Select/utils.ts new file mode 100644 index 00000000000000..d054dd8ff737ad --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/utils.ts @@ -0,0 +1,125 @@ +import { borders, colors, radius, spacing, typography } from '@components/theme'; +import { getFontSize } from '@components/theme/utils'; + +import { SelectStyleProps } from './types'; + +export const getOptionLabelStyle = (isSelected: boolean, isMultiSelect?: boolean, isDisabled?: boolean) => ({ + cursor: isDisabled ? 'not-allowed' : 'pointer', + padding: spacing.xsm, + borderRadius: radius.md, + lineHeight: typography.lineHeights.normal, + backgroundColor: isSelected && !isMultiSelect ? colors.violet[100] : 'transparent', + color: isSelected ? colors.violet[700] : colors.gray[500], + fontWeight: typography.fontWeights.medium, + fontSize: typography.fontSizes.md, + display: 'flex', + alignItems: 'center', + + '&:hover': { + backgroundColor: isSelected ? colors.violet[100] : colors.gray[100], + }, +}); + +export const getFooterButtonSize = (size) => { + return size === 'sm' ? 'sm' : 'md'; +}; + +export const getSelectFontStyles = (size) => { + const baseFontStyles = { + lineHeight: typography.lineHeights.none, + }; + + const sizeStyles = { + sm: { + ...baseFontStyles, + fontSize: getFontSize(size), + }, + md: { + ...baseFontStyles, + fontSize: getFontSize(size), + }, + lg: { + ...baseFontStyles, + fontSize: getFontSize(size), + }, + }; + + return sizeStyles[size]; +}; + +export const getSelectPadding = (size) => { + const paddingStyles = { + sm: { + padding: `${spacing.sm} ${spacing.xsm}`, + }, + md: { + padding: `${spacing.sm} ${spacing.md}`, + }, + lg: { + padding: `${spacing.md} ${spacing.sm}`, + }, + }; + + return paddingStyles[size]; +}; + +export const getSearchPadding = (size) => { + const paddingStyles = { + sm: { + padding: `${spacing.xxsm} ${spacing.xsm}`, + }, + md: { + padding: `${spacing.xsm} ${spacing.xsm}`, + }, + lg: { + padding: `${spacing.xsm} ${spacing.xsm}`, + }, + }; + + return paddingStyles[size]; +}; + +export const getSelectStyle = (props: SelectStyleProps) => { + const { isDisabled, isReadOnly, fontSize, isOpen } = props; + + const baseStyle = { + borderRadius: radius.md, + border: `1px solid ${colors.gray[200]}`, + fontFamily: typography.fonts.body, + color: isDisabled ? colors.gray[300] : colors.black, + cursor: isDisabled || isReadOnly ? 'not-allowed' : 'pointer', + backgroundColor: isDisabled ? colors.gray[100] : 'initial', + + '&::placeholder': { + color: colors.gray[400], + }, + + // Open Styles + ...(isOpen + ? { + borderColor: colors.violet[300], + boxShadow: `0px 0px 4px 0px rgba(83, 63, 209, 0.5)`, + outline: 'none', + } + : {}), + + // Hover Styles + ...(isDisabled || isReadOnly || isOpen + ? {} + : { + '&:hover': { + borderColor: colors.violet[200], + outline: `${borders['1px']} ${colors.violet[200]}`, + }, + }), + }; + + const fontStyles = getSelectFontStyles(fontSize); + const paddingStyles = getSelectPadding(fontSize); + + return { + ...baseStyle, + ...fontStyles, + ...paddingStyles, + }; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx b/datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx new file mode 100644 index 00000000000000..7bb4ee2397cc63 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx @@ -0,0 +1,169 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; + +import { Switch, switchDefaults } from './Switch'; +import { AVAILABLE_ICONS } from '../Icon'; + +const meta = { + title: 'Forms / Switch', + component: Switch, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get user input in the state of a toggle.', + }, + }, + + // Component-level argTypes + argTypes: { + label: { + description: 'Label for the Switch.', + table: { + defaultValue: { summary: switchDefaults.label }, + }, + control: { + type: 'text', + }, + }, + labelPosition: { + description: 'The position of the label relative to the Switch.', + options: ['left', 'top'], + table: { + defaultValue: { summary: switchDefaults.labelPosition }, + }, + control: { + type: 'select', + }, + }, + icon: { + description: 'The icon to display in the Switch Slider.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + colorScheme: { + description: 'The color of the Switch.', + options: ['violet', 'green', 'red', 'blue', 'gray'], + table: { + defaultValue: { summary: switchDefaults.colorScheme }, + }, + control: { + type: 'select', + }, + }, + size: { + description: 'The size of the Button.', + options: ['sm', 'md', 'lg', 'xl'], + table: { + defaultValue: { summary: switchDefaults.size }, + }, + control: { + type: 'select', + }, + }, + isSquare: { + description: 'Whether the Switch is square in shape.', + table: { + defaultValue: { summary: switchDefaults?.isSquare?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isChecked: { + description: 'Whether the Switch is checked.', + table: { + defaultValue: { summary: switchDefaults?.isChecked?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Switch is in disabled state.', + table: { + defaultValue: { summary: switchDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Switch is a required field.', + table: { + defaultValue: { summary: switchDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + + // Define defaults + args: { + label: switchDefaults.label, + labelPosition: switchDefaults.labelPosition, + icon: switchDefaults.icon, + colorScheme: switchDefaults.colorScheme, + size: switchDefaults.size, + isSquare: switchDefaults.isSquare, + isChecked: switchDefaults.isChecked, + isDisabled: switchDefaults.isDisabled, + isRequired: switchDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const sizes = () => ( + + + + + + +); + +export const colors = () => ( + + + + + + + +); + +export const states = () => ( + + + + + +); + +export const types = () => ( + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx b/datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx new file mode 100644 index 00000000000000..18a01386562ee9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx @@ -0,0 +1,74 @@ +import { Tooltip } from '@components'; +import React, { useEffect, useState } from 'react'; +import { IconContainer, Label, Required, Slider, StyledIcon, StyledInput, SwitchContainer } from './components'; +import { SwitchProps } from './types'; + +export const switchDefaults: SwitchProps = { + label: 'Label', + labelPosition: 'left', + colorScheme: 'violet', + size: 'md', + isSquare: false, + isChecked: false, + isDisabled: false, + isRequired: false, +}; + +export const Switch = ({ + label = switchDefaults.label, + labelPosition = switchDefaults.labelPosition, + icon, // undefined by default + colorScheme = switchDefaults.colorScheme, + size = switchDefaults.size, + isSquare = switchDefaults.isSquare, + isChecked = switchDefaults.isChecked, + isDisabled = switchDefaults.isDisabled, + isRequired = switchDefaults.isRequired, + labelHoverText, + disabledHoverText, + labelStyle, + ...props +}: SwitchProps) => { + const [checked, setChecked] = useState(isChecked); + + useEffect(() => { + setChecked(isChecked); + }, [isChecked]); + + const id = props.id || `switchToggle-${label}`; + + return ( + + + + + setChecked(!checked)} + customSize={size} + disabled={isDisabled} + colorScheme={colorScheme || 'violet'} + aria-labelledby={id} + aria-checked={checked} + {...props} + /> + + + + {icon && ( + + )} + + + + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Switch/components.ts b/datahub-web-react/src/alchemy-components/components/Switch/components.ts new file mode 100644 index 00000000000000..1586c1cf9f32fd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/components.ts @@ -0,0 +1,118 @@ +import styled from 'styled-components'; + +import { borders, colors, shadows, spacing, transition } from '@components/theme'; +import { ColorOptions, SizeOptions } from '@components/theme/config'; + +import { Icon } from '../Icon'; + +import { formLabelTextStyles } from '../commonStyles'; + +import { + getIconTransformPositionLeft, + getIconTransformPositionTop, + getInputHeight, + getSliderTransformPosition, + getToggleSize, +} from './utils'; + +import type { SwitchLabelPosition } from './types'; + +export const Label = styled.div({ + ...formLabelTextStyles, + display: 'flex', + alignItems: 'flex-start', +}); + +export const SwitchContainer = styled.label<{ labelPosition: SwitchLabelPosition; isDisabled?: boolean }>( + ({ labelPosition, isDisabled }) => ({ + display: 'flex', + flexDirection: labelPosition === 'top' ? 'column' : 'row', + alignItems: labelPosition === 'top' ? 'flex-start' : 'center', + gap: spacing.sm, + cursor: isDisabled ? 'not-allowed' : 'pointer', + width: 'max-content', + }), +); + +export const Slider = styled.div<{ size?: SizeOptions; isSquare?: boolean; isDisabled?: boolean }>( + ({ size, isSquare, isDisabled }) => ({ + '&:before': { + transition: `${transition.duration.normal} all`, + content: '""', + position: 'absolute', + minWidth: getToggleSize(size || 'md', 'slider'), // sliders width and height must be same + minHeight: getToggleSize(size || 'md', 'slider'), + borderRadius: !isSquare ? '35px' : '0px', + top: '50%', + left: spacing.xxsm, + transform: 'translate(0, -50%)', + backgroundColor: !isDisabled ? colors.white : colors.gray[200], + boxShadow: ` + 0px 1px 2px 0px rgba(16, 24, 40, 0.06), + 0px 1px 3px 0px rgba(16, 24, 40, 0.12) + `, + }, + borderRadius: !isSquare ? '32px' : '0px', + minWidth: getToggleSize(size || 'md', 'input'), + minHeight: getInputHeight(size || 'md'), + }), + { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + + backgroundColor: colors.gray[100], + padding: spacing.xxsm, + transition: `${transition.duration.normal} all`, + boxSizing: 'content-box', + }, +); + +export const Required = styled.span({ + color: colors.red[500], + marginLeft: spacing.xxsm, +}); + +export const StyledInput = styled.input<{ + customSize?: SizeOptions; + disabled?: boolean; + colorScheme: ColorOptions; + checked?: boolean; +}>` + opacity: 0; + position: absolute; + + &:checked + ${Slider} { + background-color: ${(props) => (!props.disabled ? colors[props.colorScheme][500] : colors.gray[100])}; + + &:before { + transform: ${({ customSize }) => getSliderTransformPosition(customSize || 'md')}; + } + } + + &:focus-within + ${Slider} { + border-color: ${(props) => (props.checked ? colors[props.colorScheme][200] : 'transparent')}; + outline: ${(props) => (props.checked ? `${borders['2px']} ${colors[props.colorScheme][200]}` : 'none')}; + box-shadow: ${(props) => (props.checked ? shadows.xs : 'none')}; + } +`; + +export const StyledIcon = styled(Icon)<{ checked?: boolean; size: SizeOptions }>( + ({ checked, size }) => ({ + left: getIconTransformPositionLeft(size, checked || false), + top: getIconTransformPositionTop(size), + }), + { + transition: `${transition.duration.normal} all`, + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: colors.gray[500], + }, +); + +export const IconContainer = styled.div({ + position: 'relative', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Switch/index.ts b/datahub-web-react/src/alchemy-components/components/Switch/index.ts new file mode 100644 index 00000000000000..0c48d2964887ec --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/index.ts @@ -0,0 +1,2 @@ +export { Switch, switchDefaults } from './Switch'; +export type { SwitchProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Switch/types.ts b/datahub-web-react/src/alchemy-components/components/Switch/types.ts new file mode 100644 index 00000000000000..e15c0f81b4a392 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/types.ts @@ -0,0 +1,21 @@ +import { ColorOptions, SizeOptions } from '@components/theme/config'; +import { InputHTMLAttributes } from 'react'; +import { CSSProperties } from 'styled-components'; +import { IconNames } from '../Icon'; + +export type SwitchLabelPosition = 'left' | 'top'; + +export interface SwitchProps extends Omit, 'size'> { + label: string; + labelPosition?: SwitchLabelPosition; + icon?: IconNames; + colorScheme?: ColorOptions; + size?: SizeOptions; + isSquare?: boolean; + isChecked?: boolean; + isDisabled?: boolean; + isRequired?: boolean; + labelHoverText?: string; + disabledHoverText?: string; + labelStyle?: CSSProperties; +} diff --git a/datahub-web-react/src/alchemy-components/components/Switch/utils.ts b/datahub-web-react/src/alchemy-components/components/Switch/utils.ts new file mode 100644 index 00000000000000..c0365baa348183 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/utils.ts @@ -0,0 +1,97 @@ +import { SizeOptions } from '@components/theme/config'; + +const sliderSize = { + sm: '14px', + md: '16px', + lg: '18px', + xl: '20px', +}; + +const inputSize = { + sm: '35px', + md: '40px', + lg: '45px', + xl: '50px', +}; + +const translateSize = { + sm: '22px', + md: '24px', + lg: '26px', + xl: '28px', +}; + +const iconTransformPositionLeft = { + sm: { + checked: '5.5px', + unchecked: '-16.5px', + }, + md: { + checked: '5px', + unchecked: '-19px', + }, + lg: { + checked: '4.5px', + unchecked: '-21.5px', + }, + xl: { + checked: '4px', + unchecked: '-24px', + }, +}; + +const iconTransformPositionTop = { + sm: '-6px', + md: '-7px', + lg: '-8px', + xl: '-9px', +}; + +export const getToggleSize = (size: SizeOptions, mode: 'slider' | 'input'): string => { + if (size === 'sm') return mode === 'slider' ? sliderSize.sm : inputSize.sm; + if (size === 'md') return mode === 'slider' ? sliderSize.md : inputSize.md; + if (size === 'lg') return mode === 'slider' ? sliderSize.lg : inputSize.lg; + return mode === 'slider' ? sliderSize.xl : inputSize.xl; // xl +}; + +export const getInputHeight = (size: SizeOptions) => { + if (size === 'sm') return sliderSize.sm; + if (size === 'md') return sliderSize.md; + if (size === 'lg') return sliderSize.lg; + return sliderSize.xl; // xl +}; + +export const getSliderTransformPosition = (size: SizeOptions): string => { + if (size === 'sm') return `translate(${translateSize.sm}, -50%)`; + if (size === 'md') return `translate(${translateSize.md}, -50%)`; + if (size === 'lg') return `translate(${translateSize.lg}, -50%)`; + return `translate(${translateSize.xl}, -50%)`; // xl +}; + +export const getIconTransformPositionLeft = (size: SizeOptions, checked: boolean): string => { + if (size === 'sm') { + if (checked) return iconTransformPositionLeft.sm.checked; + return iconTransformPositionLeft.sm.unchecked; + } + + if (size === 'md') { + if (checked) return iconTransformPositionLeft.md.checked; + return iconTransformPositionLeft.md.unchecked; + } + + if (size === 'lg') { + if (checked) return iconTransformPositionLeft.lg.checked; + return iconTransformPositionLeft.lg.unchecked; + } + + // xl + if (checked) return iconTransformPositionLeft.xl.checked; + return iconTransformPositionLeft.xl.unchecked; +}; + +export const getIconTransformPositionTop = (size: SizeOptions): string => { + if (size === 'sm') return iconTransformPositionTop.sm; + if (size === 'md') return iconTransformPositionTop.md; + if (size === 'lg') return iconTransformPositionTop.lg; + return iconTransformPositionTop.xl; // xl +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx b/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx new file mode 100644 index 00000000000000..3a36b658978066 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx @@ -0,0 +1,162 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Table, tableDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Lists & Tables / Table', + component: Table, + + // Display Properties + parameters: { + layout: 'padded', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'This component allows users to render a table with different columns and their data', + }, + }, + + // Component-level argTypes + argTypes: { + columns: { + description: 'Array of column objects for the table header.', + control: 'object', + table: { + defaultValue: { summary: JSON.stringify(tableDefaults.columns) }, + }, + }, + data: { + description: 'Array of data rows for the table body.', + control: 'object', + table: { + defaultValue: { summary: JSON.stringify(tableDefaults.data) }, + }, + }, + showHeader: { + description: 'Whether to show the table header.', + control: 'boolean', + table: { + defaultValue: { summary: tableDefaults.showHeader?.toString() }, + }, + }, + isLoading: { + description: 'Whether the table is in loading state.', + control: 'boolean', + table: { + defaultValue: { summary: tableDefaults.isLoading?.toString() }, + }, + }, + isScrollable: { + description: 'Whether the table is scrollable.', + control: 'boolean', + table: { + defaultValue: { summary: tableDefaults.isScrollable?.toString() }, + }, + }, + maxHeight: { + description: 'Maximum height of the table container.', + control: 'text', + table: { + defaultValue: { summary: tableDefaults.maxHeight }, + }, + }, + }, + + // Define defaults + args: { + columns: [ + { title: 'Column 1', key: 'column1', dataIndex: 'column1' }, + { title: 'Column 2', key: 'column2', dataIndex: 'column2' }, + ], + data: [ + { column1: 'Row 1 Col 1', column2: 'Row 1 Col 2' }, + { column1: 'Row 2 Col 1', column2: 'Row 2 Col 2' }, + ], + showHeader: tableDefaults.showHeader, + isLoading: tableDefaults.isLoading, + isScrollable: tableDefaults.isScrollable, + maxHeight: tableDefaults.maxHeight, + }, +} satisfies Meta>; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const withScroll = () => ( +
+); + +export const withCustomColumnWidths = () => ( +
+); + +export const withColumnSorting = () => ( +
a.column1.localeCompare(b.column1), + }, + { title: 'Column 2', key: 'column2', dataIndex: 'column2' }, + { title: 'Column 3', key: 'column3', dataIndex: 'column3', sorter: (a, b) => a.column3 - b.column3 }, + ]} + data={[ + { column1: 'Row 2 Col 1', column2: 'Row 2 Col 2', column3: 3 }, + { column1: 'Row 1 Col 1', column2: 'Row 1 Col 2', column3: 2 }, + { column1: 'Row 3 Col 1', column2: 'Row 3 Col 2', column3: 1 }, + ]} + /> +); + +export const withoutHeader = () => ( +
+); diff --git a/datahub-web-react/src/alchemy-components/components/Table/Table.tsx b/datahub-web-react/src/alchemy-components/components/Table/Table.tsx new file mode 100644 index 00000000000000..11e598f8d4e0f7 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/Table.tsx @@ -0,0 +1,115 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Text } from '@components'; +import React, { useState } from 'react'; +import { + BaseTable, + HeaderContainer, + LoadingContainer, + SortIcon, + SortIconsContainer, + TableCell, + TableContainer, + TableHeader, + TableHeaderCell, + TableRow, +} from './components'; +import { TableProps } from './types'; +import { getSortedData, handleActiveSort, renderCell, SortingState } from './utils'; + +export const tableDefaults: TableProps = { + columns: [], + data: [], + showHeader: true, + isLoading: false, + isScrollable: false, + maxHeight: '100%', +}; + +export const Table = ({ + columns = tableDefaults.columns, + data = tableDefaults.data, + showHeader = tableDefaults.showHeader, + isLoading = tableDefaults.isLoading, + isScrollable = tableDefaults.isScrollable, + maxHeight = tableDefaults.maxHeight, + ...props +}: TableProps) => { + const [sortColumn, setSortColumn] = useState(null); + const [sortOrder, setSortOrder] = useState(SortingState.ORIGINAL); + + const sortedData = getSortedData(columns, data, sortColumn, sortOrder); + + if (isLoading) { + return ( + + + Loading data... + + ); + } + + return ( + + + {showHeader && ( + + + {columns.map((column) => ( + + + {column.title} + {column.sorter && ( + + column.sorter && + handleActiveSort( + column.key, + sortColumn, + setSortColumn, + setSortOrder, + ) + } + > + + + + )} + + + ))} + + + )} + + {sortedData.map((row, index) => ( + + {columns.map((column) => { + return ( + + {renderCell(column, row, index)} + + ); + })} + + ))} + + + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/components.ts b/datahub-web-react/src/alchemy-components/components/Table/components.ts new file mode 100644 index 00000000000000..8908256a81ddf2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/components.ts @@ -0,0 +1,94 @@ +import { Icon } from '@components'; +import { colors, radius, spacing, typography } from '@src/alchemy-components/theme'; +import { AlignmentOptions } from '@src/alchemy-components/theme/config'; +import styled from 'styled-components'; + +export const TableContainer = styled.div<{ isScrollable?: boolean; maxHeight?: string }>( + ({ isScrollable, maxHeight }) => ({ + borderRadius: radius.lg, + border: `1px solid ${colors.gray[1400]}`, + overflow: isScrollable ? 'auto' : 'hidden', + width: '100%', + maxHeight: maxHeight || '100%', + }), +); + +export const BaseTable = styled.table({ + borderCollapse: 'collapse', + width: '100%', +}); + +export const TableHeader = styled.thead({ + backgroundColor: colors.gray[1500], + borderRadius: radius.lg, + position: 'sticky', + top: 0, + zIndex: 100, +}); + +export const TableHeaderCell = styled.th<{ width?: string }>(({ width }) => ({ + padding: `${spacing.sm} ${spacing.md}`, + color: colors.gray[600], + fontSize: typography.fontSizes.sm, + fontWeight: typography.fontWeights.medium, + textAlign: 'start', + width: width || 'auto', +})); + +export const HeaderContainer = styled.div({ + display: 'flex', + alignItems: 'center', + gap: spacing.sm, +}); + +export const TableRow = styled.tr({ + '&:last-child': { + '& td': { + borderBottom: 'none', + }, + }, + + '& td:first-child': { + fontWeight: typography.fontWeights.medium, + color: colors.gray[600], + }, +}); + +export const TableCell = styled.td<{ width?: string; alignment?: AlignmentOptions }>(({ width, alignment }) => ({ + padding: spacing.md, + borderBottom: `1px solid ${colors.gray[1400]}`, + color: colors.gray[1700], + fontSize: typography.fontSizes.md, + fontWeight: typography.fontWeights.normal, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: width || 'unset', + textAlign: alignment || 'left', +})); + +export const SortIconsContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const SortIcon = styled(Icon)<{ isActive?: boolean }>(({ isActive }) => ({ + margin: '-3px', + stroke: isActive ? colors.violet[600] : undefined, + + ':hover': { + cursor: 'pointer', + }, +})); + +export const LoadingContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + width: '100%', + gap: spacing.sm, + color: colors.violet[700], + fontSize: typography.fontSizes['3xl'], +}); diff --git a/datahub-web-react/src/alchemy-components/components/Table/index.ts b/datahub-web-react/src/alchemy-components/components/Table/index.ts new file mode 100644 index 00000000000000..986f467da74b8c --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/index.ts @@ -0,0 +1,2 @@ +export { Table, tableDefaults } from './Table'; +export type { Column, TableProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Table/types.ts b/datahub-web-react/src/alchemy-components/components/Table/types.ts new file mode 100644 index 00000000000000..b3e0357d5cf147 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/types.ts @@ -0,0 +1,21 @@ +import { AlignmentOptions } from '@src/alchemy-components/theme/config'; +import { TableHTMLAttributes } from 'react'; + +export interface Column { + title: string; + key: string; + dataIndex?: string; + render?: (record: T, index: number) => React.ReactNode; + width?: string; + sorter?: (a: T, b: T) => number; + alignment?: AlignmentOptions; +} + +export interface TableProps extends TableHTMLAttributes { + columns: Column[]; + data: T[]; + showHeader?: boolean; + isLoading?: boolean; + isScrollable?: boolean; + maxHeight?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/Table/utils.ts b/datahub-web-react/src/alchemy-components/components/Table/utils.ts new file mode 100644 index 00000000000000..c76494d32ca633 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/utils.ts @@ -0,0 +1,73 @@ +import { Column } from './types'; + +export enum SortingState { + ASCENDING = 'ascending', + DESCENDING = 'descending', + ORIGINAL = 'original', +} + +export const handleActiveSort = ( + key: string, + sortColumn: string | null, + setSortColumn: React.Dispatch>, + setSortOrder: React.Dispatch>, +) => { + if (sortColumn === key) { + // Toggle sort order + setSortOrder((prevOrder) => { + if (prevOrder === SortingState.ASCENDING) return SortingState.DESCENDING; + if (prevOrder === SortingState.DESCENDING) return SortingState.ORIGINAL; + return SortingState.ASCENDING; + }); + } else { + // Set new column and default sort order + setSortColumn(key); + setSortOrder(SortingState.ASCENDING); + } +}; + +export const getSortedData = ( + columns: Column[], + data: T[], + sortColumn: string | null, + sortOrder: SortingState, +) => { + if (sortOrder === SortingState.ORIGINAL || !sortColumn) { + return data; + } + + const activeColumn = columns.find((column) => column.key === sortColumn); + + // Sort based on the order and column sorter + if (activeColumn && activeColumn.sorter) { + return data.slice().sort((a, b) => { + return sortOrder === SortingState.ASCENDING ? activeColumn.sorter!(a, b) : activeColumn.sorter!(b, a); + }); + } + + return data; +}; + +export const renderCell = (column: Column, row: T, index: number) => { + const { render, dataIndex } = column; + + let cellData; + + if (dataIndex) { + cellData = row[dataIndex]; + + if (typeof dataIndex === 'string') { + cellData = dataIndex.split('.').reduce((acc, prop) => acc && acc[prop], row); + } + + if (Array.isArray(dataIndex)) { + cellData = dataIndex.reduce((acc, prop) => acc && acc[prop], row); + } + } + + if (render) { + return render(row, index); + } + + return cellData; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx b/datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx new file mode 100644 index 00000000000000..c82d468aaa08ce --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { VerticalFlexGrid } from '@components/.docs/mdx-components'; +import { Text, textDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Typography / Text', + component: Text, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render text and paragraphs within an interface.', + }, + }, + + // Component-level argTypes + argTypes: { + children: { + description: 'The content to display within the heading.', + table: { + type: { summary: 'string' }, + }, + }, + type: { + description: 'The type of text to display.', + table: { + defaultValue: { summary: textDefaults.type }, + }, + }, + size: { + description: 'Override the size of the text.', + table: { + defaultValue: { summary: `${textDefaults.size}` }, + }, + }, + color: { + description: 'Override the color of the text.', + table: { + defaultValue: { summary: textDefaults.color }, + }, + }, + weight: { + description: 'Override the weight of the heading.', + table: { + defaultValue: { summary: textDefaults.weight }, + }, + }, + }, + + // Define default args + args: { + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas aliquet nulla id felis vehicula, et posuere dui dapibus. Nullam rhoncus massa non tortor convallis, in blandit turpis rutrum. Morbi tempus velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel mollis eros.', + type: textDefaults.type, + size: textDefaults.size, + color: textDefaults.color, + weight: textDefaults.weight, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => {props.children}, +}; + +export const sizes: StoryFn = (props: any) => ( + + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + +); + +export const withLink = () => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas aliquet nulla id felis vehicula, et posuere + dui dapibus. Nullam rhoncus massa non tortor convallis, in blandit turpis rutrum. Morbi tempus + velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel mollis eros. + +); diff --git a/datahub-web-react/src/alchemy-components/components/Text/Text.tsx b/datahub-web-react/src/alchemy-components/components/Text/Text.tsx new file mode 100644 index 00000000000000..89122afbfcc8bf --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/Text.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { TextProps } from './types'; +import { P, Div, Span } from './components'; + +export const textDefaults: TextProps = { + type: 'p', + color: 'inherit', + size: 'md', + weight: 'normal', +}; + +export const Text = ({ + type = textDefaults.type, + color = textDefaults.color, + size = textDefaults.size, + weight = textDefaults.weight, + children, + ...props +}: TextProps) => { + const sharedProps = { size, color, weight, ...props }; + + switch (type) { + case 'p': + return

{children}

; + case 'div': + return
{children}
; + case 'span': + return {children}; + default: + return

{children}

; + } +}; diff --git a/datahub-web-react/src/alchemy-components/components/Text/components.ts b/datahub-web-react/src/alchemy-components/components/Text/components.ts new file mode 100644 index 00000000000000..1d48497f39c9c8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/components.ts @@ -0,0 +1,50 @@ +import styled from 'styled-components'; + +import { typography, colors } from '@components/theme'; +import { getColor, getFontSize } from '@components/theme/utils'; +import { TextProps } from './types'; + +// Text Styles +const textStyles = { + fontSize: typography.fontSizes.md, + lineHeight: typography.lineHeights.md, + fontWeight: typography.fontWeights.normal, +}; + +// Default styles +const baseStyles = { + fontFamily: typography.fonts.body, + margin: 0, + + '& a': { + color: colors.violet[400], + textDecoration: 'none', + transition: 'color 0.15s ease', + + '&:hover': { + color: colors.violet[500], + }, + }, +}; + +// Prop Driven Styles +const propStyles = (props, isText = false) => { + const styles = {} as any; + if (props.size) styles.fontSize = getFontSize(props.size); + if (props.color) styles.color = getColor(props.color); + if (props.weight) styles.fontWeight = typography.fontWeights[props.weight]; + if (isText) styles.lineHeight = typography.lineHeights[props.size || 'md']; + return styles; +}; + +export const P = styled.p({ ...baseStyles, ...textStyles }, (props: TextProps) => ({ + ...propStyles(props as TextProps, true), +})); + +export const Span = styled.span({ ...baseStyles, ...textStyles }, (props: TextProps) => ({ + ...propStyles(props as TextProps, true), +})); + +export const Div = styled.div({ ...baseStyles, ...textStyles }, (props: TextProps) => ({ + ...propStyles(props as TextProps, true), +})); diff --git a/datahub-web-react/src/alchemy-components/components/Text/index.ts b/datahub-web-react/src/alchemy-components/components/Text/index.ts new file mode 100644 index 00000000000000..d4240105173d48 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/index.ts @@ -0,0 +1,2 @@ +export { Text, textDefaults } from './Text'; +export type { TextProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Text/types.ts b/datahub-web-react/src/alchemy-components/components/Text/types.ts new file mode 100644 index 00000000000000..6a41929da12a9b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/types.ts @@ -0,0 +1,9 @@ +import { HTMLAttributes } from 'react'; +import type { FontSizeOptions, FontColorOptions, FontWeightOptions } from '@components/theme/config'; + +export interface TextProps extends HTMLAttributes { + type?: 'span' | 'p' | 'div'; + size?: FontSizeOptions; + color?: FontColorOptions; + weight?: FontWeightOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx b/datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx new file mode 100644 index 00000000000000..b244eefa6f2073 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx @@ -0,0 +1,159 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; + +import { TextArea, textAreaDefaults } from './TextArea'; +import { AVAILABLE_ICONS } from '../Icon'; + +// Auto Docs +const meta = { + title: 'Forms / Text Area', + component: TextArea, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get user input in a text area field.', + }, + }, + + // Component-level argTypes + argTypes: { + label: { + description: 'Label for the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults.label }, + }, + control: { + type: 'text', + }, + }, + placeholder: { + description: 'Placeholder for the Text Area.', + table: { + defaultValue: { summary: textAreaDefaults.placeholder }, + }, + control: { + type: 'text', + }, + }, + icon: { + description: 'The icon to display in the Text Area.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + error: { + description: 'Enforce error state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults.error }, + }, + control: { + type: 'text', + }, + }, + warning: { + description: 'Enforce warning state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults.warning }, + }, + control: { + type: 'text', + }, + }, + isSuccess: { + description: 'Enforce success state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isSuccess?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Enforce disabled state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isInvalid: { + description: 'Enforce invalid state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isInvalid?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isReadOnly: { + description: 'Enforce read only state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isReadOnly?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Enforce required state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + + // Define defaults + args: { + label: textAreaDefaults.label, + placeholder: textAreaDefaults.placeholder, + icon: textAreaDefaults.icon, + error: textAreaDefaults.error, + warning: textAreaDefaults.warning, + isSuccess: textAreaDefaults.isSuccess, + isDisabled: textAreaDefaults.isDisabled, + isInvalid: textAreaDefaults.isInvalid, + isReadOnly: textAreaDefaults.isReadOnly, + isRequired: textAreaDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) =>