├── .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
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
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("{expectedText}
17 |Random text to test the element width
25 | 26 | ); 27 | }; 28 | 29 | export const Default: StoryObj21 | 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 |13 | {children} 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/form-control/form-control.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ComponentProps } from "react"; 2 | import { Meta, StoryFn, StoryObj } from "@storybook/react"; 3 | import { FormControl } from "."; 4 | 5 | const story = { 6 | title: "Components/FormControl", 7 | component: FormControl, 8 | parameters: { 9 | controls: { 10 | include: [ 11 | "className", 12 | "id", 13 | "onChange", 14 | "value", 15 | "componentClass", 16 | "placeholder", 17 | "required", 18 | "type", 19 | ], 20 | }, 21 | }, 22 | argTypes: { 23 | className: { control: { type: "text" } }, 24 | id: { control: { type: "text" } }, 25 | onChange: { action: "changed" }, 26 | value: { control: { type: "text" } }, 27 | componentClass: { 28 | options: ["input", "textarea"], 29 | }, 30 | placeholder: { control: { type: "text" } }, 31 | required: { control: "boolean" }, 32 | type: { options: ["text", "email", "url"] }, 33 | }, 34 | } satisfies MetaLaboriosam autem non et nisi.
49 |Given the following code:
14 |temp = "5 degrees"
15 | cel = 0
16 | fahr = float(temp)
17 | cel = (fahr - 32.0) * 5.0 / 9.0
18 | print(cel)
19 |
20 | Which line/lines should be surrounded by try
block?
Given the following code:
33 |temp = "5 degrees"
34 | cel = 0
35 | fahr = float(temp)
36 | cel = (fahr - 32.0) * 5.0 / 9.0
37 | print(cel)
38 |
39 | Which line/lines should be surrounded by try
block?
Given the following code:
59 |temp = "5 degrees"
60 | cel = 0
61 | fahr = float(temp)
62 | cel = (fahr - 32.0) * 5.0 / 9.0
63 | print(cel)
64 |
65 | Which line/lines should be surrounded by try
block?
This story shows how PrismFormatted displays a long line of code. This line should not wrap to a new line, but instead, the overflow content is clipped and can be scrolled into view.
`,
75 | getCodeBlockAriaLabel: (codeName) => `${codeName} code example`,
76 | },
77 | parameters: {
78 | docs: {
79 | source: {
80 | code: `This story shows how PrismFormatted displays a long line of code. This line should not wrap to a new line, but instead, the overflow content is clipped and can be scrolled into view.
\`}
83 | />`,
84 | },
85 | },
86 | },
87 | };
88 |
89 | export const InsideDisclosureElement: Story = {
90 | decorators: [
91 | (Story) => (
92 | This story shows how PrismFormatted displays a long line of code when it's rendered inside a disclosure element. This line should not wrap to a new line, but instead, the overflow content is clipped and can be scrolled into view.
`,
100 | getCodeBlockAriaLabel: (codeName) => `${codeName} code example`,
101 | },
102 | parameters: {
103 | docs: {
104 | description: {
105 | story:
106 | "This story shows how PrismFormatted displays a long line of code when it's rendered inside a disclosure element. The text content should not wrap to a new line, but instead, the overflow content is clipped and can be scrolled into view.",
107 | },
108 | source: {
109 | code: `This story shows how PrismFormatted displays a long line of code when it's rendered inside a disclosure element. This line should not wrap to a new line, but instead, the overflow content is clipped and can be scrolled into view.
\`}
115 | />
116 | An if
statement allows you to run a block of code only when a condition is met. It uses the following syntax:
if (condition) {
125 | logic
126 | }
An if
statement allows you to run a block of code only when a condition is met. It uses the following syntax:
if (condition) {
139 | logic
140 | }
Foo
12 |function favoriteAnimal(animal = 'Giant Panda') {
13 | return animal + " is my favorite animal!"
14 | }
15 |
16 | Bar
`; 17 | 18 | const getCodeBlockAriaLabel = (codeName: string) => `${codeName} code example`; 19 | 20 | describe("PrismFormatted", () => { 21 | it("should render a code block with a region role and an aria label if `noAria` is not specified", () => { 22 | render( 23 |]+)?>/, ''); 156 | } 157 | 158 | useEffect(() => { 159 | // Just in case 'current' has not been created, though it should have been. 160 | if (instructionsRef.current) { 161 | Prism.hooks.add("complete", (prismEnv) => 162 | enhancePrismAccessibility({ 163 | prismEnv, 164 | getCodeBlockAriaLabel, 165 | }), 166 | ); 167 | 168 | if (isCollapsible && disclosureLabel) { 169 | Prism.hooks.add("complete", (prismEnv) => 170 | makePrismCollapsible({ 171 | prismEnv, 172 | disclosureLabel, 173 | }), 174 | ); 175 | } 176 | 177 | Prism.highlightAllUnder(instructionsRef.current); 178 | } 179 | }, [getCodeBlockAriaLabel, isCollapsible, disclosureLabel]); 180 | 181 | return ( 182 |187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /src/prism-formatted/prism-light.css: -------------------------------------------------------------------------------- 1 | @import "../colors.css"; 2 | 3 | .light-palette pre[class*="language-"]::selection, 4 | .light-palette pre[class*="language-"] ::selection, 5 | .light-palette code[class*="language-"]::selection, 6 | .light-palette code[class*="language-"] ::selection { 7 | background: var(--background-selection); 8 | } 9 | 10 | /* a11y color adjustments */ 11 | .light-palette .token.comment, 12 | .light-palette .token.prolog, 13 | .light-palette .token.doctype, 14 | .light-palette .token.cdata { 15 | color: #62707f; 16 | } 17 | 18 | .light-palette .token.punctuation { 19 | color: #38425c; 20 | } 21 | 22 | .light-palette .token.property, 23 | .light-palette .token.tag, 24 | .light-palette .token.constant, 25 | .light-palette .token.symbol, 26 | .light-palette .token.deleted { 27 | color: #e00000; 28 | } 29 | 30 | .light-palette .token.number { 31 | color: #9932cc; 32 | } 33 | 34 | .light-palette .token.boolean { 35 | color: #1f3a93; 36 | } 37 | 38 | .light-palette .token.selector, 39 | .light-palette .token.attr-name, 40 | .light-palette .token.string, 41 | .light-palette .token.char, 42 | .light-palette .token.builtin, 43 | .light-palette .token.inserted { 44 | color: #008040; 45 | } 46 | 47 | .light-palette code .token.operator { 48 | background: none; 49 | } 50 | 51 | .light-palette .token.operator, 52 | .light-palette .token.entity, 53 | .light-palette .token.url, 54 | .light-palette .language-css .token.string, 55 | .light-palette .style .token.string { 56 | color: #38425c; 57 | } 58 | 59 | .light-palette .token.atrule, 60 | .light-palette .token.attr-value, 61 | .light-palette .token.keyword { 62 | color: #2574a9; 63 | } 64 | 65 | .light-palette .token.function, 66 | .light-palette .token.class-name { 67 | color: #992900; 68 | } 69 | 70 | .light-palette .token.regex, 71 | .light-palette .token.important, 72 | .light-palette .token.variable { 73 | color: #856514; 74 | } 75 | 76 | .light-palette .line-numbers-rows > span::before { 77 | color: #62707f; 78 | } 79 | -------------------------------------------------------------------------------- /src/prism-formatted/types.ts: -------------------------------------------------------------------------------- 1 | interface PrismFormattedBaseProps { 2 | className?: string; 3 | text: string; 4 | getCodeBlockAriaLabel: (codeName: string) => string; 5 | useSpan?: boolean; 6 | noAria?: boolean; 7 | hasLineNumbers?: boolean; 8 | } 9 | 10 | type PrismFormattedDisclosure = 11 | | { 12 | isCollapsible?: false; 13 | disclosureLabel?: never; 14 | } 15 | | { 16 | isCollapsible: true; 17 | disclosureLabel: string; 18 | }; 19 | 20 | export type PrismFormattedProps = PrismFormattedBaseProps & 21 | PrismFormattedDisclosure; 22 | -------------------------------------------------------------------------------- /src/quiz-question/answer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RadioGroup } from "@headlessui/react"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; 5 | 6 | import { QuizQuestionValidation, type QuizQuestionAnswer } from "./types"; 7 | 8 | interface AnswerProps 9 | extends QuizQuestionAnswer { 10 | checked?: boolean; 11 | disabled?: boolean; 12 | } 13 | 14 | const radioIconDefaultClasses = [ 15 | "block", 16 | "relative", 17 | "w-[20px]", 18 | "h-[20px]", 19 | "bg-background-secondary", 20 | "rounded-full", 21 | "border-2", 22 | "border-foreground-primary", 23 | "me-[15px]", 24 | "shrink-0", 25 | ]; 26 | 27 | const radioIconActiveClasses = [ 28 | "outline", 29 | "outline-3", 30 | "outline-focus-outline-color", 31 | "outline-offset-2", 32 | ]; 33 | 34 | const radioIconCheckedClasses = [ 35 | "before:absolute", 36 | "before:w-[10px]", 37 | "before:h-[10px]", 38 | "before:bg-foreground-primary", 39 | "before:rounded-full", 40 | "before:top-1/2", 41 | "before:start-1/2", 42 | "before:-translate-x-1/2", 43 | "before:-translate-y-1/2", 44 | ]; 45 | 46 | const radioOptionDefaultClasses = [ 47 | // Set `focus:outline-none` to remove the Headless UI's default focus outline, 48 | // which highlights the entire option div while we only want to highlight the radio icon. 49 | "focus:outline-none", 50 | "cursor-pointer", 51 | "p-[20px]", 52 | "flex", 53 | "items-center", 54 | ]; 55 | 56 | const radioWrapperDefaultClasses = [ 57 | "flex", 58 | "flex-col", 59 | "border-x-4", 60 | "border-t-4", 61 | "last:border-b-4", 62 | "border-background-tertiary", 63 | "bg-background-primary", 64 | ]; 65 | 66 | const RadioIcon = ({ 67 | active, 68 | checked, 69 | }: { 70 | active: boolean; 71 | checked: boolean; 72 | }) => { 73 | let radioCls = [...radioIconDefaultClasses]; 74 | 75 | if (active) { 76 | radioCls = radioCls.concat(radioIconActiveClasses); 77 | } 78 | if (checked) { 79 | radioCls = radioCls.concat(radioIconCheckedClasses); 80 | } 81 | 82 | return ; 83 | }; 84 | 85 | const ValidationMessage = ({ state, message }: QuizQuestionValidation) => { 86 | return state === "correct" ? ( 87 | 88 |
94 | ) : ( 95 |92 | {message} 93 | 96 |
102 | ); 103 | }; 104 | 105 | export const Answer =100 | {message} 101 | ({ 106 | value, 107 | label, 108 | disabled, 109 | checked, 110 | validation, 111 | feedback, 112 | }: AnswerProps ) => { 113 | const getRadioWrapperCls = () => { 114 | const cls = [...radioWrapperDefaultClasses]; 115 | 116 | if (checked && validation?.state === "correct") 117 | cls.push("border-l-background-success"); 118 | if (checked && validation?.state === "incorrect") 119 | cls.push("border-l-background-danger"); 120 | 121 | return cls.join(" "); 122 | }; 123 | 124 | const getRadioOptionCls = () => { 125 | const cls = [...radioOptionDefaultClasses]; 126 | 127 | if (disabled) 128 | cls.push("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-80"); 129 | return cls.join(" "); 130 | }; 131 | 132 | return ( 133 | 134 |165 | ); 166 | }; 167 | -------------------------------------------------------------------------------- /src/quiz-question/index.ts: -------------------------------------------------------------------------------- 1 | export { QuizQuestion } from "./quiz-question"; 2 | export { 3 | type QuizQuestionProps, 4 | type QuizQuestionAnswer, 5 | type QuizQuestionValidation, 6 | } from "./types"; 7 | -------------------------------------------------------------------------------- /src/quiz-question/quiz-question.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { RadioGroup } from "@headlessui/react"; 3 | 4 | import type { QuizQuestionAnswer, QuizQuestionProps } from "./types"; 5 | import { Answer } from "./answer"; 6 | 7 | const QuestionText = ({ 8 | question, 9 | position, 10 | }: { 11 | question: ReactNode; 12 | position?: number; 13 | }) => { 14 | if (position == null) { 15 | return {question}; 16 | } 17 | 18 | return ( 19 | 20 | {position}. 21 | 22 | {question} 23 | 24 | ); 25 | }; 26 | 27 | /** 28 | * QuizQuestion is a radio group that allows users to select a single option from a list of multiple options. 29 | * The component can be used as a standalone component or in a group of multiple questions. 30 | * 31 | * QuizQuestion does not track its selected option internally, 32 | * but instead, it provides a `selectedAnswer` and an `onChange` props, 33 | * giving the parent component full control over the selection handling logic. 34 | */ 35 | export const QuizQuestion =140 | {({ active }) => ( 141 | <> 142 | 149 | {(!!validation || !!feedback) && ( 150 | // Remove the default bottom margin of the validation message `p`, 151 | // and apply a bottom padding of 20px to match the top padding of RadioGroup.Option 152 |143 | 144 | {label} 145 | 146 | > 147 | )} 148 |153 | {validation && ( 154 |163 | )} 164 |158 | )} 159 | {feedback && ( 160 | {feedback}161 | )} 162 |({ 36 | question, 37 | answers, 38 | required, 39 | disabled, 40 | selectedAnswer, 41 | onChange, 42 | position, 43 | }: QuizQuestionProps ) => { 44 | const handleChange = ( 45 | selectedOption: QuizQuestionAnswer ["value"], 46 | ) => { 47 | if (!onChange) { 48 | return; 49 | } 50 | 51 | onChange(selectedOption); 52 | }; 53 | 54 | return ( 55 | 64 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/quiz-question/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export interface QuizQuestionAnswer65 | 67 | 68 | {answers.map(({ value, label, feedback, validation }) => { 69 | const checked = selectedAnswer === value; 70 | return ( 71 |66 | 80 | ); 81 | })} 82 | { 4 | label: ReactNode; 5 | value: T; 6 | feedback?: ReactNode; 7 | 8 | /** 9 | * Information needed to render the validation status 10 | */ 11 | validation?: QuizQuestionValidation; 12 | } 13 | 14 | export interface QuizQuestionValidation { 15 | state: "correct" | "incorrect"; 16 | message: string; 17 | } 18 | 19 | export interface QuizQuestionProps { 20 | /** 21 | * Question text, can be plain text or contain code. 22 | * If the question text contains code, use the PrismFormatted component to ensure the code is rendered correctly. 23 | */ 24 | question: ReactNode; 25 | 26 | /** 27 | * Answer options 28 | */ 29 | answers: QuizQuestionAnswer []; 30 | 31 | /** 32 | * Position of the question amongst its siblings 33 | */ 34 | position?: number; 35 | 36 | /** 37 | * Whether the question is required 38 | */ 39 | required?: boolean; 40 | 41 | /** 42 | * Whether the question is disabled 43 | */ 44 | disabled?: boolean; 45 | 46 | /** 47 | * Value of the selected answer 48 | */ 49 | selectedAnswer?: AnswerT; 50 | 51 | /** 52 | * Change event handler, called when an answer is selected 53 | */ 54 | onChange?: (selectedAnswer: AnswerT) => void; 55 | } 56 | -------------------------------------------------------------------------------- /src/quiz/index.ts: -------------------------------------------------------------------------------- 1 | export { Quiz } from "./quiz"; 2 | export { useQuiz } from "./use-quiz"; 3 | -------------------------------------------------------------------------------- /src/quiz/quiz.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, within } from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | import { Quiz } from "./quiz"; 6 | import { type QuizProps } from "./types"; 7 | import { useQuiz } from "./use-quiz"; 8 | 9 | const ControlledQuiz = ({ disabled, required }: Partial >) => { 10 | const { questions } = useQuiz({ 11 | initialQuestions: [ 12 | { 13 | question: "Lorem ipsum dolor sit amet", 14 | answers: [ 15 | { label: "Option 1", value: 1 }, 16 | { label: "Option 2", value: 2 }, 17 | { label: "Option 3", value: 3 }, 18 | ], 19 | correctAnswer: 1, 20 | }, 21 | { 22 | question: "Consectetur adipiscing elit", 23 | answers: [ 24 | { label: "Option 1", value: 1 }, 25 | { label: "Option 2", value: 2 }, 26 | { label: "Option 3", value: 3 }, 27 | ], 28 | correctAnswer: 2, 29 | }, 30 | { 31 | question: "Fugit itaque delectus voluptatem alias aliquid", 32 | answers: [ 33 | { label: "Option 1", value: 1 }, 34 | { label: "Option 2", value: 2 }, 35 | { label: "Option 3", value: 3 }, 36 | ], 37 | correctAnswer: 3, 38 | }, 39 | ], 40 | validationMessages: { 41 | correct: "Correct", 42 | incorrect: "Incorrect", 43 | }, 44 | passingPercent: 80, 45 | }); 46 | 47 | return ; 48 | }; 49 | 50 | describe(" ", () => { 51 | it("should render as list", () => { 52 | render( ); 53 | 54 | expect(screen.getByRole("list")).toBeInTheDocument(); 55 | expect(screen.getAllByRole("listitem")).toHaveLength(3); 56 | }); 57 | 58 | it("should render the questions with their position correctly", () => { 59 | render( ); 60 | 61 | expect( 62 | screen.getByRole("radiogroup", { 63 | name: "1. Lorem ipsum dolor sit amet", 64 | }), 65 | ).toBeInTheDocument(); 66 | 67 | expect( 68 | screen.getByRole("radiogroup", { 69 | name: "2. Consectetur adipiscing elit", 70 | }), 71 | ).toBeInTheDocument(); 72 | 73 | expect( 74 | screen.getByRole("radiogroup", { 75 | name: "3. Fugit itaque delectus voluptatem alias aliquid", 76 | }), 77 | ).toBeInTheDocument(); 78 | }); 79 | 80 | it("should reflect the selected answers correctly", async () => { 81 | render( ); 82 | 83 | const question1 = screen.getByRole("radiogroup", { 84 | name: "1. Lorem ipsum dolor sit amet", 85 | }); 86 | const question1Option = within(question1).getByRole("radio", { 87 | name: "Option 1", 88 | }); 89 | 90 | const question2 = screen.getByRole("radiogroup", { 91 | name: "2. Consectetur adipiscing elit", 92 | }); 93 | const question2Option = within(question2).getByRole("radio", { 94 | name: "Option 3", 95 | }); 96 | 97 | expect(question1Option).not.toBeChecked(); 98 | expect(question2Option).not.toBeChecked(); 99 | 100 | await userEvent.click(question1Option); 101 | await userEvent.click(question2Option); 102 | 103 | expect(question1Option).toBeChecked(); 104 | expect(question2Option).toBeChecked(); 105 | }); 106 | 107 | it("should mark all questions as disabled if `disabled` is `true`", () => { 108 | render( ); 109 | 110 | const answers = screen.getAllByRole("radio"); 111 | 112 | answers.forEach((answer) => { 113 | expect(answer).toHaveAttribute("aria-disabled", "true"); 114 | }); 115 | }); 116 | 117 | it("should mark all questions as required if `required` is `true`", () => { 118 | render( ); 119 | 120 | const questions = screen.getAllByRole("radiogroup"); 121 | 122 | questions.forEach((question) => { 123 | expect(question).toBeRequired(); 124 | }); 125 | }); 126 | }); 127 | 128 | // ------------------------------ 129 | // Type tests 130 | // ------------------------------ 131 | // Quiz without explicit type 132 | ; 146 | 147 | // Quiz with `number` type 148 | 149 | questions={[ 150 | { 151 | question: "Lorem ipsum dolor sit amet", 152 | answers: [ 153 | // @ts-expect-error - `value` type must be in accordance with the specified type 154 | { label: "Option 1", value: "1" }, 155 | { label: "Option 2", value: 2 }, 156 | { label: "Option 3", value: 3 }, 157 | ], 158 | correctAnswer: 1, 159 | }, 160 | ]} 161 | />; 162 | 163 | // Quiz with `string` type 164 | 165 | questions={[ 166 | { 167 | question: "Lorem ipsum dolor sit amet", 168 | answers: [ 169 | // @ts-expect-error - `value` type must be in accordance with the specified type 170 | { label: "Option 1", value: 1 }, 171 | { label: "Option 2", value: "2" }, 172 | { label: "Option 3", value: "3" }, 173 | ], 174 | }, 175 | ]} 176 | />; 177 | 178 | // Quiz with `value` as number and `selectedAnswer` as string 179 | 180 | questions={[ 181 | { 182 | question: "Lorem ipsum dolor sit amet", 183 | answers: [ 184 | { label: "Option 1", value: 1 }, 185 | { label: "Option 2", value: 2 }, 186 | { label: "Option 3", value: 3 }, 187 | ], 188 | // @ts-expect-error - `value` and `selectedAnswer` must have the same type 189 | selectedAnswer: "1", 190 | }, 191 | ]} 192 | />; 193 | 194 | // Quiz with `value` as string and `selectedAnswer` as number 195 | 196 | questions={[ 197 | { 198 | question: "Lorem ipsum dolor sit amet", 199 | answers: [ 200 | { label: "Option 1", value: "1" }, 201 | { label: "Option 2", value: "2" }, 202 | { label: "Option 3", value: "3" }, 203 | ], 204 | // @ts-expect-error - `value` and `selectedAnswer` must have the same type 205 | selectedAnswer: 1, 206 | }, 207 | ]} 208 | />; 209 | 210 | // Quiz with `value` as number and `correctAnswer` as string 211 | 212 | questions={[ 213 | { 214 | question: "Lorem ipsum dolor sit amet", 215 | answers: [ 216 | { label: "Option 1", value: 1 }, 217 | { label: "Option 2", value: 2 }, 218 | { label: "Option 3", value: 3 }, 219 | ], 220 | // @ts-expect-error - `value` and `selectedAnswer` must have the same type 221 | correctAnswer: "1", 222 | }, 223 | ]} 224 | />; 225 | 226 | // Quiz with `value` as string and `correctAnswer` as number 227 | 228 | questions={[ 229 | { 230 | question: "Lorem ipsum dolor sit amet", 231 | answers: [ 232 | { label: "Option 1", value: "1" }, 233 | { label: "Option 2", value: "2" }, 234 | { label: "Option 3", value: "3" }, 235 | ], 236 | // @ts-expect-error - `value` and `selectedAnswer` must have the same type 237 | correctAnswer: 1, 238 | }, 239 | ]} 240 | />; 241 | -------------------------------------------------------------------------------- /src/quiz/quiz.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { QuizQuestion } from "../quiz-question"; 4 | import { type QuizProps } from "./types"; 5 | 6 | export const Quiz = ({ 7 | questions, 8 | disabled, 9 | required, 10 | }: QuizProps ) => { 11 | return ( 12 | 13 | {questions.map((question, index) => ( 14 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/quiz/types.ts: -------------------------------------------------------------------------------- 1 | import type { QuizQuestionAnswer, QuizQuestionProps } from "../quiz-question"; 2 | 3 | export interface QuizProps- 15 |
22 | ))} 23 |21 | { 4 | questions: Question []; 5 | disabled?: boolean; 6 | required?: boolean; 7 | } 8 | 9 | // This interface is a subset of QuizQuestionProps. 10 | // The props are limited to ensure that 11 | // their configurations don't collide with Quiz'. 12 | // For example: Quiz should be able to apply `disabled` to all questions 13 | // without being overriden by the `disabled` prop of the individual question. 14 | export interface Question { 15 | /** 16 | * Question text, can be plain text or contain code. 17 | * If the question text contains code, use the PrismFormatted component to ensure the code is rendered correctly. 18 | */ 19 | question: QuizQuestionProps ["question"]; 20 | 21 | /** 22 | * Answer options 23 | */ 24 | answers: QuizQuestionAnswer []; 25 | 26 | /** 27 | * Value of the correct answer 28 | */ 29 | correctAnswer: AnswerT; 30 | 31 | /** 32 | * Change event handler, called when an answer is selected 33 | */ 34 | onChange?: (selectedAnswer: AnswerT) => void; 35 | 36 | /** 37 | * Value of the selected answer 38 | */ 39 | selectedAnswer?: AnswerT; 40 | } 41 | -------------------------------------------------------------------------------- /src/quiz/use-quiz.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { type Question } from "./types"; 4 | import { QuizQuestionAnswer } from "../quiz-question"; 5 | 6 | type InitialQuestion = Omit< 7 | Question , 8 | "onChange" 9 | >; 10 | 11 | type ReturnedQuestion = Question & { 12 | onChange: (selectedAnswer: AnswerT) => void; 13 | }; 14 | 15 | interface Props { 16 | initialQuestions: InitialQuestion []; 17 | validationMessages: { 18 | correct: string; 19 | incorrect: string; 20 | }; 21 | passingPercent: number; 22 | onSuccess?: () => void; 23 | onFailure?: () => void; 24 | } 25 | 26 | type ValidationData = 27 | | { validated: true; grade: number; correctAnswerCount: number } 28 | | { validated: false; grade?: never; correctAnswerCount?: never }; 29 | 30 | type UseQuizReturnType = ValidationData & { 31 | questions: ReturnedQuestion []; 32 | validateAnswers: () => void; 33 | }; 34 | 35 | export const useQuiz = ({ 36 | initialQuestions, 37 | validationMessages, 38 | onSuccess, 39 | onFailure, 40 | passingPercent, 41 | }: Props ): UseQuizReturnType => { 42 | const [questions, setQuestions] = 43 | useState []>(initialQuestions); 44 | const [validation, setValidation] = useState ({ 45 | validated: false, 46 | }); 47 | 48 | const questionsWithChangeHandling = questions.map((question, index) => ({ 49 | ...question, 50 | onChange: (selectedAnswer: AnswerT) => { 51 | // update the selected answer for this question 52 | setQuestions((prevQuestions) => 53 | prevQuestions.map((prevQuestion, prevIndex) => 54 | prevIndex === index 55 | ? { ...prevQuestion, selectedAnswer } 56 | : prevQuestion, 57 | ), 58 | ); 59 | }, 60 | })); 61 | 62 | const validateAnswers = () => { 63 | setQuestions((prevQuestion) => { 64 | const updatedQuestions: Question [] = prevQuestion.map( 65 | (question) => { 66 | const answersWithValidation = question.answers.map((answer) => { 67 | let validation: QuizQuestionAnswer ["validation"]; 68 | 69 | // Only pass validation to the selected answer 70 | if (answer.value === question.selectedAnswer) { 71 | validation = 72 | answer.value === question.correctAnswer 73 | ? { 74 | state: "correct", 75 | message: validationMessages.correct, 76 | } 77 | : { 78 | state: "incorrect", 79 | message: validationMessages.incorrect, 80 | }; 81 | } 82 | 83 | return { ...answer, validation }; 84 | }); 85 | 86 | return { ...question, answers: answersWithValidation }; 87 | }, 88 | ); 89 | 90 | const correctCount = updatedQuestions.filter( 91 | ({ selectedAnswer, correctAnswer }) => selectedAnswer === correctAnswer, 92 | ).length; 93 | 94 | const grade = parseFloat( 95 | ((correctCount / initialQuestions.length) * 100).toFixed(2), 96 | ); 97 | 98 | setValidation({ 99 | validated: true, 100 | grade, 101 | correctAnswerCount: correctCount, 102 | }); 103 | 104 | if (grade >= passingPercent) { 105 | onSuccess && onSuccess(); 106 | } else { 107 | onFailure && onFailure(); 108 | } 109 | 110 | return updatedQuestions; 111 | }); 112 | }; 113 | 114 | return { 115 | questions: questionsWithChangeHandling, 116 | validateAnswers, 117 | ...validation, 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /src/row/index.ts: -------------------------------------------------------------------------------- 1 | export { Row } from "./row"; 2 | export type { RowProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/row/row.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryObj } from "@storybook/react"; 3 | import { Row } from "."; 4 | 5 | const story = { 6 | title: "Components/Row", 7 | component: Row, 8 | } satisfies Meta ; 9 | 10 | type Story = StoryObj ; 11 | 12 | export const Default: Story = { 13 | args: { 14 | children: Random text to test the element width
, 15 | }, 16 | }; 17 | 18 | export default story; 19 | -------------------------------------------------------------------------------- /src/row/row.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import { Row } from "."; 5 | 6 | describe("", () => { 7 | it("Row can accept className", () => { 8 | render(
Learn to code for free.
); 9 | expect(screen.getByText("Learn to code for free.")).toHaveClass( 10 | "mx-[-15px] h-full", 11 | ); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/row/row.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { RowProps } from "./types"; 4 | 5 | export const Row = ({ className, children, ...rest }: RowProps) => { 6 | return ( 7 |8 | {children} 9 |10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/row/types.ts: -------------------------------------------------------------------------------- 1 | export interface RowProps extends React.HTMLAttributes{ 2 | className?: string; 3 | children?: React.ReactNode; 4 | } 5 | -------------------------------------------------------------------------------- /src/spacer/index.ts: -------------------------------------------------------------------------------- 1 | export { Spacer } from "./spacer"; 2 | -------------------------------------------------------------------------------- /src/spacer/spacer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryFn, StoryObj } from "@storybook/react"; 3 | import { Spacer } from "./spacer"; 4 | import { Button } from "../button"; 5 | 6 | const story = { 7 | title: "Components/Spacer", 8 | component: Spacer, 9 | } satisfies Meta ; 10 | 11 | type Story = StoryObj ; 12 | 13 | const Template: StoryFn = (args) => ( 14 | <> 15 | 16 | 17 | 18 | > 19 | ); 20 | 21 | export const XXS: Story = { 22 | render: Template, 23 | args: { 24 | size: "xxs", 25 | }, 26 | }; 27 | 28 | export const XS: Story = { 29 | render: Template, 30 | args: { 31 | size: "xs", 32 | }, 33 | }; 34 | 35 | export const S: Story = { 36 | render: Template, 37 | args: { 38 | size: "s", 39 | }, 40 | }; 41 | 42 | export const M: Story = { 43 | render: Template, 44 | args: { 45 | size: "m", 46 | }, 47 | }; 48 | 49 | export const L: Story = { 50 | render: Template, 51 | args: { 52 | size: "l", 53 | }, 54 | }; 55 | 56 | export const XL: Story = { 57 | render: Template, 58 | args: { 59 | size: "xl", 60 | }, 61 | }; 62 | 63 | export const XXL: Story = { 64 | render: Template, 65 | args: { 66 | size: "xxl", 67 | }, 68 | }; 69 | 70 | export default story; 71 | -------------------------------------------------------------------------------- /src/spacer/spacer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const sizes = { 4 | xxs: "h-[5px]", 5 | xs: "h-[10px]", 6 | s: "h-[20px]", 7 | m: "h-[30px]", 8 | l: "h-[60px]", 9 | xl: "h-[90px]", 10 | xxl: "h-[180px]", 11 | } as const; 12 | 13 | interface SpacerProps { 14 | /** 15 | * Sizes: 16 | * - xxs: 5px 17 | * - xs: 10px 18 | * - s: 20px 19 | * - m: 30px 20 | * - l: 60px 21 | * - xl: 90px 22 | * - xxl: 180px 23 | */ 24 | size: keyof typeof sizes; 25 | } 26 | 27 | export const Spacer = ({ size }: SpacerProps) => { 28 | // NOTE: Do not construct class names dynamically 29 | // https://tailwindcss.com/docs/content-configuration#classes-arent-generated 30 | return ; 31 | }; 32 | -------------------------------------------------------------------------------- /src/table/index.ts: -------------------------------------------------------------------------------- 1 | export { Table } from "./table"; 2 | export type { TableProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/table/table.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryFn, StoryObj } from "@storybook/react"; 3 | import { Table } from "."; 4 | 5 | const exampleTable = ( 6 | <> 7 | 8 | 9 | 14 | 15 | 16 |# 10 |First Name 11 |Last Name 12 |Username 13 |17 | 22 |1 18 |Mark 19 |Otto 20 |@mdo 21 |23 | 28 |2 24 |John 25 |Loos 26 |@mlos 27 |29 | 34 | 35 | > 36 | ); 37 | 38 | const story = { 39 | title: "Components/Table", 40 | component: Table, 41 | parameters: { 42 | controls: { 43 | include: [ 44 | "variant", 45 | "size", 46 | "bordered", 47 | "borderless", 48 | "hover", 49 | "striped", 50 | "condensed", 51 | "responsive", 52 | ], 53 | }, 54 | }, 55 | argTypes: { 56 | striped: { 57 | options: [true, false], 58 | control: { type: "radio" }, 59 | }, 60 | condensed: { 61 | options: [true, false], 62 | control: { type: "radio" }, 63 | }, 64 | }, 65 | } satisfies Meta3 30 |Joe 31 |Kot 32 |@mko 33 |; 66 | 67 | const Template: StoryFn = (args) => ( 68 | {exampleTable}
69 | ); 70 | 71 | type Story = StoryObj; 72 | 73 | export const Default: Story = { 74 | render: Template, 75 | 76 | args: { 77 | condensed: false, 78 | striped: false, 79 | }, 80 | }; 81 | 82 | export const Condensed: Story = { 83 | render: Template, 84 | 85 | args: { 86 | condensed: true, 87 | }, 88 | }; 89 | 90 | export const Striped: Story = { 91 | render: Template, 92 | 93 | args: { 94 | striped: true, 95 | }, 96 | }; 97 | 98 | export default story; 99 | -------------------------------------------------------------------------------- /src/table/table.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | 4 | import { Table } from "."; 5 | 6 | describe(" ", () => { 7 | it("should apply striped bg color to every odd
element", () => { 8 | render( ); 9 | 10 | const table = screen.getByRole("table"); 11 | const oddTableRowClass = 12 | "[&>tbody>tr:nth-of-type(odd)]:bg-background-tertiary"; 13 | 14 | expect(table).toHaveClass(oddTableRowClass); 15 | }); 16 | it("should apply the condensed to
and elements", () => { 17 | render( ); 18 | 19 | const table = screen.getByRole("table"); 20 | const tableDataClass = "[&_td]:p-1 "; 21 | const tableHeaderClass = "[&_th]:p-1"; 22 | 23 | expect(table).toHaveClass(tableDataClass + tableHeaderClass); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/table/table.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { TableProps } from "./types"; 4 | 5 | const defaultClassNames = [ 6 | "table-auto", 7 | "w-full", 8 | "max-w-full", 9 | "border-collapse", 10 | "text-start", 11 | "text-foreground-tertiary", 12 | "[&_th]:font-normal", 13 | ]; 14 | 15 | const computeClassNames = ({ 16 | condensed, 17 | striped, 18 | }: { 19 | condensed: boolean; 20 | striped: boolean; 21 | }) => { 22 | const classNames = [...defaultClassNames]; 23 | if (condensed) classNames.push("[&_td]:p-1 [&_th]:p-1"); 24 | else classNames.push("[&_td]:p-2 [&_th]:p-2"); 25 | if (striped) 26 | classNames.push("[&>tbody>tr:nth-of-type(odd)]:bg-background-tertiary"); 27 | 28 | return classNames.join(" "); 29 | }; 30 | 31 | export const Table = React.forwardRef
( 32 | ({ striped = false, condensed = false, ...props }, ref) => { 33 | const classNames = React.useMemo( 34 | () => computeClassNames({ condensed, striped }), 35 | [condensed, striped], 36 | ); 37 | 38 | const table = ; 39 | 40 | return table; 41 | }, 42 | ); 43 | 44 | Table.displayName = "Table"; 45 | -------------------------------------------------------------------------------- /src/table/types.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export interface TableProps 4 | extends React.TableHTMLAttributes
{ 5 | condensed?: boolean; 6 | striped?: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"; 2 | -------------------------------------------------------------------------------- /src/tabs/tabs.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryFn, StoryObj } from "@storybook/react"; 3 | import { Tabs, TabsList, TabsTrigger, TabsContent } from "."; 4 | 5 | const story = { 6 | title: "Components/Tabs", 7 | component: Tabs, 8 | } satisfies Meta ; 9 | 10 | const Template: StoryFn = (args) => { 11 | return ( 12 | 13 | 22 | ); 23 | }; 24 | 25 | type Story = StoryObj14 | 17 |Code 15 |Tests 16 |18 | 20 |here is a code element.
19 |Here is the test for the code. 21 |; 26 | 27 | export const Default: Story = { 28 | render: Template, 29 | 30 | args: { 31 | id: "uncontrolled-tab-example", 32 | defaultValue: "code", 33 | onSelect: () => { 34 | console.log("onSelect"); 35 | }, 36 | }, 37 | }; 38 | 39 | export default story; 40 | -------------------------------------------------------------------------------- /src/tabs/tabs.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { render, screen } from "@testing-library/react"; 4 | 5 | import { Tabs, TabsList, TabsTrigger, TabsContent } from "."; 6 | 7 | describe(" ", () => { 8 | it("should switch tabs content if the tab trigger is pressed", async () => { 9 | render( 10 | 11 | , 20 | ); 21 | const codeContent = screen.getByText("here is a code element."); 22 | expect(codeContent).toBeInTheDocument(); 23 | 24 | const tabsTrigger = screen.getByText("Tests"); 25 | await userEvent.click(tabsTrigger); 26 | const testContent = screen.getByText("Here is the test for the code."); 27 | expect(testContent).toBeInTheDocument(); 28 | expect(codeContent).not.toBeInTheDocument(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/tabs/tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ComponentPropsWithoutRef } from "react"; 2 | import { Root, List, Trigger, Content } from "@radix-ui/react-tabs"; 3 | 4 | const buttonClassNames = 5 | "flex-1 block relative px-2.5 py-[5px] text-sm text-foreground-secondary border-none aria-selected:font-bold aria-selected:bg-foreground-quaternary aria-selected:text-background-secondary hover:bg-background-quaternary"; 6 | 7 | // remove additional border styles after migration 8 | const listClassNames = 9 | "flex mb-0 pl-0 mt-0 border-b-[1px] border-solid border-foreground-quaternary"; 10 | 11 | export const TabsTrigger = React.forwardRef< 12 | React.ElementRef12 | 15 |Code 13 |Tests 14 |16 | 18 |here is a code element.
17 |Here is the test for the code. 19 |, 13 | ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => { 15 | const triggerClasses = [buttonClassNames, className].join(" "); 16 | return ; 17 | }); 18 | 19 | export const TabsList = React.forwardRef< 20 | React.ElementRef , 21 | ComponentPropsWithoutRef 22 | >(({ className, ...props }, ref) => { 23 | const listClasses = [listClassNames, className].join(" "); 24 | return ; 25 | }); 26 | 27 | export const Tabs = Root; 28 | export const TabsContent = Content; 29 | 30 | TabsContent.displayName = Content.displayName; 31 | TabsTrigger.displayName = Trigger.displayName; 32 | TabsList.displayName = List.displayName; 33 | -------------------------------------------------------------------------------- /src/toggle-button/index.ts: -------------------------------------------------------------------------------- 1 | export { ToggleButton } from "./toggle-button"; 2 | export type { ToggleButtonProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/toggle-button/toggle-button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Meta, StoryObj } from "@storybook/react"; 3 | import { ToggleButton } from "."; 4 | 5 | const story = { 6 | title: "WIP/ToggleButton", 7 | component: ToggleButton, 8 | parameters: { 9 | controls: { 10 | include: [ 11 | "children", 12 | "bsStyle", 13 | "bsSize", 14 | "disabled", 15 | "checked", 16 | "onChange", 17 | "value", 18 | "name", 19 | ], 20 | }, 21 | }, 22 | argTypes: { 23 | bsStyle: { 24 | options: ["primary"], 25 | }, 26 | bsSize: { 27 | options: ["small", "medium", "large"], 28 | }, 29 | disabled: { 30 | options: [true, false], 31 | control: { type: "radio" }, 32 | }, 33 | checked: { 34 | options: [true, false], 35 | control: { type: "radio" }, 36 | }, 37 | onChange: { 38 | action: "changed", 39 | }, 40 | value: { 41 | type: { name: "string" }, 42 | }, 43 | name: { 44 | type: { name: "string" }, 45 | }, 46 | }, 47 | } satisfies Meta
; 48 | 49 | type Story = StoryObj ; 50 | 51 | export const Default: Story = { 52 | args: { 53 | children: "Off", 54 | }, 55 | }; 56 | 57 | export const Checked: Story = { 58 | args: { 59 | checked: true, 60 | children: "On", 61 | value: "Value", 62 | }, 63 | }; 64 | 65 | export const Large: Story = { 66 | args: { 67 | bsSize: "large", 68 | children: "Off", 69 | }, 70 | }; 71 | 72 | export const Medium: Story = { 73 | args: { 74 | bsSize: "medium", 75 | children: "Off", 76 | }, 77 | }; 78 | 79 | export const Disabled: Story = { 80 | args: { 81 | children: "Off", 82 | disabled: true, 83 | }, 84 | }; 85 | 86 | export const RadioChecked: Story = { 87 | args: { 88 | type: "radio", 89 | children: "On", 90 | value: "radio", 91 | name: "radio", 92 | checked: true, 93 | }, 94 | }; 95 | 96 | export const RadioUnchecked: Story = { 97 | args: { 98 | type: "radio", 99 | children: "Off", 100 | value: "radio", 101 | name: "radio", 102 | }, 103 | }; 104 | 105 | export const InsideToggleGroup = (): JSX.Element => { 106 | const [checked, setChecked] = useState(true); 107 | 108 | return ( 109 | <> 110 | { 113 | setChecked(checked); 114 | }} 115 | > 116 | On 117 | 118 |{ 121 | setChecked(!checked); 122 | }} 123 | > 124 | Off 125 | 126 | > 127 | ); 128 | }; 129 | 130 | export default story; 131 | -------------------------------------------------------------------------------- /src/toggle-button/toggle-button.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | import { ToggleButton } from "."; 6 | 7 | describe("", () => { 8 | it("should render the toggle button and text", () => { 9 | render( On ); 10 | 11 | expect(screen.getByRole("button", { name: /on/i })).toBeInTheDocument(); 12 | }); 13 | 14 | it("should call onChange when clicked", async () => { 15 | const onChange = jest.fn(); 16 | render(On ); 17 | 18 | await userEvent.click(screen.getByRole("button", { name: /on/i })); 19 | 20 | expect(onChange).toHaveBeenCalledTimes(1); 21 | }); 22 | 23 | it("should be checked if checked prop is true", () => { 24 | render( 25 |26 | On 27 | , 28 | ); 29 | 30 | expect(screen.getByRole("radio")).toBeChecked(); 31 | }); 32 | 33 | it("should be unchecked if checked prop is false", () => { 34 | render( 35 |36 | On 37 | , 38 | ); 39 | 40 | expect(screen.getByRole("radio")).not.toBeChecked(); 41 | }); 42 | 43 | it("should be aria-disabled if disabled prop is true", () => { 44 | render(On ); 45 | 46 | expect(screen.getByRole("button", { name: /on/i })).toHaveAttribute( 47 | "aria-disabled", 48 | "true", 49 | ); 50 | }); 51 | 52 | it("should not trigger onChange if disabled prop is true", async () => { 53 | const onChange = jest.fn(); 54 | render( 55 |56 | On 57 | , 58 | ); 59 | 60 | await userEvent.click(screen.getByRole("button", { name: /on/i })); 61 | 62 | expect(onChange).not.toHaveBeenCalled(); 63 | }); 64 | 65 | it("should have value property if radio", () => { 66 | render( 67 | , 72 | ); 73 | 74 | expect(screen.getByRole("form")).toHaveFormValues({ 75 | radio: "value", 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/toggle-button/toggle-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ButtonSize, ToggleButtonProps } from "./types"; 3 | 4 | const defaultClassNames = [ 5 | "relative", 6 | "border-3", 7 | "text-center", 8 | "inline-block", 9 | "cursor-pointer", 10 | "border-foreground-secondary", 11 | "focus-within:ring", 12 | "focus-within:ring-focus-outline-color", 13 | "aria-disabled:cursor-not-allowed", 14 | "aria-disabled:opacity-50", 15 | "ml-[-3px]", 16 | "first:ml-0", 17 | ]; 18 | 19 | const computeClassNames = ({ 20 | bsSize, 21 | checked, 22 | disabled, 23 | }: { 24 | bsSize: ButtonSize; 25 | checked?: boolean; 26 | disabled?: boolean; 27 | }) => { 28 | const classNames = [ 29 | ...defaultClassNames, 30 | ...(checked 31 | ? ["cursor-default", "bg-foreground-primary", "text-background-primary"] 32 | : ["bg-background-quaternary", "text-foreground-secondary"]), 33 | ...(disabled 34 | ? ["active:before:hidden"] 35 | : [ 36 | "active:before:w-full", 37 | "active:before:h-full", 38 | "active:before:absolute", 39 | "active:before:inset-0", 40 | "active:before:border-3", 41 | "active:before:border-transparent", 42 | "active:before:bg-gray-900", 43 | "active:before:opacity-20", 44 | "dark:hover:bg-background-primary", 45 | "dark:hover:text-foreground-primary", 46 | ...(checked 47 | ? [ 48 | "hover:bg-background-quaternary", 49 | "hover:text-foreground-secondary", 50 | ] 51 | : ["hover:bg-foreground-primary", "hover:text-background-primary"]), 52 | ]), 53 | ]; 54 | 55 | switch (bsSize) { 56 | case "large": 57 | classNames.push("px-8 py-2.5 text-lg"); 58 | break; 59 | case "medium": 60 | classNames.push("px-6 py-1.5 text-md"); 61 | break; 62 | // default size is 'small' 63 | default: 64 | classNames.push("px-5 py-1 text-sm"); 65 | } 66 | 67 | return classNames.join(" "); 68 | }; 69 | 70 | export const ToggleButton = ({ 71 | bsSize = "small", 72 | type = "button", 73 | disabled, 74 | children, 75 | checked, 76 | onChange, 77 | value, 78 | name, 79 | }: ToggleButtonProps): JSX.Element => { 80 | const classNames = computeClassNames({ bsSize, disabled, checked }); 81 | 82 | const handleChange = () => { 83 | if (!disabled && onChange) { 84 | onChange(true); 85 | } 86 | }; 87 | 88 | if (type === "radio") { 89 | return ( 90 | 103 | ); 104 | } 105 | 106 | return ( 107 | 115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /src/toggle-button/types.ts: -------------------------------------------------------------------------------- 1 | export type ButtonStyle = "primary" | "danger"; 2 | 3 | export type ButtonSize = "small" | "medium" | "large"; 4 | 5 | export interface ToggleButtonProps { 6 | children: React.ReactNode; 7 | bsSize?: ButtonSize; 8 | bsStyle?: ButtonStyle; 9 | disabled?: boolean; 10 | checked?: boolean; 11 | onChange?: (value: boolean) => void; 12 | className?: string; 13 | value?: string; 14 | name?: string; 15 | type?: "button" | "radio"; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/get-theming-class.test.ts: -------------------------------------------------------------------------------- 1 | import { getThemingClass } from "./get-theming-class"; 2 | 3 | // We are interested in the `matches` value 4 | // as it is the result of the `(prefers-color-scheme: dark)` lookup. 5 | // This function allows us to programmatically switch the `matches` value 6 | // in order to validate the value `getThemingClass()` returns. 7 | const mockSystemTheme = (theme?: "light" | "dark") => { 8 | Object.defineProperty(window, "matchMedia", { 9 | writable: true, 10 | value: jest.fn().mockImplementation((query) => ({ 11 | matches: query.includes(theme), 12 | media: query, 13 | })), 14 | }); 15 | }; 16 | 17 | describe("getThemingClass", () => { 18 | it("should return `light-palette` if `theme` is `light`", () => { 19 | const cls = getThemingClass("light"); 20 | expect(cls).toBe("light-palette"); 21 | }); 22 | 23 | it("should return `dark-palette` if `theme` is `dark`", () => { 24 | const cls = getThemingClass("dark"); 25 | expect(cls).toBe("dark-palette"); 26 | }); 27 | 28 | it("should return `light-palette` if `theme` is not specified and system theme is `light`", () => { 29 | mockSystemTheme("light"); 30 | 31 | const cls = getThemingClass(); 32 | expect(cls).toBe("light-palette"); 33 | }); 34 | 35 | it("should return `dark-palette` if `theme` is not specified and system theme is `dark`", () => { 36 | mockSystemTheme("dark"); 37 | 38 | const cls = getThemingClass(); 39 | expect(cls).toBe("dark-palette"); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/utils/get-theming-class.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function returns the theming class that would be added to the consumer's `body` element. 3 | * The function accepts `theme`, which can be controled by the consumer, 4 | * and default to the system theme if `theme` isn't specified. 5 | */ 6 | export const getThemingClass = (theme?: "light" | "dark") => { 7 | if (theme) { 8 | return theme === "dark" ? "dark-palette" : "light-palette"; 9 | } 10 | 11 | return window.matchMedia("(prefers-color-scheme: dark)").matches 12 | ? "dark-palette" 13 | : "light-palette"; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { getThemingClass } from "./get-theming-class"; 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const plugin = require("tailwindcss/plugin"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | "./src/**/*.html", 7 | "./src/**/*.js", 8 | "./src/**/*.ts", 9 | "./src/**/*.tsx", 10 | "!./src/**/*.test.tsx", 11 | ], 12 | darkMode: "class", 13 | theme: { 14 | extend: { 15 | colors: { 16 | transparent: "transparent", 17 | "dark-theme-background": "var(--gray90)", 18 | "light-theme-background": "var(--gray00)", 19 | // Foreground 20 | "foreground-primary": "var(--foreground-primary)", 21 | "foreground-secondary": "var(--foreground-secondary)", 22 | "foreground-tertiary": "var(--foreground-tertiary)", 23 | "foreground-quaternary": "var(--foreground-quaternary)", 24 | "foreground-danger": "var(--foreground-danger)", 25 | "foreground-success": "var(--foreground-success)", 26 | "foreground-info": "var(--foreground-info)", 27 | // Background 28 | "background-primary": "var(--background-primary)", 29 | "background-secondary": "var(--background-secondary)", 30 | "background-tertiary": "var(--background-tertiary)", 31 | "background-quaternary": "var(--background-quaternary)", 32 | "background-danger": "var(--background-danger)", 33 | "background-success": "var(--background-success)", 34 | "background-info": "var(--background-info)", 35 | // Focus outline 36 | "focus-outline-color": "var(--focus-outline-color)", 37 | gray: { 38 | 0: "var(--gray00)", 39 | 50: "var(--gray05)", 40 | 100: "var(--gray10)", 41 | 150: "var(--gray15)", 42 | 450: "var(--gray45)", 43 | 750: "var(--gray75)", 44 | 800: "var(--gray80)", 45 | 850: "var(--gray85)", 46 | 900: "var(--gray90)", 47 | }, 48 | green: { 49 | 50: "var(--green05)", 50 | 100: "var(--green10)", 51 | 400: "var(--green40)", 52 | 700: "var(--green70)", 53 | 900: "var(--green90)", 54 | }, 55 | blue: { 56 | 50: "var(--blue05)", 57 | 100: "var(--blue10)", 58 | 300: "var(--blue30)", 59 | 500: "var(--blue50)", 60 | 700: "var(--blue70)", 61 | 900: "var(--blue90)", 62 | }, 63 | yellow: { 64 | 50: "var(--yellow05)", 65 | 100: "var(--yellow10)", 66 | 400: "var(--yellow40)", 67 | 450: "var(--yellow45)", 68 | 500: "var(--yellow50)", 69 | 700: "var(--yellow70)", 70 | 900: "var(--yellow90)", 71 | }, 72 | red: { 73 | 50: "var(--red05)", 74 | 100: "var(--red10)", 75 | 150: "var(--red15)", 76 | 300: "var(--red30)", 77 | 700: "var(--red70)", 78 | 800: "var(--red80)", 79 | 900: "var(--red90)", 80 | }, 81 | orange: { 82 | 300: "var(--orange30)", 83 | }, 84 | }, 85 | borderWidth: { 86 | 1: "1px", 87 | 3: "3px", 88 | }, 89 | outlineWidth: { 90 | 3: "3px", 91 | }, 92 | fontFamily: { 93 | sans: ["Lato", "sans-serif"], 94 | mono: ["Hack-ZeroSlash", "monospace"], 95 | }, 96 | fontSize: { 97 | // https://tailwindcss.com/docs/font-size#providing-a-default-line-height 98 | // [fontSize, lineHeight] 99 | sm: ["16px", "1.5"], 100 | md: ["18px", "1.42857143"], 101 | lg: ["24px", "1.3333333"], 102 | }, 103 | minHeight: { 104 | "43-px": "43px", 105 | }, 106 | zIndex: { 107 | 2: "2", 108 | 1050: "1050", 109 | }, 110 | }, 111 | }, 112 | plugins: [ 113 | plugin(({ addVariant }) => { 114 | addVariant("aria-disabled", '&[aria-disabled="true"]'); 115 | }), 116 | ], 117 | }; 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES6", 4 | "target": "ES6", 5 | "sourceMap": true, 6 | "jsx": "react", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "node", 10 | "strict": true, 11 | "noEmit": true, 12 | "skipLibCheck": true 13 | }, 14 | "ts-node": { 15 | "compilerOptions": { 16 | "module": "commonjs" 17 | }, 18 | "transpileOnly": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /utils/gen-component-script.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { promisify } from "util"; 4 | 5 | import { component, story, test, barrel, type } from "./gen-component-template"; 6 | 7 | const writeFile = promisify(fs.writeFile); 8 | 9 | // Grab component name from terminal argument 10 | const [name] = process.argv.slice(2); 11 | if (!name) { 12 | throw new Error("You must include a component name."); 13 | } 14 | 15 | if (!/^[A-Z]/.exec(name)) { 16 | throw new Error("Component name must be in PascalCase."); 17 | } 18 | 19 | const toKebabCase = (pascalCasedName: string) => 20 | pascalCasedName 21 | .replace(/([A-Z][a-z])/g, "-$1") // Add a hyphen before each capital letter 22 | .toLowerCase() 23 | .substring(1); // Return the string but exclude the hyphen at the beginning 24 | 25 | const kebabCasedName = toKebabCase(name); 26 | 27 | const dir = path.join(__dirname, `../src/${kebabCasedName}`); 28 | 29 | // Throw an error if the component's folder already exists 30 | if (fs.existsSync(dir)) { 31 | throw new Error("A component with that name already exists."); 32 | } 33 | 34 | // Create the folder 35 | fs.mkdirSync(dir); 36 | 37 | // Create the component file - my-component.tsx 38 | writeFile(`${dir}/${kebabCasedName}.tsx`, component(name)); 39 | 40 | // Create the type file - types.ts 41 | writeFile(`${dir}/types.ts`, type(name)); 42 | 43 | // Create the test file - my-component.test.tsx 44 | writeFile(`${dir}/${kebabCasedName}.test.tsx`, test(name)); 45 | 46 | // Create the Storybook file - my-component.stories.tsx 47 | writeFile(`${dir}/${kebabCasedName}.stories.tsx`, story(name)); 48 | 49 | // Create the barrel file - index.ts 50 | writeFile(`${dir}/index.ts`, barrel(name, kebabCasedName)); 51 | 52 | console.log(`The ${name} component has been created successfully! 🎉`); 53 | -------------------------------------------------------------------------------- /utils/gen-component-template.ts: -------------------------------------------------------------------------------- 1 | // component.tsx 2 | export const component = (name: string): string => ` 3 | import React from 'react'; 4 | 5 | import { ${name}Props } from './types'; 6 | 7 | export const ${name} = ({}: ${name}Props) => { 8 | returnHello, I am a ${name} component; 9 | }; 10 | `; 11 | 12 | // types.ts 13 | export const type = (name: string): string => ` 14 | export interface ${name}Props { 15 | className?: string 16 | } 17 | `; 18 | 19 | // component.test.tsx 20 | export const test = (name: string): string => ` 21 | import React from 'react'; 22 | import { render, screen } from '@testing-library/react'; 23 | import userEvent from '@testing-library/user-event'; 24 | 25 | import { ${name} } from '.'; 26 | 27 | describe('<${name} />', () => { 28 | it('should render correctly', () => {}); 29 | }); 30 | `; 31 | 32 | // component.stories.tsx 33 | export const story = (name: string): string => ` 34 | import React from 'react'; 35 | import { Story } from '@storybook/react'; 36 | import { ${name}, ${name}Props } from '.'; 37 | 38 | const story = { 39 | title: 'WIP/${name}', 40 | component: ${name} 41 | }; 42 | 43 | const Template: Story<${name}Props> = args => { 44 | return <${name} {...args} />; 45 | }; 46 | 47 | export const Default = Template.bind({}); 48 | Default.args = { 49 | // default props go here 50 | }; 51 | 52 | export default story; 53 | `; 54 | 55 | // index.ts 56 | export const barrel = (name: string, kebabCasedName: string): string => ` 57 | export { ${name} } from './${kebabCasedName}'; 58 | export type { ${name}Props } from './types'; 59 | `; 60 | --------------------------------------------------------------------------------