├── .babelrc.js ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── storybook-deploy.yml │ ├── storybook-test.yml │ └── unit-tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .nvmrc ├── .prettierignore ├── .storybook ├── main.ts ├── manager.ts ├── preview.tsx └── theme.ts ├── LICENSE.md ├── README.md ├── __mocks__ └── styleMock.ts ├── jest-setup.ts ├── jest.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── renovate.json ├── rollup.config.mjs ├── src ├── alert │ ├── alert.stories.tsx │ ├── alert.test.tsx │ ├── alert.tsx │ └── index.ts ├── assets │ └── fonts │ │ ├── hack-zeroslash │ │ ├── Hack-ZeroSlash-Bold.woff │ │ ├── Hack-ZeroSlash-Bold.woff2 │ │ ├── Hack-ZeroSlash-BoldItalic.woff │ │ ├── Hack-ZeroSlash-BoldItalic.woff2 │ │ ├── Hack-ZeroSlash-Italic.woff │ │ ├── Hack-ZeroSlash-Italic.woff2 │ │ ├── Hack-ZeroSlash-Regular.woff │ │ └── Hack-ZeroSlash-Regular.woff2 │ │ ├── lato │ │ ├── Lato-Black.woff │ │ ├── Lato-BlackItalic.woff │ │ ├── Lato-Bold.woff │ │ ├── Lato-BoldItalic.woff │ │ ├── Lato-Hairline.woff │ │ ├── Lato-HairlineItalic.woff │ │ ├── Lato-Italic.woff │ │ ├── Lato-Light.woff │ │ ├── Lato-LightItalic.woff │ │ └── Lato-Regular.woff │ │ └── noto-sans-arabic │ │ ├── NotoSansArabic-Black.woff │ │ ├── NotoSansArabic-Bold.woff │ │ ├── NotoSansArabic-Light.woff │ │ └── NotoSansArabic-Regular.woff ├── base.css ├── button │ ├── button.stories.tsx │ ├── button.test.tsx │ ├── button.tsx │ ├── index.ts │ └── types.ts ├── callout │ ├── callout.stories.tsx │ ├── callout.test.tsx │ ├── callout.tsx │ ├── index.ts │ └── types.ts ├── close-button │ ├── close-button.stories.tsx │ ├── close-button.test.tsx │ ├── close-button.tsx │ └── index.ts ├── col │ ├── col.stories.tsx │ ├── col.test.tsx │ ├── col.tsx │ ├── index.ts │ └── types.ts ├── color-system │ ├── color-system.stories.tsx │ ├── color-system.tsx │ └── types.ts ├── colors.css ├── container │ ├── container.stories.tsx │ ├── container.test.tsx │ ├── container.tsx │ ├── index.ts │ └── types.ts ├── control-label │ ├── control-label.stories.tsx │ ├── control-label.test.tsx │ ├── control-label.tsx │ ├── index.ts │ └── types.ts ├── declarations.d.ts ├── drop-down │ ├── drop-down.stories.tsx │ ├── drop-down.test.tsx │ ├── drop-down.tsx │ ├── index.ts │ └── menu-item.tsx ├── fonts.css ├── form-control │ ├── form-control-feedback.tsx │ ├── form-control-static.tsx │ ├── form-control.stories.tsx │ ├── form-control.test.tsx │ ├── form-control.tsx │ ├── index.ts │ └── types.ts ├── form-group │ ├── form-group.stories.tsx │ ├── form-group.test.tsx │ ├── form-group.tsx │ ├── index.ts │ └── types.ts ├── help-block │ ├── help-block.stories.tsx │ ├── help-block.test.tsx │ ├── help-block.tsx │ └── index.ts ├── image │ ├── image.stories.tsx │ ├── image.test.tsx │ ├── image.tsx │ ├── index.ts │ └── types.ts ├── index.ts ├── introduction.mdx ├── link │ ├── index.ts │ ├── link.stories.tsx │ ├── link.test.tsx │ ├── link.tsx │ └── types.ts ├── modal │ ├── index.ts │ ├── modal.stories.tsx │ ├── modal.test.tsx │ ├── modal.tsx │ └── types.ts ├── panel │ ├── index.ts │ ├── panel.stories.tsx │ ├── panel.test.tsx │ ├── panel.tsx │ └── types.ts ├── prism-formatted │ ├── index.ts │ ├── prism-base.css │ ├── prism-dark.css │ ├── prism-formatted.stories.tsx │ ├── prism-formatted.test.tsx │ ├── prism-formatted.tsx │ ├── prism-light.css │ └── types.ts ├── quiz-question │ ├── answer.tsx │ ├── index.ts │ ├── quiz-question.stories.tsx │ ├── quiz-question.test.tsx │ ├── quiz-question.tsx │ └── types.ts ├── quiz │ ├── index.ts │ ├── quiz.stories.tsx │ ├── quiz.test.tsx │ ├── quiz.tsx │ ├── types.ts │ ├── use-quiz.test.ts │ └── use-quiz.ts ├── row │ ├── index.ts │ ├── row.stories.tsx │ ├── row.test.tsx │ ├── row.tsx │ └── types.ts ├── spacer │ ├── index.ts │ ├── spacer.stories.tsx │ └── spacer.tsx ├── table │ ├── index.ts │ ├── table.stories.tsx │ ├── table.test.tsx │ ├── table.tsx │ └── types.ts ├── tabs │ ├── index.ts │ ├── tabs.stories.tsx │ ├── tabs.test.tsx │ └── tabs.tsx ├── toggle-button │ ├── index.ts │ ├── toggle-button.stories.tsx │ ├── toggle-button.test.tsx │ ├── toggle-button.tsx │ └── types.ts └── utils │ ├── get-theming-class.test.ts │ ├── get-theming-class.ts │ └── index.ts ├── tailwind.config.js ├── tsconfig.json └── utils ├── gen-component-script.ts └── gen-component-template.ts /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-react", 4 | "@babel/preset-typescript", 5 | [ 6 | "@babel/preset-env", 7 | { 8 | targets: { 9 | browsers: [">0.25%", "not dead"], 10 | }, 11 | }, 12 | ], 13 | ], 14 | plugins: [ 15 | ["transform-react-remove-prop-types", { removeImport: true }], 16 | [ 17 | "prismjs", 18 | { 19 | languages: [ 20 | "clike", 21 | "css", 22 | "html", 23 | "javascript", 24 | "json", 25 | "jsx", 26 | "markup", 27 | "mathml", 28 | "pug", 29 | "python", 30 | "sql", 31 | "svg", 32 | "typescript", 33 | "xml", 34 | "bash", 35 | "sass", 36 | "scss", 37 | ], 38 | theme: "default", 39 | css: true, 40 | plugins: ["line-numbers"], 41 | }, 42 | ], 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = tab 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.{json,yml}] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | storybook-static/** 3 | *.config.js 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "jest/globals": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react/recommended", 12 | "plugin:react-hooks/recommended", 13 | "plugin:jest-dom/recommended", 14 | "plugin:jsx-a11y/recommended", 15 | "plugin:testing-library/react" 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": "latest", 20 | "sourceType": "module" 21 | }, 22 | "settings": { 23 | "react": { 24 | "version": "detect" 25 | } 26 | }, 27 | "overrides": [ 28 | { 29 | "files": ["**/*.test.*"], 30 | "extends": ["plugin:jest/recommended", "plugin:jest/style"] 31 | } 32 | ], 33 | "rules": { 34 | "no-mixed-spaces-and-tabs": ["error", "smart-tabs"] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # CODEOWNERS - For automated review request for 3 | # high impact files. 4 | # 5 | # Important: The order in this file cascades. 6 | # 7 | # https://help.github.com/articles/about-codeowners 8 | # ------------------------------------------------- 9 | 10 | # ------------------------------------------------- 11 | # All files are owned by dev team 12 | # ------------------------------------------------- 13 | 14 | * @freecodecamp/dev-team 15 | 16 | # --- Owned by none (negate rule above) --- 17 | 18 | *.md 19 | package.json 20 | pnpm-lock.yaml 21 | 22 | # ------------------------------------------------- 23 | # All files in the root are owned by dev team 24 | # ------------------------------------------------- 25 | 26 | /* @freecodecamp/dev-team 27 | 28 | # --- Owned by none (negate rule above) --- 29 | 30 | /package.json 31 | /pnpm-lock.yaml 32 | 33 | # ------------------------------------------------- 34 | # Files that need attention from Staff 35 | # ------------------------------------------------- 36 | 37 | # README, LICENSE, etc. 38 | /*.md @freeCodeCamp/staff 39 | 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Checklist: 2 | 3 | 4 | 5 | - [ ] I have read and followed the [contribution guidelines](https://contribute.freecodecamp.org). 6 | - [ ] I have read and followed the [how to open a pull request guide](https://contribute.freecodecamp.org/how-to-open-a-pull-request/). 7 | - [ ] My pull request targets the `main` branch of the repo. 8 | - [ ] I have tested these changes locally on my machine. 9 | 10 | 11 | 12 | Closes #XXXXX 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/storybook-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Storybook to GitHub Pages 2 | on: 3 | push: 4 | branches: [main] 5 | jobs: 6 | publish: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 12 | 13 | - name: Install Node.js 14 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 15 | with: 16 | node-version: 20 17 | 18 | - uses: pnpm/action-setup@v3 19 | name: Install pnpm 20 | with: 21 | version: 9 22 | run_install: true 23 | 24 | - name: Build Storybook 25 | run: pnpm run build-storybook 26 | 27 | - name: Deploy to GitHub Pages 28 | uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # v4.7.3 29 | with: 30 | folder: storybook-static 31 | -------------------------------------------------------------------------------- /.github/workflows/storybook-test.yml: -------------------------------------------------------------------------------- 1 | name: Test Storybook 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 14 | 15 | - name: Install Node.js 16 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 17 | with: 18 | node-version: 20 19 | 20 | - uses: pnpm/action-setup@v3 21 | name: Install pnpm 22 | with: 23 | version: 9 24 | run_install: true 25 | 26 | - name: Check Storybook can be built 27 | run: pnpm run build-storybook --test 28 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 14 | 15 | - name: Install Node.js 16 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 17 | with: 18 | node-version: 20 19 | 20 | - uses: pnpm/action-setup@v3 21 | name: Install pnpm 22 | with: 23 | version: 9 24 | run_install: true 25 | 26 | - name: Run lint 27 | run: pnpm lint 28 | test: 29 | name: Test 30 | needs: lint 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | react-version: [16, 17] 35 | testing-library-version: [12] 36 | include: 37 | - react-version: 18 38 | testing-library-version: 16 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 42 | 43 | - name: Install Node.js 44 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 45 | with: 46 | node-version: 20 47 | 48 | - uses: pnpm/action-setup@v3 49 | name: Install pnpm 50 | with: 51 | version: 9 52 | run_install: true 53 | 54 | - name: Install Specific React version 55 | run: pnpm add react@${{ matrix.react-version }} react-dom@${{ matrix.react-version }} @testing-library/react@${{ matrix.testing-library-version }} 56 | 57 | - name: Run tests 58 | run: pnpm test 59 | 60 | typecheck: 61 | name: Typecheck 62 | needs: lint 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 67 | 68 | - name: Install Node.js 69 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 70 | with: 71 | node-version: 20 72 | 73 | - uses: pnpm/action-setup@v3 74 | name: Install pnpm 75 | with: 76 | version: 9 77 | run_install: true 78 | 79 | - name: Run typecheck 80 | run: pnpm typecheck 81 | 82 | build: 83 | name: Build Package 84 | needs: [lint, typecheck, test] 85 | runs-on: ubuntu-latest 86 | steps: 87 | - name: Checkout 88 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 89 | 90 | - name: Install Node.js 91 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 92 | with: 93 | node-version: 20 94 | 95 | - uses: pnpm/action-setup@v3 96 | name: Install pnpm 97 | with: 98 | version: 9 99 | run_install: true 100 | 101 | - name: Check if the package can be built 102 | run: pnpm run build 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Storybook build output 5 | types/ 6 | dist/ 7 | storybook-static/ 8 | 9 | # dotenv environment variables file 10 | .env 11 | 12 | # System Files 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # IDE-specific files and folders 17 | .vscode/ 18 | .idea/ 19 | *.sublime-project 20 | *.sublime-workspace 21 | *.swp 22 | *~ 23 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { "*.{js,jsx,ts,tsx,json}": "eslint", "*": "prettier --ignore-unknown --write" } 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # pnpm-lock.yaml does not need formatting, but it does need to be committed 2 | # so we can't just use .gitignore 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-webpack5"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.tsx"], 5 | 6 | addons: [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-a11y", 10 | { 11 | name: "@storybook/addon-styling-webpack", 12 | options: { 13 | rules: [ 14 | { 15 | test: /\.css$/, 16 | sideEffects: true, 17 | use: [ 18 | "style-loader", 19 | { 20 | loader: "css-loader", 21 | options: { 22 | importLoaders: 1, 23 | // Enable Interoperable CSS mode so that CSS variables can be imported into JS via the `:export` syntax. 24 | // We should not need this mode if/when we use CSS modules in our codebase. 25 | // https://webpack.js.org/loaders/css-loader/#separating-interoperable-css-only-and-css-module-features 26 | modules: { 27 | mode: "icss", 28 | }, 29 | }, 30 | }, 31 | { 32 | loader: "postcss-loader", 33 | }, 34 | ], 35 | }, 36 | ], 37 | }, 38 | }, 39 | "@storybook/addon-webpack5-compiler-babel", 40 | ], 41 | 42 | typescript: { 43 | check: false, 44 | checkOptions: {}, 45 | reactDocgen: "react-docgen-typescript", 46 | reactDocgenTypescriptOptions: { 47 | shouldExtractLiteralValuesFromEnum: true, 48 | propFilter: (prop) => 49 | prop.parent ? !/node_modules/.test(prop.parent.fileName) : true, 50 | }, 51 | }, 52 | 53 | framework: { 54 | name: "@storybook/react-webpack5", 55 | options: {}, 56 | }, 57 | 58 | docs: { 59 | autodocs: true, 60 | }, 61 | 62 | staticDirs: [{ from: "../src/assets", to: "/assets" }], 63 | 64 | managerHead: (head) => ` 65 | ${head} 66 | 67 | `, 68 | }; 69 | 70 | export default config; 71 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from "@storybook/manager-api"; 2 | import theme from "./theme"; 3 | 4 | addons.setConfig({ 5 | theme, 6 | }); 7 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../src/base.css"; 3 | import "../src/fonts.css"; 4 | 5 | export const parameters = { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/, 10 | }, 11 | }, 12 | backgrounds: { 13 | default: "light-palette", 14 | values: [ 15 | { 16 | name: "light-palette", 17 | value: "#f5f6f7", 18 | }, 19 | { 20 | name: "dark-palette", 21 | value: "#1b1b32", 22 | }, 23 | ], 24 | }, 25 | }; 26 | 27 | export const decorators = [renderTheme]; 28 | 29 | /** 30 | * Gets matching theme name for currently selected background and provides it 31 | * to the story. 32 | */ 33 | function renderTheme(Story, context) { 34 | const selectedBackgroundValue = context.globals.backgrounds?.value; 35 | const selectedBackgroundName = parameters.backgrounds.values.find( 36 | (bg) => bg.value === selectedBackgroundValue, 37 | )?.name; 38 | 39 | // Use the value of the default background to prevent "undefined" className 40 | const className = selectedBackgroundName || parameters.backgrounds.default; 41 | 42 | if (className === "light-palette") { 43 | document.body.classList.remove("dark-palette"); 44 | document.body.classList.add("light-palette"); 45 | } else { 46 | document.body.classList.remove("light-palette"); 47 | document.body.classList.add("dark-palette"); 48 | } 49 | 50 | return ; 51 | } 52 | -------------------------------------------------------------------------------- /.storybook/theme.ts: -------------------------------------------------------------------------------- 1 | import { create } from "@storybook/theming"; 2 | 3 | export default create({ 4 | base: "light", 5 | brandTitle: "freeCodeCamp.org", 6 | brandImage: 7 | "https://cdn.freecodecamp.org/platform/universal/fcc_secondary.svg", 8 | }); 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, freeCodeCamp. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | - Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | - Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | - Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | freeCodeCamp banner 2 | 3 | freeCodeCamp's component library is a collection of reusable React components that can be used in your projects. The components are built with accessibility in mind and are designed to be easy to use and customize. 4 | 5 | ## Installation 6 | 7 | - Run the following command to install the library: 8 | 9 | ```bash 10 | pnpm install @freecodecamp/ui 11 | ``` 12 | 13 | - Import the library's base stylesheet into your app: 14 | 15 | ```tsx 16 | // app.tsx 17 | import "@freecodecamp/ui/dist/base.css"; 18 | import "./my-app.css"; // Your custom stylesheet should be imported after, in order to override the base. 19 | ``` 20 | 21 | - Use the `getThemingClass` util to get a CSS class for theming, and add the class to the `body` element: 22 | 23 | ```tsx 24 | import { getThemingClass } from "@freecodecamp/ui"; 25 | 26 | const MyApp = () => { 27 | const cls = getThemingClass(); 28 | 29 | return ; 30 | }; 31 | ``` 32 | 33 | ## Docs 34 | 35 | To see the components in action, check out the [Storybook](https://freecodecamp.github.io/ui/). 36 | -------------------------------------------------------------------------------- /__mocks__/styleMock.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const config: Config = { 4 | testEnvironment: "jsdom", 5 | setupFilesAfterEnv: ["/jest-setup.ts"], 6 | // Stub out CSS as Jest would try to parse the imported stylesheets as JS modules 7 | // and throw an error as it can't transpile the code. 8 | // Ref: https://github.com/jestjs/jest/issues/3094#issuecomment-385164816 9 | moduleNameMapper: { 10 | "\\.css": "/__mocks__/styleMock.ts", 11 | }, 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/ui", 3 | "version": "4.0.1", 4 | "author": "freeCodeCamp ", 5 | "license": "BSD-3-Clause", 6 | "description": "The freeCodeCamp.org open-source UI components", 7 | "main": "dist/bundle.js", 8 | "module": "dist/bundle.es.js", 9 | "style": "dist/base.css", 10 | "types": "dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "private": false, 15 | "engines": { 16 | "node": ">=20", 17 | "pnpm": "9" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/freeCodeCamp/ui.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/freeCodeCamp/ui/issues" 25 | }, 26 | "homepage": "https://github.com/freeCodeCamp/ui#readme", 27 | "scripts": { 28 | "preinstall": "npx only-allow pnpm", 29 | "prepublishOnly": "pnpm run build", 30 | "build-storybook": "storybook build", 31 | "build": "pnpm clean && pnpm build:css && pnpm build:js", 32 | "build:js": "cross-env NODE_ENV=production rollup -c", 33 | "build:css": "npx -y tailwindcss -i ./src/base.css -o ./dist/base.css --minify", 34 | "dev:js": "cross-env NODE_ENV=development rollup -c -w ", 35 | "dev:css": "pnpm tailwindcss -i ./src/base.css -o ./dist/base.css --watch", 36 | "develop": "npm-run-all --parallel dev:css dev:js storybook", 37 | "format:eslint": "eslint . --fix", 38 | "format:prettier": "prettier --write .", 39 | "format": "pnpm run format:eslint && pnpm run format:prettier", 40 | "lint": "prettier --check . && eslint . --max-warnings 0", 41 | "start": "pnpm run develop", 42 | "storybook": "storybook dev -p 6006 --no-open", 43 | "storybook:theming": "pnpm run storybook --no-manager-cache", 44 | "clean": "rm -rf dist/*", 45 | "gen-component": "ts-node ./utils/gen-component-script", 46 | "test": "jest", 47 | "prepare": "husky", 48 | "typecheck": "tsc" 49 | }, 50 | "dependencies": { 51 | "@fortawesome/fontawesome-svg-core": "6.7.2", 52 | "@fortawesome/free-solid-svg-icons": "6.7.2", 53 | "@fortawesome/react-fontawesome": "0.2.2", 54 | "@headlessui/react": "1.7.19", 55 | "@radix-ui/react-tabs": "1.1.12", 56 | "babel-plugin-prismjs": "2.1.0", 57 | "prismjs": "1.30.0" 58 | }, 59 | "peerDependencies": { 60 | "react": "^16.14.0 || ^17.0.0 || ^18.0.0", 61 | "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" 62 | }, 63 | "devDependencies": { 64 | "@babel/core": "7.27.4", 65 | "@babel/preset-env": "7.27.2", 66 | "@babel/preset-react": "7.27.1", 67 | "@babel/preset-typescript": "7.27.1", 68 | "@rollup/plugin-babel": "5.3.1", 69 | "@rollup/plugin-commonjs": "19.0.2", 70 | "@rollup/plugin-node-resolve": "13.3.0", 71 | "@rollup/plugin-terser": "0.4.4", 72 | "@rollup/plugin-typescript": "8.5.0", 73 | "@storybook/addon-a11y": "8.6.14", 74 | "@storybook/addon-actions": "8.6.14", 75 | "@storybook/addon-docs": "8.6.14", 76 | "@storybook/addon-essentials": "8.6.14", 77 | "@storybook/addon-links": "8.6.14", 78 | "@storybook/addon-styling-webpack": "1.0.1", 79 | "@storybook/addon-webpack5-compiler-babel": "3.0.6", 80 | "@storybook/blocks": "8.6.14", 81 | "@storybook/react": "8.6.14", 82 | "@storybook/react-webpack5": "8.6.14", 83 | "@testing-library/jest-dom": "6.6.3", 84 | "@testing-library/react": "12.1.5", 85 | "@testing-library/react-hooks": "8.0.1", 86 | "@testing-library/user-event": "14.6.1", 87 | "@types/jest": "29.5.14", 88 | "@types/prismjs": "1.26.5", 89 | "@types/react": "16.14.65", 90 | "@types/react-dom": "16.9.25", 91 | "@typescript-eslint/eslint-plugin": "7.18.0", 92 | "@typescript-eslint/parser": "7.18.0", 93 | "autoprefixer": "10.4.21", 94 | "babel-loader": "8.4.1", 95 | "babel-plugin-transform-react-remove-prop-types": "0.4.24", 96 | "cross-env": "7.0.3", 97 | "css-loader": "6.11.0", 98 | "eslint": "8.57.1", 99 | "eslint-plugin-jest": "28.13.3", 100 | "eslint-plugin-jest-dom": "5.5.0", 101 | "eslint-plugin-jsx-a11y": "6.10.2", 102 | "eslint-plugin-react": "7.37.5", 103 | "eslint-plugin-react-hooks": "4.6.2", 104 | "eslint-plugin-testing-library": "6.5.0", 105 | "husky": "9.1.7", 106 | "jest": "29.7.0", 107 | "jest-environment-jsdom": "29.7.0", 108 | "lint-staged": "15.5.2", 109 | "npm-run-all2": "5.0.2", 110 | "postcss": "8.5.5", 111 | "postcss-import": "14.1.0", 112 | "postcss-loader": "8.1.1", 113 | "prettier": "3.5.3", 114 | "rollup": "3.29.5", 115 | "rollup-plugin-bundle-size": "1.0.3", 116 | "rollup-plugin-postcss": "4.0.2", 117 | "storybook": "8.6.14", 118 | "style-loader": "3.3.4", 119 | "tailwindcss": "3.4.17", 120 | "ts-node": "10.9.2", 121 | "tslib": "2.8.1", 122 | "tsx": "4.20.1", 123 | "typescript": "5.8.3" 124 | }, 125 | "keywords": [ 126 | "storybook", 127 | "freecodecamp", 128 | "ui" 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-import"), 4 | require("tailwindcss"), 5 | require("autoprefixer"), 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>freecodecamp/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import typescript from "@rollup/plugin-typescript"; 4 | import postcss from "rollup-plugin-postcss"; 5 | import terser from "@rollup/plugin-terser"; 6 | import resolve from "@rollup/plugin-node-resolve"; 7 | import bundleSize from "rollup-plugin-bundle-size"; 8 | 9 | // See https://rollupjs.org/command-line-interface/#importing-package-json 10 | import pkgJson from "./package.json" assert { type: "json" }; 11 | 12 | const production = process.env.NODE_ENV !== "development"; 13 | 14 | const config = { 15 | input: "src/index.ts", 16 | output: [ 17 | { 18 | file: "dist/bundle.js", 19 | format: "cjs", 20 | sourcemap: true, 21 | }, 22 | { 23 | file: "dist/bundle.es.js", 24 | format: "es", 25 | sourcemap: true, 26 | }, 27 | ], 28 | external: Object.keys(pkgJson.peerDependencies), 29 | plugins: [ 30 | postcss(), 31 | resolve(), 32 | typescript({ 33 | sourceMap: true, 34 | declaration: true, 35 | declarationDir: "dist", 36 | include: ["src/**/*"], 37 | exclude: ["**/*.test.*", "**/*.stories.*"], 38 | }), 39 | babel.babel({ babelHelpers: "bundled" }), 40 | commonjs(), 41 | production && terser(), 42 | bundleSize(), 43 | ], 44 | }; 45 | 46 | export default config; 47 | -------------------------------------------------------------------------------- /src/alert/alert.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryObj } from "@storybook/react"; 3 | import { Alert } from "./alert"; 4 | 5 | const story = { 6 | title: "Components/Alert", 7 | component: Alert, 8 | argTypes: { 9 | children: { control: { type: "text" } }, 10 | className: { control: { type: "text" } }, 11 | }, 12 | } satisfies Meta; 13 | 14 | type Story = StoryObj; 15 | 16 | export const Success: Story = { 17 | args: { 18 | children: "Hello, Alert!", 19 | variant: "success", 20 | }, 21 | }; 22 | 23 | export const Info: Story = { 24 | args: { 25 | children: "Hello, Alert!", 26 | variant: "info", 27 | }, 28 | }; 29 | 30 | export const Warning: Story = { 31 | args: { 32 | children: "Hello, Alert!", 33 | variant: "warning", 34 | }, 35 | }; 36 | 37 | export const Danger: Story = { 38 | args: { 39 | children: "Hello, Alert!", 40 | variant: "danger", 41 | }, 42 | }; 43 | 44 | export const LongText: Story = { 45 | args: { 46 | variant: "success", 47 | children: 48 | "Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet animi commodi cumque dicta ducimus eum iure, maiores mollitia, odit porro quas quod rerum soluta sunt tempora unde, vel voluptas voluptates.", 49 | }, 50 | }; 51 | 52 | export const WithHeadingAndParagraphs: Story = { 53 | args: { 54 | variant: "info", 55 | children: ( 56 | <> 57 |

58 | Some Heading Text 59 |

60 |

61 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet animi 62 | commodi cumque dicta ducimus eum iure, maiores mollitia, odit porro 63 | quas quod rerum soluta sunt tempora unde, vel voluptas voluptates. 64 |

65 |

66 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet animi 67 | commodi cumque dicta ducimus eum iure, maiores mollitia, odit porro 68 | quas quod rerum soluta sunt tempora unde, vel voluptas voluptates. 69 |

70 | 71 | ), 72 | }, 73 | }; 74 | 75 | export default story; 76 | -------------------------------------------------------------------------------- /src/alert/alert.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import React from "react"; 3 | import { Alert } from "./alert"; 4 | 5 | describe("", () => { 6 | it('should have an "alert" role', () => { 7 | render(Hello); 8 | 9 | expect(screen.getByRole("alert")).toBeInTheDocument(); 10 | }); 11 | 12 | it("renders children", () => { 13 | const expectedText = "Hello"; 14 | render( 15 | 16 |

{expectedText}

17 |
, 18 | ); 19 | 20 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 21 | }); 22 | 23 | it("appends className", () => { 24 | const expectedClass = "basic"; 25 | render( 26 | 27 | Hello 28 | , 29 | ); 30 | 31 | expect(screen.getByRole("alert")).toHaveClass(expectedClass); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/alert/alert.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ComponentProps } from "react"; 2 | 3 | type AlertVariant = "success" | "info" | "warning" | "danger"; 4 | 5 | export type AlertProps = ComponentProps<"div"> & { 6 | variant: AlertVariant; 7 | }; 8 | 9 | const variantClasses = { 10 | success: "text-green-700 bg-green-50 border-green-100", 11 | info: "text-blue-700 bg-blue-50 border-blue-100", 12 | warning: "text-yellow-700 bg-yellow-50 border-yellow-100", 13 | danger: "text-red-700 bg-red-50 border-red-100", 14 | }; 15 | 16 | /** 17 | * `Alert` is used to communicate high-priority or time-sensitive information. 18 | * `Alert` is not dismissable. 19 | * Use `Callout` instead of `Alert` if you want to communicate information specific to a page. 20 | */ 21 | export const Alert = ({ 22 | children, 23 | className, 24 | variant, 25 | ...props 26 | }: AlertProps): JSX.Element => { 27 | const variantClass = variantClasses[variant]; 28 | 29 | const classes = [ 30 | "p-4 mb-6 border border-solid border-1 break-words", 31 | variantClass, 32 | className, 33 | ].join(" "); 34 | 35 | return ( 36 |
37 | {children} 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/alert/index.ts: -------------------------------------------------------------------------------- 1 | export { Alert } from "./alert"; 2 | export type { AlertProps } from "./alert"; 3 | -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Bold.woff -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-BoldItalic.woff -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-BoldItalic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Italic.woff -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/hack-zeroslash/Hack-ZeroSlash-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-Black.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-BlackItalic.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-Bold.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-BoldItalic.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-Hairline.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-Hairline.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-HairlineItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-HairlineItalic.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-Italic.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-Light.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-LightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-LightItalic.woff -------------------------------------------------------------------------------- /src/assets/fonts/lato/Lato-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/lato/Lato-Regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/noto-sans-arabic/NotoSansArabic-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/noto-sans-arabic/NotoSansArabic-Black.woff -------------------------------------------------------------------------------- /src/assets/fonts/noto-sans-arabic/NotoSansArabic-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/noto-sans-arabic/NotoSansArabic-Bold.woff -------------------------------------------------------------------------------- /src/assets/fonts/noto-sans-arabic/NotoSansArabic-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/noto-sans-arabic/NotoSansArabic-Light.woff -------------------------------------------------------------------------------- /src/assets/fonts/noto-sans-arabic/NotoSansArabic-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/ui/17723bf5b3ad31ada699c972d18e1334909cb2b3/src/assets/fonts/noto-sans-arabic/NotoSansArabic-Regular.woff -------------------------------------------------------------------------------- /src/base.css: -------------------------------------------------------------------------------- 1 | @import "./colors.css"; 2 | 3 | /* https://github.com/tailwindlabs/tailwindcss/blob/3.4/src/css/preflight.css */ 4 | @tailwind base; 5 | 6 | @layer base { 7 | html { 8 | @apply text-md; 9 | } 10 | 11 | :focus-visible { 12 | @apply outline outline-3 outline-focus-outline-color outline-offset-0; 13 | } 14 | 15 | a { 16 | /* Override Tailwind's default `text-decoration` rule. */ 17 | /* https://github.com/tailwindlabs/tailwindcss/blob/3.4/src/css/preflight.css#L92 */ 18 | @apply underline; 19 | /* This is required in order to improve text readability in Arabic */ 20 | text-underline-position: under; 21 | } 22 | @supports not (text-underline-position: under) { 23 | a { 24 | text-underline-offset: 0.1em; 25 | } 26 | } 27 | 28 | code { 29 | @apply bg-background-tertiary text-foreground-tertiary; 30 | } 31 | :not(pre) > code { 32 | @apply border-1 border-gray-450 px-[4px] py-[1px]; 33 | } 34 | 35 | pre { 36 | color: inherit; 37 | background-color: inherit; 38 | border: none; 39 | border-radius: 0; 40 | display: block; 41 | padding: 9.5px; 42 | margin: 0 0 10px; 43 | font-size: 17px; 44 | line-height: 1.42857143; 45 | word-break: break-all; 46 | word-wrap: break-word; 47 | } 48 | 49 | pre code { 50 | padding: 0; 51 | font-size: inherit; 52 | color: inherit; 53 | white-space: pre-wrap; 54 | background-color: transparent; 55 | border-radius: 0; 56 | } 57 | 58 | h1, 59 | h2, 60 | h3, 61 | h4, 62 | h5, 63 | h6, 64 | p { 65 | margin-bottom: 12.5px; 66 | } 67 | 68 | /* Override the Tailwind's placeholder text color. */ 69 | /* https://github.com/tailwindlabs/tailwindcss/blob/3.4/src/css/preflight.css#L335 */ 70 | input::placeholder, 71 | textarea::placeholder { 72 | @apply text-foreground-quaternary opacity-80; 73 | } 74 | 75 | label { 76 | display: inline-block; 77 | max-width: 100%; 78 | margin-bottom: 5px; 79 | } 80 | 81 | blockquote { 82 | padding: 10px 20px; 83 | margin: 0 0 20px; 84 | font-size: 17.5px; 85 | border-left: 5px solid var(--background-quaternary); 86 | } 87 | 88 | ul { 89 | margin-top: 0; 90 | margin-bottom: 10px; 91 | padding-inline-start: 40px; 92 | list-style-type: disc; 93 | } 94 | 95 | ol { 96 | margin-top: 0; 97 | margin-bottom: 10px; 98 | padding-inline-start: 40px; 99 | list-style-type: decimal; 100 | } 101 | 102 | hr { 103 | margin-top: 20px; 104 | margin-bottom: 20px; 105 | border: 0; 106 | border-top: 1px solid var(--background-quaternary); 107 | } 108 | 109 | /* This is to override Tailwind as it sets the font-weight to `bolder`.*/ 110 | b, 111 | strong { 112 | font-weight: bold; 113 | } 114 | 115 | legend { 116 | display: block; 117 | width: 100%; 118 | padding: 0; 119 | margin-bottom: 20px; 120 | font-size: 21px; 121 | line-height: inherit; 122 | } 123 | } 124 | 125 | @tailwind components; 126 | @tailwind utilities; 127 | -------------------------------------------------------------------------------- /src/button/button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Meta, StoryObj } from "@storybook/react"; 3 | 4 | import { FormControl } from "../form-control"; 5 | import { FormGroup } from "../form-group"; 6 | import { ControlLabel } from "../control-label"; 7 | import { Button } from "."; 8 | 9 | const story = { 10 | title: "Components/Button", 11 | component: Button, 12 | parameters: { 13 | controls: { 14 | include: [ 15 | "children", 16 | "variant", 17 | "size", 18 | "disabled", 19 | "block", 20 | "href", 21 | "download", 22 | "target", 23 | "onClick", 24 | ], 25 | }, 26 | }, 27 | argTypes: { 28 | variant: { 29 | options: ["primary", "danger", "info"], 30 | }, 31 | size: { 32 | options: ["small", "medium", "large"], 33 | }, 34 | disabled: { 35 | options: [true, false], 36 | control: { type: "radio" }, 37 | }, 38 | block: { 39 | options: [true, false], 40 | control: { type: "radio" }, 41 | }, 42 | target: { 43 | options: ["_self", "_blank", "_parent", "_top"], 44 | }, 45 | onClick: { 46 | action: "clicked", 47 | }, 48 | href: { 49 | control: { type: "text" }, 50 | }, 51 | download: { 52 | control: { type: "text" }, 53 | }, 54 | }, 55 | } satisfies Meta; 56 | 57 | type Story = StoryObj; 58 | 59 | export const Default: Story = { 60 | args: { 61 | children: "Button", 62 | }, 63 | }; 64 | 65 | export const Danger: Story = { 66 | args: { 67 | variant: "danger", 68 | children: "Button", 69 | }, 70 | }; 71 | 72 | export const Info: Story = { 73 | args: { 74 | variant: "info", 75 | children: "Button", 76 | }, 77 | }; 78 | 79 | export const Large: Story = { 80 | args: { 81 | size: "large", 82 | children: "Button", 83 | }, 84 | }; 85 | 86 | export const Small: Story = { 87 | args: { 88 | size: "small", 89 | children: "Button", 90 | }, 91 | }; 92 | 93 | export const Disabled: Story = { 94 | args: { 95 | children: "Button", 96 | disabled: true, 97 | }, 98 | }; 99 | 100 | export const FullWidth: Story = { 101 | args: { 102 | children: "Button", 103 | block: true, 104 | }, 105 | }; 106 | 107 | export const AsALink: Story = { 108 | args: { 109 | children: "I'm a link that looks like a button", 110 | href: "https://www.freecodecamp.org", 111 | }, 112 | }; 113 | 114 | export const AsADownloadLink: Story = { 115 | args: { 116 | children: "I'm a download link", 117 | href: "https://www.freecodecamp.org", 118 | download: "my_file.txt", 119 | }, 120 | }; 121 | 122 | const FormWithSubmitButton = () => { 123 | const [username, setUsername] = useState(""); 124 | 125 | const handleChange = (event: React.ChangeEvent) => { 126 | setUsername(event.target.value); 127 | }; 128 | 129 | const handleSubmit = () => { 130 | alert("Submitted"); 131 | }; 132 | 133 | return ( 134 |
135 | 136 | Username 137 | 142 | 143 | 144 | 147 |
148 | ); 149 | }; 150 | 151 | export const AsASubmitButton: Story = { 152 | render: FormWithSubmitButton, 153 | }; 154 | 155 | export default story; 156 | -------------------------------------------------------------------------------- /src/button/button.test.tsx: -------------------------------------------------------------------------------- 1 | // Silence the `jest-dom/prefer-enabled-disabled` rule as the rule looks for the `disabled` attribute 2 | // while the Button component doesn't use it. 3 | /* eslint-disable jest-dom/prefer-enabled-disabled */ 4 | 5 | import { render, screen } from "@testing-library/react"; 6 | import userEvent from "@testing-library/user-event"; 7 | import React from "react"; 8 | 9 | import { Button } from "./button"; 10 | 11 | describe("); 14 | 15 | expect( 16 | screen.getByRole("button", { name: /hello world/i }), 17 | ).toBeInTheDocument(); 18 | }); 19 | 20 | it("should have the type 'button' by default", () => { 21 | render(); 22 | 23 | expect( 24 | screen.getByRole("button", { name: /hello world/i }), 25 | ).toHaveAttribute("type", "button"); 26 | }); 27 | 28 | it("should have the type 'submit' if it is specified", () => { 29 | render(); 30 | 31 | expect( 32 | screen.getByRole("button", { name: /hello world/i }), 33 | ).toHaveAttribute("type", "submit"); 34 | }); 35 | 36 | it("should trigger the onClick prop on click if the component is a button element", async () => { 37 | const onClick = jest.fn(); 38 | 39 | render(); 40 | 41 | const button = screen.getByRole("button", { name: /hello world/i }); 42 | 43 | await userEvent.click(button); 44 | 45 | expect(onClick).toHaveBeenCalledTimes(1); 46 | }); 47 | 48 | it("should reflect the disabled state using the aria-disabled attribute", () => { 49 | render(); 50 | 51 | const button = screen.getByRole("button", { name: /hello world/i }); 52 | 53 | expect(button).toHaveAttribute("aria-disabled", "true"); 54 | 55 | // Ensure that the `disabled` attribute is not used. 56 | expect(button).not.toHaveAttribute("disabled", "true"); 57 | }); 58 | 59 | it("should not trigger the onClick prop if the button is disabled", async () => { 60 | const onClick = jest.fn(); 61 | 62 | render( 63 | , 66 | ); 67 | 68 | const button = screen.getByRole("button", { name: /hello world/i }); 69 | await userEvent.click(button); 70 | 71 | expect(onClick).not.toHaveBeenCalled(); 72 | }); 73 | 74 | it("should not trigger form submission if the button has `submit` type and is disabled", async () => { 75 | const handleSubmit = jest.fn(); 76 | 77 | render( 78 |
79 | 83 | 84 | 87 |
, 88 | ); 89 | 90 | const button = screen.getByRole("button", { name: "Submit" }); 91 | await userEvent.click(button); 92 | 93 | expect(handleSubmit).not.toHaveBeenCalled(); 94 | }); 95 | 96 | it("should render an anchor element if the `href` prop is defined", () => { 97 | render(); 98 | 99 | const link = screen.getByRole("link", { name: /freeCodeCamp/i }); 100 | const button = screen.queryByRole("button", { name: /freeCodeCamp/i }); 101 | 102 | expect(link).toBeInTheDocument(); 103 | expect(link).toHaveAttribute("href", "https://www.freecodecamp.org"); 104 | // Ensure that a button element is not rendered 105 | expect(button).not.toBeInTheDocument(); 106 | }); 107 | 108 | it("should set the `rel` attribute to `noopener noreferrer` if `target` is `_blank`", () => { 109 | render( 110 | , 113 | ); 114 | 115 | const link = screen.getByRole("link", { name: /freeCodeCamp/i }); 116 | expect(link).toHaveAttribute("rel", "noopener noreferrer"); 117 | }); 118 | 119 | it("should not set the `rel` attribute if `target` is not `_blank`", () => { 120 | render(); 121 | 122 | const link = screen.getByRole("link", { name: /freeCodeCamp/i }); 123 | expect(link).toHaveAttribute("rel", ""); 124 | }); 125 | 126 | it("should render a button element if the `href` and `disabled` props are both defined", () => { 127 | render( 128 | , 131 | ); 132 | 133 | const button = screen.getByRole("button", { name: /freeCodeCamp/i }); 134 | const link = screen.queryByRole("link", { name: /freeCodeCamp/i }); 135 | 136 | expect(button).toBeInTheDocument(); 137 | expect(button).toHaveAttribute("aria-disabled", "true"); 138 | // Ensure that a link element is not rendered 139 | expect(link).not.toBeInTheDocument(); 140 | }); 141 | 142 | it("should trigger the onClick prop on click if the component is an anchor element", async () => { 143 | const onClick = jest.fn(); 144 | 145 | render( 146 | , 149 | ); 150 | 151 | const link = screen.getByRole("link", { name: /freeCodeCamp/i }); 152 | 153 | await userEvent.click(link); 154 | 155 | expect(onClick).toHaveBeenCalledTimes(1); 156 | }); 157 | }); 158 | 159 | // ------------------------------ 160 | // Type tests 161 | // ------------------------------ 162 | 163 | // @ts-expect-error - Button with `danger` variant cannot be disabled 164 | ; 167 | 168 | // @ts-expect-error - Button with `info` variant cannot be disabled 169 | ; 172 | -------------------------------------------------------------------------------- /src/button/button.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { ButtonProps, ButtonSize, ButtonVariant } from "./types"; 3 | 4 | const defaultClassNames = [ 5 | // Positioning 6 | "relative", 7 | "inline-block", 8 | "mt-[0.5px]", 9 | // Border 10 | "border-solid", 11 | "border-3", 12 | // Active state 13 | "active:before:w-full", 14 | "active:before:h-full", 15 | "active:before:absolute", 16 | "active:before:inset-0", 17 | "active:before:border-3", 18 | "active:before:border-transparent", 19 | "active:before:bg-gray-900", 20 | "active:before:opacity-20", 21 | // Misc 22 | "text-center", 23 | "cursor-pointer", 24 | "no-underline", // For link 25 | ]; 26 | 27 | const computeClassNames = ({ 28 | size, 29 | variant, 30 | disabled, 31 | block, 32 | }: { 33 | size?: ButtonSize; 34 | variant?: ButtonVariant; 35 | disabled?: boolean; 36 | block?: boolean; 37 | }) => { 38 | const classNames = [...defaultClassNames]; 39 | 40 | if (block) { 41 | classNames.push("block", "w-full"); 42 | } 43 | 44 | switch (variant) { 45 | case "danger": 46 | classNames.push( 47 | "border-foreground-danger", 48 | "bg-background-danger", 49 | "text-foreground-danger", 50 | "hover:bg-foreground-danger", 51 | "hover:text-background-danger", 52 | // This hover rule is redundant for the component library, 53 | // but is needed to override the border color set in client's `global.css`. 54 | // We can remove it once we have completely removed the CSS overrides in client. 55 | "hover:border-foreground-danger", 56 | "dark:hover:bg-background-danger", 57 | "dark:hover:text-foreground-danger", 58 | ); 59 | break; 60 | case "info": 61 | classNames.push( 62 | "border-foreground-info", 63 | "bg-background-info", 64 | "text-foreground-info", 65 | "hover:bg-foreground-info", 66 | "hover:text-background-info", 67 | // This hover rule is redundant for the component library, 68 | // but is needed to override the border color set in client's `global.css`. 69 | // We can remove it once we have completely removed the CSS overrides in client. 70 | "hover:border-foreground-info", 71 | "dark:hover:bg-background-info", 72 | "dark:hover:text-foreground-info", 73 | ); 74 | break; 75 | // default variant is 'primary' 76 | default: 77 | classNames.push( 78 | "bg-background-quaternary", 79 | "text-foreground-secondary", 80 | ...(disabled 81 | ? [ 82 | "active:before:hidden", 83 | "border-gray-450", 84 | "aria-disabled:cursor-not-allowed", 85 | "aria-disabled:opacity-80", 86 | ] 87 | : [ 88 | "border-foreground-secondary", 89 | "hover:bg-foreground-primary", 90 | "hover:text-background-primary", 91 | // This hover rule is redundant for the component library, 92 | // but is needed to override the border color set in client's `global.css`. 93 | // We can remove it once we have completely removed the CSS overrides in client. 94 | "hover:border-foreground-secondary", 95 | "dark:hover:bg-background-primary", 96 | "dark:hover:text-foreground-primary", 97 | ]), 98 | ); 99 | } 100 | 101 | switch (size) { 102 | case "large": 103 | classNames.push("px-4 py-2.5 text-lg"); 104 | break; 105 | case "small": 106 | classNames.push("px-2.5 py-1 text-sm"); 107 | break; 108 | // default size is 'medium' 109 | default: 110 | classNames.push("px-3 py-1.5 text-md"); 111 | } 112 | 113 | return classNames.join(" "); 114 | }; 115 | 116 | const StylessButton = React.forwardRef, ButtonProps>( 117 | ( 118 | { className, onClick, disabled, children, type = "button", ...rest }, 119 | ref, 120 | ) => { 121 | // Manually prevent the click event if the button is disabled 122 | // as `aria-disabled` marks the element disabled but still registers the click event. 123 | // Ref: https://css-tricks.com/making-disabled-buttons-more-inclusive/#aa-the-difference-between-disabled-and-aria-disabled 124 | const handleClick = (event: React.MouseEvent) => { 125 | if (disabled) { 126 | event.preventDefault(); 127 | return; 128 | } 129 | 130 | if (onClick) { 131 | onClick(event); 132 | } 133 | }; 134 | 135 | return ( 136 | 146 | ); 147 | }, 148 | ); 149 | 150 | const Link = React.forwardRef, ButtonProps>( 151 | ({ className, href, download, target, children, ...rest }, ref) => { 152 | return ( 153 | 161 | {children} 162 | 163 | ); 164 | }, 165 | ); 166 | 167 | export const HeadlessButton = React.forwardRef< 168 | React.ElementRef<"button" | "a">, 169 | ButtonProps 170 | >( 171 | ( 172 | { onClick, className, children, disabled, href, download, target, ...rest }, 173 | ref, 174 | ) => { 175 | if (href && !disabled) { 176 | return ( 177 | } 184 | onClick={onClick} 185 | {...rest} 186 | > 187 | {children} 188 | 189 | ); 190 | } else { 191 | return ( 192 | // @ts-expect-error - Type check error is expected. 193 | // `disabled` can either be `boolean | undefined` or `false | undefined` depending on the union member. 194 | // TypeScript can't infer the actual union member (that ties to the `variant`), 195 | // so it complains about the `disabled` type being incompatible. 196 | // Ref: https://github.com/Microsoft/TypeScript/issues/30518 197 | } 202 | {...rest} 203 | > 204 | {children} 205 | 206 | ); 207 | } 208 | }, 209 | ); 210 | 211 | export const Button = React.forwardRef< 212 | React.ElementRef<"button" | "a">, 213 | ButtonProps 214 | >( 215 | ( 216 | { 217 | className, 218 | size = "medium", 219 | disabled, 220 | variant = "primary", 221 | block, 222 | ...rest 223 | }, 224 | ref, 225 | ) => { 226 | const classes = useMemo( 227 | () => computeClassNames({ size, variant, disabled, block }), 228 | [size, variant, disabled, block], 229 | ); 230 | 231 | const buttonStyle = [className, classes].join(" "); 232 | 233 | return ( 234 | 240 | ); 241 | }, 242 | ); 243 | 244 | Button.displayName = "Button"; 245 | HeadlessButton.displayName = "HeadlessButton"; 246 | StylessButton.displayName = "StylessButton"; 247 | Link.displayName = "Link"; 248 | -------------------------------------------------------------------------------- /src/button/index.ts: -------------------------------------------------------------------------------- 1 | export { Button, HeadlessButton } from "./button"; 2 | export type { ButtonProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/button/types.ts: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react"; 2 | 3 | export type ButtonVariant = "primary" | "danger" | "info"; 4 | 5 | export type ButtonSize = "small" | "medium" | "large"; 6 | 7 | interface BaseButtonProps 8 | extends React.ButtonHTMLAttributes { 9 | children: React.ReactNode; 10 | size?: ButtonSize; 11 | onClick?: MouseEventHandler; 12 | type?: "submit" | "button"; 13 | block?: boolean; 14 | href?: string; 15 | download?: string; 16 | target?: React.HTMLAttributeAnchorTarget; 17 | } 18 | 19 | interface PrimaryButtonProps extends BaseButtonProps { 20 | variant?: "primary"; 21 | disabled?: boolean; 22 | } 23 | 24 | interface InfoButtonProps extends BaseButtonProps { 25 | variant: "info"; 26 | disabled?: false; 27 | } 28 | 29 | interface DangerButtonProps extends BaseButtonProps { 30 | variant: "danger"; 31 | disabled?: false; 32 | } 33 | 34 | export type ButtonProps = 35 | | PrimaryButtonProps 36 | | InfoButtonProps 37 | | DangerButtonProps; 38 | -------------------------------------------------------------------------------- /src/callout/callout.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Callout } from "./callout"; 4 | 5 | const story = { 6 | title: "Components/Callout", 7 | component: Callout, 8 | } satisfies Meta; 9 | 10 | type Story = StoryObj; 11 | 12 | export const Success: Story = { 13 | args: { 14 | children: 15 | "Eaque non tempore porro quod voluptates rerum ipsam. Consequatur ea voluptate quo tempora autem quod. Voluptatem perspiciatis non mollitia. Dicta non necessitatibus laboriosam est aut cum eos et. Animi pariatur aliquid sint ipsum nam occaecati nisi sit.", 16 | variant: "success", 17 | }, 18 | }; 19 | 20 | export const Info: Story = { 21 | args: { 22 | children: 23 | "Eaque non tempore porro quod voluptates rerum ipsam. Consequatur ea voluptate quo tempora autem quod. Voluptatem perspiciatis non mollitia. Dicta non necessitatibus laboriosam est aut cum eos et. Animi pariatur aliquid sint ipsum nam occaecati nisi sit.", 24 | variant: "info", 25 | }, 26 | }; 27 | 28 | export const Warning: Story = { 29 | args: { 30 | children: 31 | "Eaque non tempore porro quod voluptates rerum ipsam. Consequatur ea voluptate quo tempora autem quod. Voluptatem perspiciatis non mollitia. Dicta non necessitatibus laboriosam est aut cum eos et. Animi pariatur aliquid sint ipsum nam occaecati nisi sit.", 32 | variant: "warning", 33 | }, 34 | }; 35 | 36 | export const Danger: Story = { 37 | args: { 38 | children: 39 | "Eaque non tempore porro quod voluptates rerum ipsam. Consequatur ea voluptate quo tempora autem quod. Voluptatem perspiciatis non mollitia. Dicta non necessitatibus laboriosam est aut cum eos et. Animi pariatur aliquid sint ipsum nam occaecati nisi sit.", 40 | variant: "danger", 41 | }, 42 | }; 43 | 44 | export default story; 45 | -------------------------------------------------------------------------------- /src/callout/callout.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import { Callout } from "./callout"; 5 | 6 | describe("", () => { 7 | it("should render children correctly", () => { 8 | render(Hello World); 9 | 10 | expect(screen.getByText("Hello World")).toBeInTheDocument(); 11 | }); 12 | }); 13 | 14 | // ------------------------------ 15 | // Type tests 16 | // ------------------------------ 17 | 18 | // @ts-expect-error - Callout does not accept `role` 19 | 20 | Hello World 21 | ; 22 | 23 | // @ts-expect-error - Callout does not accept `role` 24 | 25 | Hello World 26 | ; 27 | 28 | // @ts-expect-error - Callout does not accept `role` 29 | 30 | Hello World 31 | ; 32 | 33 | // @ts-expect-error - Callout does not accept `role` 34 | 35 | Hello World 36 | ; 37 | -------------------------------------------------------------------------------- /src/callout/callout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CalloutProps } from "./types"; 3 | 4 | const variantClasses = { 5 | success: "text-green-700 bg-green-50 border-green-700", 6 | info: "text-blue-700 bg-blue-50 border-blue-700", 7 | warning: "text-yellow-700 bg-yellow-50 border-yellow-700", 8 | danger: "text-red-700 bg-red-50 border-red-700", 9 | }; 10 | 11 | /** 12 | * A `Callout` is used to emphasize an important snippet of information within the flow of a page. 13 | * Content in a callout should be something on the page that you want to highlight, but that is not critical information. 14 | * Use `Alert` instead of `Callout` if you want to communicate system-level information. 15 | */ 16 | export const Callout = ({ 17 | children, 18 | className, 19 | variant, 20 | ...others 21 | }: CalloutProps): JSX.Element => { 22 | const variantClass = variantClasses[variant]; 23 | 24 | const classes = [ 25 | "p-4 mb-6 border border-solid border-1 break-words", 26 | variantClass, 27 | className, 28 | ].join(" "); 29 | 30 | return ( 31 |
32 | {children} 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/callout/index.ts: -------------------------------------------------------------------------------- 1 | export { Callout } from "./callout"; 2 | -------------------------------------------------------------------------------- /src/callout/types.ts: -------------------------------------------------------------------------------- 1 | type CalloutVariant = "success" | "info" | "warning" | "danger"; 2 | 3 | export interface CalloutProps 4 | extends Omit, "role"> { 5 | variant: CalloutVariant; 6 | } 7 | -------------------------------------------------------------------------------- /src/close-button/close-button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import { CloseButton } from "./close-button"; 3 | 4 | const story = { 5 | title: "Components/CloseButton", 6 | component: CloseButton, 7 | } satisfies Meta; 8 | 9 | type Story = StoryObj; 10 | 11 | export const Basic: Story = { 12 | args: {}, 13 | }; 14 | 15 | export default story; 16 | -------------------------------------------------------------------------------- /src/close-button/close-button.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | import { CloseButton } from "./close-button"; 5 | 6 | describe("", () => { 7 | it("should render", () => { 8 | render(); 9 | 10 | expect(screen.getByRole("button")).toBeInTheDocument(); 11 | }); 12 | 13 | it('should have "Close" as the default label', () => { 14 | render(); 15 | 16 | expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); 17 | }); 18 | 19 | it('should set "aria-label" to "label" prop', () => { 20 | const expectedLabel = "Close me please"; 21 | render(); 22 | 23 | expect( 24 | screen.getByRole("button", { name: expectedLabel }), 25 | ).toBeInTheDocument(); 26 | }); 27 | 28 | it('should call "onClick" handler on button click', async () => { 29 | const onClick = jest.fn(); 30 | render(); 31 | 32 | await userEvent.click(screen.getByRole("button")); 33 | 34 | expect(onClick).toHaveBeenCalledTimes(1); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/close-button/close-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export interface CloseButtonProps { 4 | className?: string; 5 | label?: string; 6 | onClick: () => void; 7 | } 8 | 9 | /** 10 | * Basic UI component for closing modals, alerts, etc. 11 | */ 12 | export function CloseButton({ 13 | className, 14 | label, 15 | onClick, 16 | }: CloseButtonProps): JSX.Element { 17 | const classes = [ 18 | // Remove browser's default styles 19 | "bg-transparent", 20 | "border-none", 21 | // Text styles 22 | "text-lg", 23 | "font-bold", 24 | "text-foreground-primary", 25 | // Focus state 26 | "focus:opacity-100", 27 | "focus:text-opacity-100", 28 | // Hover state 29 | "hover:opacity-100", 30 | "hover:text-opacity-100", 31 | // Content positioning 32 | "flex", 33 | "justify-center", 34 | "items-center", 35 | // Others 36 | "w-[24px]", 37 | "h-[24px]", 38 | "opacity-50", 39 | className, 40 | ].join(" "); 41 | 42 | return ( 43 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/close-button/index.ts: -------------------------------------------------------------------------------- 1 | export { CloseButton } from "./close-button"; 2 | export type { CloseButtonProps } from "./close-button"; 3 | -------------------------------------------------------------------------------- /src/col/col.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryFn, StoryObj } from "@storybook/react"; 3 | import { Col } from "."; 4 | 5 | const story = { 6 | title: "Components/Col", 7 | component: Col, 8 | argTypes: { 9 | className: { control: { type: "text" } }, 10 | xs: { options: [4, 6, 8, 12, undefined] }, 11 | sm: { options: [2, 4, 6, 8, 10, 12, undefined] }, 12 | md: { options: [4, 6, 8, 10, 12, undefined] }, 13 | lg: { options: [6, 8, 10, undefined] }, 14 | xsOffset: { options: [1, 2, 3, undefined] }, 15 | smOffset: { options: [1, 2, 3, 4, undefined] }, 16 | mdOffset: { options: [1, 2, 3, 4, undefined] }, 17 | lgOffset: { options: [0, 1, 2, undefined] }, 18 | }, 19 | } satisfies Meta; 20 | 21 | const Template: StoryFn = (args) => { 22 | return ( 23 | 24 |

Random text to test the element width

25 | 26 | ); 27 | }; 28 | 29 | export const Default: StoryObj = { 30 | render: Template, 31 | 32 | args: { 33 | // default props go here 34 | }, 35 | }; 36 | 37 | export default story; 38 | -------------------------------------------------------------------------------- /src/col/col.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import { Col } from "."; 5 | 6 | describe("", () => { 7 | it("should change className when props are passed", () => { 8 | render( 9 | 10 | Learn to code for free. 11 | , 12 | ); 13 | expect(screen.getByText("Learn to code for free.")).toHaveClass( 14 | "min-h-[1px] px-[15px] w-full md:w-5/6 min-[1200px]:w-2/3 md:ml-[8.3%] min-[1200px]:ml-[16.6%]", 15 | ); 16 | }); 17 | it("should have lgOffSet 0 when it is passed to the component", () => { 18 | render(Learn to code for free.); 19 | expect(screen.getByText("Learn to code for free.")).toHaveClass( 20 | "min-h-[1px] px-[15px] min-[1200px]:ml-0", 21 | ); 22 | }); 23 | it("should add className to it", () => { 24 | render( 25 | Learn to code for free., 26 | ); 27 | expect(screen.getByText("Learn to code for free.")).toHaveClass( 28 | "min-h-[1px] px-[15px] certificate-outer-wrapper", 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/col/col.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ColProps } from "./types"; 4 | 5 | const ExtraSmallClasses = { 6 | 4: "w-1/3", 7 | 6: "w-1/2", 8 | 8: "w-2/3", 9 | 10: "w-5/6", 10 | 12: "w-full", 11 | }; 12 | 13 | const ExtraSmallOffsetClasses = { 14 | 1: "ml-[8.3%]", 15 | 2: "ml-[16.6%]", 16 | 3: "ml-[25%]", 17 | }; 18 | 19 | const SmallClasses = { 20 | 2: "md:w-1/6", 21 | 4: "md:w-1/3", 22 | 5: "md:w-5/12", 23 | 8: "md:w-2/3", 24 | 6: "md:w-1/2", 25 | 10: "md:w-5/6", 26 | 12: "md:w-full", 27 | }; 28 | 29 | const SmallOffsetClasses = { 30 | 1: "md:ml-[8.3%]", 31 | 2: "md:ml-[16.6%]", 32 | 3: "md:ml-[25%]", 33 | 4: "md:ml-[33.3%]", 34 | }; 35 | 36 | const MediumClasses = { 37 | 4: "min-[992px]:w-1/3", 38 | 6: "min-[992px]:w-1/2", 39 | 8: "min-[992px]:w-2/3", 40 | 10: "min-[992px]:w-5/6", 41 | 12: "min-[992px]:w-full", 42 | }; 43 | 44 | const MediumOffsetClasses = { 45 | 1: "min-[992px]:ml-[8.3%]", 46 | 2: "min-[992px]:ml-[16.6%]", 47 | 3: "min-[992px]:ml-[25%]", 48 | 4: "min-[992px]:ml-[33.3%]", 49 | }; 50 | 51 | const LargeClasses = { 52 | 6: "min-[1200px]:w-1/2", 53 | 8: "min-[1200px]:w-2/3", 54 | 10: "min-[1200px]:w-5/6", 55 | }; 56 | 57 | const LargeOffsetClasses = { 58 | 0: "min-[1200px]:ml-0", 59 | 1: "min-[1200px]:ml-[8.3%]", 60 | 2: "min-[1200px]:ml-[16.6%]", 61 | }; 62 | 63 | export const Col = ({ 64 | className, 65 | children, 66 | xs, 67 | sm, 68 | md, 69 | lg, 70 | xsOffset, 71 | smOffset, 72 | mdOffset, 73 | lgOffset, 74 | ...props 75 | }: ColProps) => { 76 | const xsClass = xs ? ExtraSmallClasses[xs] : ""; 77 | const xsOffsetClass = xsOffset ? ExtraSmallOffsetClasses[xsOffset] : ""; 78 | const smClass = sm ? SmallClasses[sm] : ""; 79 | const smOffsetClass = smOffset ? SmallOffsetClasses[smOffset] : ""; 80 | const mdClass = md ? MediumClasses[md] : ""; 81 | const mdOffsetClass = mdOffset ? MediumOffsetClasses[mdOffset] : ""; 82 | const lgClass = lg ? LargeClasses[lg] : ""; 83 | // we have to check condiontionally against undefined, because "lgOffset ?" clear the 0 value, maybe refactor LargeOffsetClasses[0] later to something isn't 0. 84 | const lgOffsetClass = 85 | lgOffset !== undefined ? LargeOffsetClasses[lgOffset] : ""; 86 | 87 | return ( 88 |
94 | {children} 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/col/index.ts: -------------------------------------------------------------------------------- 1 | export { Col } from "./col"; 2 | export type { ColProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/col/types.ts: -------------------------------------------------------------------------------- 1 | export interface ColProps extends React.HTMLAttributes { 2 | className?: string; 3 | children?: React.ReactNode; 4 | xs?: 4 | 6 | 8 | 10 | 12; 5 | sm?: 2 | 4 | 5 | 6 | 8 | 10 | 12; 6 | md?: 4 | 6 | 8 | 10 | 12; 7 | lg?: 6 | 8 | 10; 8 | xsOffset?: 1 | 2 | 3; 9 | smOffset?: 1 | 2 | 3 | 4; 10 | mdOffset?: 1 | 2 | 3 | 4; 11 | lgOffset?: 0 | 1 | 2; 12 | } 13 | -------------------------------------------------------------------------------- /src/color-system/color-system.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AllPalettes } from "./color-system"; 3 | 4 | const story = { 5 | title: "Design System/Color", 6 | component: AllPalettes, 7 | }; 8 | 9 | export const ColorSystem = (): JSX.Element => ; 10 | 11 | export default story; 12 | -------------------------------------------------------------------------------- /src/color-system/color-system.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import colorList from "../colors.css"; 4 | import { Color, ColorList, PaletteProps } from "./types"; 5 | 6 | // ---------------------------------------------------------- // 7 | // HELPER FUNCTIONS // 8 | // ---------------------------------------------------------- // 9 | /** 10 | * Transform colorList from an object to an array of objects 11 | * @example 12 | * Input: { '--blue10': 'var(--blue10)' } 13 | * Output: [{ label: 'blue10', value: 'var(--blue10)' }] 14 | */ 15 | const transformedColorList = Object.keys(colorList as ColorList).map( 16 | (colorName) => ({ 17 | label: colorName.replace("--", ""), 18 | value: (colorList as ColorList)[colorName], 19 | }), 20 | ); 21 | 22 | // Get the background and text color values of each palette item 23 | const getPaletteItemStyle = (color: Color) => { 24 | const itemTextColor = color.label.substring(color.label.length - 2); 25 | 26 | return { 27 | backgroundColor: color.value, 28 | // Extract the scale from the color label. 29 | // If the scale is greater or equal to 50, use white text for the label; otherwise, use dark text. 30 | color: parseInt(itemTextColor, 10) >= 50 ? "#ffffff" : "#0a0a23", 31 | }; 32 | }; 33 | 34 | const getPaletteByColorName = (name: string) => 35 | transformedColorList.filter((color) => color.label.includes(name)); 36 | 37 | // ---------------------------------------------------------- // 38 | // COMPONENTS // 39 | // ---------------------------------------------------------- // 40 | const Palette = ({ colors }: PaletteProps) => { 41 | return ( 42 |
43 | {colors.map((color) => ( 44 |
49 | {color.label} 50 |
51 | ))} 52 |
53 | ); 54 | }; 55 | 56 | export const AllPalettes = (): JSX.Element => { 57 | return ( 58 | <> 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/color-system/types.ts: -------------------------------------------------------------------------------- 1 | export interface Color { 2 | label: string; 3 | value: string; 4 | } 5 | 6 | export interface PaletteProps { 7 | colors: Color[]; 8 | } 9 | 10 | export type ColorList = Record; 11 | -------------------------------------------------------------------------------- /src/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gray00: #ffffff; 3 | --gray05: #f5f6f7; 4 | --gray10: #dfdfe2; 5 | --gray15: #d0d0d5; 6 | --gray45: #858591; 7 | --gray75: #3b3b4f; 8 | --gray80: #2a2a40; 9 | --gray85: #1b1b32; 10 | --gray90: #0a0a23; 11 | 12 | --purple10: #dbb8ff; 13 | --purple50: #9400d3; 14 | --purple90: #5a01a7; 15 | 16 | --yellow05: #fcf8e3; 17 | --yellow10: #faebcc; 18 | --yellow40: #ffc300; 19 | --yellow45: #ffbf00; 20 | --yellow50: #f1be32; 21 | --yellow70: #8a6d3b; 22 | --yellow90: #4d3800; 23 | 24 | --blue05: #d9edf7; 25 | --blue10: #bce8f1; 26 | --blue30: #99c9ff; 27 | --blue50: #198eee; 28 | --blue70: #31708f; 29 | --blue90: #002ead; 30 | 31 | /* These are blue30 and blue90 with an alpha value. 32 | The colors are in RGBA format instead of #RRGGBBAA 33 | in order to be compatible with older browsers. */ 34 | --blue30-translucent: rgba(153, 201, 255, 0.3); 35 | --blue90-translucent: rgba(0, 46, 173, 0.3); 36 | 37 | --green05: #dff0d8; 38 | --green10: #d6e9c6; 39 | --green40: #acd157; 40 | --green70: #3c763d; 41 | --green90: #00471b; 42 | 43 | --red05: #f2dede; 44 | --red10: #ebccd1; 45 | --red15: #ffadad; 46 | --red30: #f8577c; 47 | --red70: #a94442; 48 | --red80: #f82153; 49 | --red90: #850000; 50 | 51 | --orange30: #eda971; 52 | } 53 | 54 | /* Export the variables in order to use them on the Color System Storybook page */ 55 | :export { 56 | --gray00: var(--gray00); 57 | --gray05: var(--gray05); 58 | --gray10: var(--gray10); 59 | --gray15: var(--gray15); 60 | --gray45: var(--gray45); 61 | --gray75: var(--gray75); 62 | --gray80: var(--gray80); 63 | --gray85: var(--gray85); 64 | --gray90: var(--gray90); 65 | 66 | --purple10: var(--purple10); 67 | --purple50: var(--purple50); 68 | --purple90: var(--purple90); 69 | 70 | --yellow05: var(--yellow05); 71 | --yellow10: var(--yellow10); 72 | --yellow40: var(--yellow40); 73 | --yellow45: var(--yellow45); 74 | --yellow50: var(--yellow50); 75 | --yellow70: var(--yellow70); 76 | --yellow90: var(--yellow90); 77 | 78 | --blue05: var(--blue05); 79 | --blue10: var(--blue10); 80 | --blue30: var(--blue30); 81 | --blue50: var(--blue50); 82 | --blue70: var(--blue70); 83 | --blue90: var(--blue90); 84 | 85 | --green05: var(--green05); 86 | --green10: var(--green10); 87 | --green40: var(--green40); 88 | --green70: var(--green70); 89 | --green90: var(--green90); 90 | 91 | --red05: var(--red05); 92 | --red10: var(--red10); 93 | --red15: var(--red15); 94 | --red30: var(--red30); 95 | --red70: var(--red70); 96 | --red80: var(--red80); 97 | --red90: var(--red90); 98 | 99 | --orange30: var(--orange30); 100 | } 101 | 102 | .light-palette { 103 | --foreground-primary: var(--gray90); 104 | --foreground-secondary: var(--gray85); 105 | --foreground-tertiary: var(--gray80); 106 | --foreground-quaternary: var(--gray75); 107 | --foreground-danger: var(--red15); 108 | --foreground-success: var(--green40); 109 | --foreground-info: var(--blue30); 110 | --foreground-warning: var(--yellow45); 111 | 112 | --background-primary: var(--gray00); 113 | --background-secondary: var(--gray05); 114 | --background-tertiary: var(--gray10); 115 | --background-quaternary: var(--gray15); 116 | --background-danger: var(--red90); 117 | --background-success: var(--green90); 118 | --background-info: var(--blue90); 119 | --background-selection: var(--blue90-translucent); 120 | 121 | --focus-outline-color: var(--blue50); 122 | } 123 | 124 | .dark-palette { 125 | --foreground-primary: var(--gray00); 126 | --foreground-secondary: var(--gray05); 127 | --foreground-tertiary: var(--gray10); 128 | --foreground-quaternary: var(--gray15); 129 | --foreground-danger: var(--red90); 130 | --foreground-success: var(--green90); 131 | --foreground-info: var(--blue90); 132 | --foreground-warning: var(--yellow40); 133 | 134 | --background-primary: var(--gray90); 135 | --background-secondary: var(--gray85); 136 | --background-tertiary: var(--gray80); 137 | --background-quaternary: var(--gray75); 138 | --background-danger: var(--red15); 139 | --background-success: var(--green40); 140 | --background-info: var(--blue30); 141 | --background-selection: var(--blue30-translucent); 142 | 143 | --focus-outline-color: var(--blue50); 144 | } 145 | -------------------------------------------------------------------------------- /src/container/container.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryFn, StoryObj } from "@storybook/react"; 3 | import { Container } from "."; 4 | 5 | const story = { 6 | title: "Components/Container", 7 | component: Container, 8 | argTypes: { 9 | fluid: { 10 | control: { 11 | type: "boolean", 12 | }, 13 | }, 14 | }, 15 | } satisfies Meta; 16 | 17 | const Template: StoryFn = (args) => { 18 | return ( 19 | 20 |

21 | Random text to test the element width 22 |

23 |

24 | Random text to test the element width 25 |

26 |

27 | Random text to test the element width 28 |

29 |

30 | Random text to test the element width 31 |

32 |

33 | Random text to test the element width 34 |

35 |

36 | Random text to test the element width 37 |

38 |

39 | Random text to test the element width 40 |

41 |

42 | Random text to test the element width 43 |

44 |

45 | Random text to test the element width 46 |

47 |

48 | Random text to test the element width 49 |

50 |

51 | Random text to test the element width 52 |

53 |

54 | Random text to test the element width 55 |

56 |
57 | ); 58 | }; 59 | 60 | export const Default: StoryObj = { 61 | render: Template, 62 | args: {}, 63 | }; 64 | 65 | export default story; 66 | -------------------------------------------------------------------------------- /src/container/container.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import { Container } from "."; 5 | 6 | describe("", () => { 7 | it("remove width when the container is fluid", () => { 8 | render(Learn to code for free.); 9 | expect(screen.getByText("Learn to code for free.")).toHaveClass( 10 | "mx-auto px-[15px] ", 11 | ); 12 | }); 13 | it("should add className to it", () => { 14 | render( 15 | 16 | Learn to code for free. 17 | , 18 | ); 19 | expect(screen.getByText("Learn to code for free.")).toHaveClass( 20 | "mx-auto px-[15px] my-0 md:w-[750px] min-[992px]:w-[970px] min-[1200px]:w-[1170px] certificate-outer-wrapper", 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/container/container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { ContainerProps } from "./types"; 3 | 4 | export const Container = ({ 5 | children, 6 | className, 7 | fluid, 8 | }: ContainerProps): JSX.Element => { 9 | const elementClasses = fluid 10 | ? "" 11 | : "my-0 md:w-[750px] min-[992px]:w-[970px] min-[1200px]:w-[1170px]"; 12 | 13 | return ( 14 |
15 | {children} 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/container/index.ts: -------------------------------------------------------------------------------- 1 | export { Container } from "./container"; 2 | export type { ContainerProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/container/types.ts: -------------------------------------------------------------------------------- 1 | export type ContainerProps = { 2 | children?: React.ReactNode; 3 | className?: string; 4 | fluid?: boolean; 5 | }; 6 | -------------------------------------------------------------------------------- /src/control-label/control-label.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import { ControlLabel } from "."; 3 | 4 | const story = { 5 | title: "Components/ControlLabel", 6 | component: ControlLabel, 7 | parameters: { 8 | controls: { 9 | include: ["className"], 10 | }, 11 | }, 12 | argTypes: { 13 | className: { control: { type: "text" } }, 14 | htmlFor: { control: { type: "text" } }, 15 | srOnly: { options: ["srOnly", ""] }, 16 | }, 17 | } satisfies Meta; 18 | 19 | type Story = StoryObj; 20 | 21 | export const Default: Story = { 22 | args: { 23 | children: "Control Label", 24 | }, 25 | }; 26 | 27 | export default story; 28 | -------------------------------------------------------------------------------- /src/control-label/control-label.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import { FormGroup } from "../form-group"; 5 | import { ControlLabel } from "."; 6 | 7 | describe("", () => { 8 | it("should inherit `controlId` from FormGroup", () => { 9 | render( 10 | 11 | Label 12 | , 13 | ); 14 | 15 | const labelElement = screen.getByText("Label"); 16 | 17 | expect(labelElement).toBeInTheDocument(); 18 | expect(labelElement).toHaveAttribute("for", "foo"); 19 | }); 20 | 21 | it("should use `htmlFor` over `controlId` if both are specified", () => { 22 | render( 23 | 24 | Label 25 | , 26 | ); 27 | 28 | const labelElement = screen.getByText("Label"); 29 | 30 | expect(labelElement).toBeInTheDocument(); 31 | expect(labelElement).toHaveAttribute("for", "bar"); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/control-label/control-label.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { FormContext } from "../form-group/form-group"; 3 | 4 | import { ControlLabelProps } from "./types"; 5 | 6 | const validationLabel = { 7 | success: "text-background-info", 8 | warning: "text-background-warning", 9 | error: "text-background-danger", 10 | }; 11 | 12 | export const ControlLabel = ({ 13 | className, 14 | htmlFor, 15 | srOnly, 16 | ...props 17 | }: ControlLabelProps): JSX.Element => { 18 | const { controlId, validationState } = useContext(FormContext); 19 | 20 | const labelStyle = validationState 21 | ? validationLabel[validationState] 22 | : "text-foreground-primary"; 23 | const screenOnlyClass = srOnly ? "sr-only" : undefined; 24 | const defaultClasses = [ 25 | "font-bold", 26 | labelStyle, 27 | screenOnlyClass, 28 | className, 29 | ].join(" "); 30 | 31 | return ( 32 |