├── .eslintrc.cjs
├── .github
└── workflows
│ ├── pr.yml
│ └── release.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .prettierrc
├── .storybook
├── StoryLayout.tsx
├── main.ts
├── preview-head.html
├── preview.tsx
└── storybook-reset.css
├── LICENSE
├── README.md
├── commitlint.config.ts
├── docs
├── getting-started.mdx
├── introduction.mdx
└── styling.mdx
├── figma-kit
├── package.json
├── postcss.config.cjs
├── release.config.cjs
├── src
│ ├── components
│ │ ├── alert-dialog
│ │ │ ├── alert-dialog.css
│ │ │ ├── alert-dialog.stories.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ └── index.ts
│ │ ├── button
│ │ │ ├── button.css
│ │ │ ├── button.stories.tsx
│ │ │ ├── button.tsx
│ │ │ └── index.ts
│ │ ├── checkbox
│ │ │ ├── checkbox.css
│ │ │ ├── checkbox.stories.tsx
│ │ │ ├── checkbox.tsx
│ │ │ └── index.ts
│ │ ├── collapsible
│ │ │ ├── collapsible.css
│ │ │ ├── collapsible.stories.tsx
│ │ │ ├── collapsible.tsx
│ │ │ └── index.ts
│ │ ├── color-picker
│ │ │ ├── color-picker-alpha.tsx
│ │ │ ├── color-picker-area.tsx
│ │ │ ├── color-picker-hue.tsx
│ │ │ ├── color-picker-input.tsx
│ │ │ ├── color-picker.css
│ │ │ ├── color-picker.stories.tsx
│ │ │ ├── color-picker.tsx
│ │ │ └── index.ts
│ │ ├── context-menu
│ │ │ ├── context-menu.stories.tsx
│ │ │ ├── context-menu.tsx
│ │ │ └── index.ts
│ │ ├── dialog.base
│ │ │ ├── dialog.base.css
│ │ │ ├── dialog.base.tsx
│ │ │ └── index.ts
│ │ ├── dialog
│ │ │ ├── dialog.css
│ │ │ ├── dialog.stories.tsx
│ │ │ ├── dialog.tsx
│ │ │ └── index.ts
│ │ ├── dropdown-menu
│ │ │ ├── dropdown-menu.stories.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ └── index.ts
│ │ ├── flex
│ │ │ ├── flex.css
│ │ │ ├── flex.stories.tsx
│ │ │ ├── flex.tsx
│ │ │ └── index.ts
│ │ ├── icon-button
│ │ │ ├── icon-button.css
│ │ │ ├── icon-button.stories.tsx
│ │ │ ├── icon-button.tsx
│ │ │ └── index.ts
│ │ ├── icon
│ │ │ ├── icon.css
│ │ │ ├── icon.tsx
│ │ │ └── index.ts
│ │ ├── icons
│ │ │ ├── checkmark-indeterminate.tsx
│ │ │ ├── checkmark.tsx
│ │ │ ├── chevron-down.tsx
│ │ │ ├── chevron-left.tsx
│ │ │ ├── chevron-right.tsx
│ │ │ ├── chevron-up.tsx
│ │ │ ├── circle.tsx
│ │ │ ├── close.tsx
│ │ │ ├── index.ts
│ │ │ ├── plus.tsx
│ │ │ └── styles.tsx
│ │ ├── input
│ │ │ ├── index.ts
│ │ │ ├── input.css
│ │ │ ├── input.stories.tsx
│ │ │ └── input.tsx
│ │ ├── menu.base
│ │ │ └── menu.base.css
│ │ ├── popover
│ │ │ ├── index.ts
│ │ │ ├── popover.stories.tsx
│ │ │ └── popover.tsx
│ │ ├── radio-group
│ │ │ ├── index.ts
│ │ │ ├── radio-group.css
│ │ │ ├── radio-group.stories.tsx
│ │ │ └── radio-group.tsx
│ │ ├── segmented-control
│ │ │ ├── index.ts
│ │ │ ├── segmented-control.css
│ │ │ ├── segmented-control.stories.tsx
│ │ │ └── segmented-control.tsx
│ │ ├── select
│ │ │ ├── index.ts
│ │ │ ├── select.css
│ │ │ ├── select.stories.tsx
│ │ │ └── select.tsx
│ │ ├── slider
│ │ │ ├── index.ts
│ │ │ ├── slider.css
│ │ │ ├── slider.stories.tsx
│ │ │ └── slider.tsx
│ │ ├── switch
│ │ │ ├── index.ts
│ │ │ ├── switch.css
│ │ │ ├── switch.stories.tsx
│ │ │ └── switch.tsx
│ │ ├── tabs
│ │ │ ├── index.ts
│ │ │ ├── tabs.css
│ │ │ ├── tabs.stories.tsx
│ │ │ └── tabs.tsx
│ │ ├── text
│ │ │ ├── index.ts
│ │ │ ├── text.css
│ │ │ ├── text.stories.tsx
│ │ │ └── text.tsx
│ │ ├── textarea
│ │ │ ├── index.ts
│ │ │ ├── textarea.css
│ │ │ ├── textarea.stories.tsx
│ │ │ └── textarea.tsx
│ │ ├── tooltip
│ │ │ ├── index.ts
│ │ │ ├── tooltip.css
│ │ │ └── tooltip.tsx
│ │ └── value-field
│ │ │ ├── index.ts
│ │ │ ├── named-colors.ts
│ │ │ ├── types.ts
│ │ │ ├── value-field-base.tsx
│ │ │ ├── value-field-elements.tsx
│ │ │ ├── value-field-hex.tsx
│ │ │ ├── value-field-numeric.tsx
│ │ │ ├── value-field.css
│ │ │ ├── value-field.mdx
│ │ │ └── value-field.stories.tsx
│ ├── index.ts
│ ├── lib
│ │ ├── color.ts
│ │ ├── constants.ts
│ │ ├── dom
│ │ │ ├── focus.ts
│ │ │ └── set-input-value.ts
│ │ ├── number
│ │ │ ├── clamp.ts
│ │ │ └── normalize.ts
│ │ └── react
│ │ │ ├── create-context.tsx
│ │ │ ├── use-compose-refs.ts
│ │ │ ├── use-controllable-state.ts
│ │ │ └── use-select-on-input-click.tsx
│ ├── styles
│ │ ├── figma-development-theme.css
│ │ ├── index.css
│ │ └── tokens
│ │ │ ├── components.css
│ │ │ ├── font-size.css
│ │ │ ├── font-weight.css
│ │ │ ├── font.css
│ │ │ ├── letter-spacing.css
│ │ │ ├── line-height.css
│ │ │ ├── radius.css
│ │ │ ├── shadow.css
│ │ │ └── space.css
│ └── tailwind
│ │ └── preset.js
├── test
│ ├── setup.ts
│ ├── value-field-base.test.tsx
│ ├── value-field-hex.test.tsx
│ ├── value-field-numeric-evaluator.test.ts
│ └── value-field-numeric.test.tsx
├── tsconfig.json
└── vite.config.ts
├── generate-react-cli.json
├── media
├── github-banner-dark.png
└── github-banner-light.png
├── package.json
├── patches
└── @radix-ui__react-slider@1.2.0.patch
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
├── tsconfig.node.json
└── website
├── README.md
├── index.html
├── package.json
├── postcss.config.js
├── public
└── vite.svg
├── src
├── App.tsx
├── index.css
├── lib
│ └── .keep
├── main.tsx
├── views
│ └── .keep
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'prettier',
8 | 'plugin:react-hooks/recommended',
9 | 'plugin:storybook/recommended',
10 | ],
11 | plugins: ['react-refresh', 'import'],
12 | ignorePatterns: ['dist', '.eslintrc.cjs'],
13 | parser: '@typescript-eslint/parser',
14 | settings: {
15 | 'import/parsers': {
16 | '@typescript-eslint/parser': ['.ts', '.tsx'],
17 | },
18 | 'import/resolver': {
19 | typescript: {
20 | alwaysTryTypes: true,
21 | project: ['tsconfig.json', 'figma-kit/tsconfig.json', 'website/tsconfig.json'],
22 | },
23 | },
24 | },
25 | overrides: [
26 | {
27 | files: ['*.stories.tsx'],
28 | rules: {
29 | 'import/no-default-export': 'off',
30 | 'import/exports-last': 'off',
31 | },
32 | },
33 | ],
34 | rules: {
35 | 'no-console': ['error', { allow: ['warn', 'error'] }],
36 |
37 | // Import plugin rules
38 | //
39 | // https://github.com/import-js/eslint-plugin-import
40 | // https://typescript-eslint.io/docs/linting/troubleshooting#eslint-plugin-import
41 |
42 | //'import/no-cycle': 'error', // Extremely useful, yet extremely slow to keep enabled. Worth occasionally enabling locally for finding & removing circular imports.
43 | 'import/no-default-export': 'error',
44 | 'import/no-unresolved': 'error',
45 | 'import/no-self-import': 'error',
46 | 'import/no-relative-packages': 'error',
47 | 'import/no-duplicates': 'error',
48 | 'import/no-mutable-exports': 'error',
49 | 'import/exports-last': 'error',
50 |
51 | 'import/order': [
52 | 'error',
53 | {
54 | groups: ['external', 'internal', 'parent', 'sibling', 'index'],
55 | },
56 | ],
57 |
58 | // TypeScript overrides
59 | '@typescript-eslint/consistent-type-imports': 'error', // Avoid type-only imports being incorrectly bundled: https://vitejs.dev/guide/features.html#typescript
60 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: 'props|context|event' }],
61 | },
62 | };
63 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: pr
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | checks:
8 | name: Checks
9 | runs-on: ubuntu-latest
10 |
11 | env:
12 | CI: true
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: '20.x'
20 |
21 | - name: Setup pnpm
22 | uses: pnpm/action-setup@v3.0.0
23 | with:
24 | run_install: false
25 |
26 | - name: Get pnpm store directory
27 | shell: bash
28 | run: |
29 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
30 |
31 | - uses: actions/cache@v4
32 | name: Setup pnpm cache
33 | with:
34 | path: ${{ env.STORE_PATH }}
35 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
36 | restore-keys: |
37 | ${{ runner.os }}-pnpm-store-
38 |
39 | - name: Install dependencies
40 | run: pnpm install
41 |
42 | - name: Lint
43 | run: pnpm lint
44 |
45 | - name: Test
46 | run: pnpm test
47 |
48 | - name: Build
49 | run: pnpm build
50 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 | on:
3 | push:
4 | branches:
5 | - beta
6 |
7 | jobs:
8 | publish:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Use Node.js
14 | uses: actions/setup-node@v4
15 | with:
16 | node-version: '20.x'
17 |
18 | - name: Setup pnpm
19 | uses: pnpm/action-setup@v3.0.0
20 | with:
21 | run_install: false
22 |
23 | - name: Get pnpm store directory
24 | shell: bash
25 | run: |
26 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
27 |
28 | - uses: actions/cache@v4
29 | name: Setup pnpm cache
30 | with:
31 | path: ${{ env.STORE_PATH }}
32 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
33 | restore-keys: |
34 | ${{ runner.os }}-pnpm-store-
35 |
36 | - name: Install dependencies
37 | run: pnpm install
38 |
39 | - name: Lint
40 | run: pnpm lint
41 |
42 | - name: Test
43 | run: pnpm test
44 |
45 | - name: Build
46 | run: pnpm build
47 |
48 | - name: Readme
49 | run: cp README.md ./figma-kit
50 |
51 | - name: Release
52 | working-directory: ./figma-kit
53 | env:
54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
56 | run: |
57 | npx semantic-release
58 |
59 | - name: Clean up
60 | run: rm ./figma-kit/README.md
61 |
62 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Build process CSS output
27 | figma-kit/*.css
28 |
29 | # Generated
30 | tsconfig.tsbuildinfo
31 | .eslintcache
32 |
33 | *storybook.log
34 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | npx --no -- commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | node_modules/.bin/lint-staged
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "arrowParens": "always",
7 | "singleQuote": true,
8 | "trailingComma": "es5"
9 | }
10 |
--------------------------------------------------------------------------------
/.storybook/StoryLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, Link, Text, Tooltip } from '../figma-kit/src';
3 | import { StoryContext } from '@storybook/react';
4 |
5 | const StoryLayout = (props: { children: React.ReactNode; context: StoryContext }) => {
6 | const { children, context } = props;
7 | const shouldRenderRadixNotice = context.parameters.radixUrl && context.parameters.radixComponentName;
8 |
9 | return (
10 |
11 | {shouldRenderRadixNotice && (
12 |
13 | )}
14 |
{children}
15 |
16 | );
17 | };
18 |
19 | const RadixNotice = ({ name, url }: { name: string; url: string }) => {
20 | return (
21 |
22 |
23 |
24 | Based on{' '}
25 |
26 | Radix {name}
27 |
28 |
29 |
30 |
35 | This component is built using the {name} primitive from Radix UI.
36 | Familiarity with Radix Primitives is recommended
37 |
38 | }
39 | >
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | const LinkIcon = () => (
50 |
51 |
57 |
58 | );
59 |
60 | const InfoIcon = () => (
61 |
62 |
66 |
67 | );
68 |
69 | export { StoryLayout };
70 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/react-vite';
2 | import tsconfigPaths from 'vite-tsconfig-paths';
3 |
4 | const config: StorybookConfig = {
5 | stories: ['../docs/**/*.mdx', '../figma-kit/src/**/*.mdx', '../figma-kit/src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
6 | docs: {
7 | defaultName: 'Documentation',
8 | },
9 | addons: [
10 | '@storybook/addon-links',
11 | '@storybook/addon-essentials',
12 | {
13 | name: '@storybook/addon-storysource',
14 | options: {
15 | loaderOptions: {
16 | injectStoryParameters: false,
17 | },
18 | },
19 | },
20 | 'storybook-dark-mode',
21 | ],
22 | framework: {
23 | name: '@storybook/react-vite',
24 | options: {},
25 | },
26 | async viteFinal(config) {
27 | const { mergeConfig } = await import('vite');
28 |
29 | return mergeConfig(config, {
30 | plugins: [tsconfigPaths()],
31 | });
32 | },
33 | };
34 |
35 | export default config;
36 |
--------------------------------------------------------------------------------
/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.storybook/preview.tsx:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react';
2 | import './storybook-reset.css';
3 | import '../figma-kit/src/styles/figma-development-theme.css';
4 | import '../figma-kit/src/styles/index.css';
5 | import { TooltipProvider } from '../figma-kit/src/components/tooltip';
6 | import React from 'react';
7 | import { StoryLayout } from './StoryLayout';
8 |
9 | const preview: Preview = {
10 | argTypes: {
11 | children: {
12 | table: {
13 | disable: true,
14 | },
15 | },
16 | },
17 | decorators: [
18 | (Story, storyContext) => (
19 |
20 |
21 |
22 |
23 |
24 | ),
25 | ],
26 | parameters: {
27 | layout: 'fullscreen',
28 | actions: {
29 | disable: true,
30 | },
31 | backgrounds: {
32 | grid: {
33 | cellSize: 8,
34 | opacity: 0.25,
35 | cellAmount: 4,
36 | offsetX: 16, // Default is 0 if story has 'fullscreen' layout, 16 if layout is 'padded'
37 | offsetY: 16, // Default is 0 if story has 'fullscreen' layout, 16 if layout is 'padded'
38 | },
39 | },
40 | options: {
41 | storySort: {
42 | order: [
43 | 'Introduction',
44 | 'Getting Started',
45 | 'Styling',
46 | 'Components',
47 | [
48 | 'Text',
49 | 'Flex',
50 | 'Button',
51 | 'Icon Button',
52 | 'Switch',
53 | 'Input',
54 | 'Textarea',
55 | 'Value Field',
56 | 'Checkbox',
57 | 'Radio Group',
58 | 'Segmented Control',
59 | 'Slider',
60 | 'Popover',
61 | 'Dialog',
62 | 'Alert Dialog',
63 | 'Select',
64 | 'Dropdown Menu',
65 | 'Context Menu',
66 | ],
67 | ],
68 | },
69 | },
70 | controls: {
71 | matchers: {
72 | color: /(background|color)$/i,
73 | date: /Date$/i,
74 | },
75 | },
76 | darkMode: {
77 | darkClass: 'figma-dark',
78 | lightClass: 'light',
79 | classTarget: 'html',
80 | stylePreview: true,
81 | },
82 | },
83 | };
84 |
85 | export default preview;
86 |
--------------------------------------------------------------------------------
/.storybook/storybook-reset.css:
--------------------------------------------------------------------------------
1 | html {
2 | height: 100%;
3 | }
4 |
5 | html.figma-dark {
6 | background-color: #2c2c2c;
7 | }
8 |
9 | body {
10 | margin: 0;
11 | }
12 |
13 | .story-layout {
14 | position: relative;
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | height: 100vh;
19 | overflow-y: auto;
20 | }
21 |
22 | .sbdocs .story-layout {
23 | height: 350px;
24 | }
25 |
26 | .radix-notice {
27 | position: absolute;
28 | top: 16px;
29 | right: 16px;
30 | display: flex;
31 | align-items: center;
32 | }
33 |
34 | .radix-link {
35 | display: inline-flex;
36 | gap: 2px;
37 | align-items: center;
38 | }
39 |
40 | .beta-notice {
41 | border: 1px solid #fab815;
42 | background-color: rgba(255, 241, 194, 0.2);
43 | padding: 12px 16px;
44 | border-radius: 5px;
45 | margin: -24px auto 40px !important;
46 | max-width: 720px;
47 |
48 | p {
49 | margin: 0;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Tigran Petrossian
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!NOTE]
2 | > Figma Kit is currently in beta. There may be breaking changes before the final release.
3 |
4 |
14 |
15 | ### Overview
16 |
17 | Figma Kit is an extensive collection of React components that attempts to replicate Figma's user interface look & feel with near-100% feature parity.
18 |
19 | #### Features
20 |
21 | * UI3 Ready
22 | * Automatic dark mode
23 | * First-class Tailwind support with a preset
24 |
25 | ### Documentation
26 |
27 | The documentation for the library is on its way. Meanwhile, feel free to check out the [Storybook](https://storybook.figma-kit.dev) for basic guides, information about available components and functionality.
28 |
--------------------------------------------------------------------------------
/commitlint.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | extends: ['@commitlint/config-conventional'],
3 | };
4 |
5 |
--------------------------------------------------------------------------------
/docs/getting-started.mdx:
--------------------------------------------------------------------------------
1 | import { Meta, CodeOrSourceMdx, Source } from '@storybook/blocks';
2 |
3 |
4 |
5 | ## Quick start
6 |
7 |
8 |
9 | ### Install Figma Kit
10 |
11 | ```
12 | npm install figma-kit@beta
13 | ```
14 |
15 | ### Enable access to Figma's semantic tokens in your plugin UI setup:
16 |
17 | Figma injects a set of [semantic color tokens as CSS custom properties](https://www.figma.com/plugin-docs/css-variables/) into plugin UI. Figma Kit
18 | relies on those to display the components correctly. Enable this by initializing your plugin UI
19 | as shown below:
20 |
21 |
22 |
23 | ### Import Figma Kit styles into your CSS
24 |
25 |
26 |
27 | Alternatively, if your plugin does not use CSS files, you can also import `styles.css` directly
28 | into your root JavaScript/TypeScript file:
29 |
30 |
31 |
32 | Importing CSS files into JS/TS might require additional setup, but it is typically already
33 | supported by build tools like Vite.
34 |
35 | ### Accessing Figma's semantic tokens outside of plugin environment
36 |
37 | When using the library outside of Figma plugin UI, e.g. in a normal browser, you'll still need
38 | access to semantic tokens for components to display correctly. Just for that purpose, Figma kit
39 | exports an optional theme file:
40 |
41 |
42 |
43 | This file is meant to be used for development purposes only, and **should not be included** in
44 | actual plugin code.
45 |
46 | ### Importing and using components
47 |
48 | {
53 | return Hello Figma
54 | }
55 | `}
56 | />
57 |
--------------------------------------------------------------------------------
/docs/introduction.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/blocks';
2 | import { Flex } from '../figma-kit/src';
3 |
4 |
5 |
6 |
7 | Note: Figma Kit is currently in beta.
8 |
9 | The documentation website is under development, and there may be breaking changes before the final release.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ## Overview
22 |
23 | Figma Kit is an extensive set of React components for building Figma plugins that look and
24 | behave like Figma's interface.
25 |
26 | ### Features
27 |
28 | - UI3 Ready: Components are designed to match Figma's new interface.
29 | - Automatic dark mode: Dark mode is supported out of the box without any setup or additional code,
30 | thanks to Figma's semantic color tokens.
31 | - Incremental Adoption: Components can be gradually integrated into existing plugins.
32 |
--------------------------------------------------------------------------------
/docs/styling.mdx:
--------------------------------------------------------------------------------
1 | import { Meta, Source } from '@storybook/blocks';
2 |
3 |
4 |
5 | ## Styling
6 |
7 | Figma Kit is designed to closely resemble Figma's interface, so minimal styling is needed,
8 | mainly for layout adjustments. All Figma Kit components accept the className prop and can be styled
9 | using any preferred styling solution.
10 | Figma injects an extensive set of [semantic tokens](https://www.figma.com/plugin-docs/css-variables/),
11 | which this library relies on, so familiarity with these tokens is recommended.
12 |
13 |
14 | ## Using with Tailwind
15 |
16 | Figma kit ships with a Tailwind preset that makes Figma theme tokens available in Tailwind classes:
17 |
18 |
19 |
20 | ```tsx
21 | import figmaKitPreset from 'figma-kit/tailwind.preset.js';
22 |
23 | /** @type {import('tailwindcss').Config} */
24 | export default {
25 | // ...
26 | presets: [figmaKitPreset],
27 | };
28 | ```
29 |
30 |
31 | This replaces the default Tailwind theme with Figma tokens, allowing you to use these tokens directly:
32 |
33 |
34 | ```tsx
35 | import { Text } from 'figma-kit'
36 |
37 | const App = () => {
38 | return (
39 |
40 | Welcome to Figma
41 |
42 | )
43 | }
44 | ```
45 |
46 | The preset is built to match Figma's [semantic token format](https://www.figma.com/plugin-docs/css-variables/#tokens-overview),
47 | which differs slightly from typical Tailwind themes. Here, properties are
48 | strictly mapped to their respective tokens:
49 |
50 | ```tsx
51 |
52 |
53 |
54 |
55 |
56 | ```
57 |
58 | Translates to:
59 | ```css
60 | .text {
61 | color: var(--figma-color-text);
62 | }
63 | .text-secondary {
64 | color: var(--figma-color-text-secondary);
65 | }
66 | .bg {
67 | background-color: var(--figma-color-bg);
68 | }
69 | .bg-secondary {
70 | background-color: var(--figma-color-bg-secondary);
71 | }
72 | ```
73 |
74 | ### Breaking out of strict property mapping
75 |
76 | In rare cases it might be necessary to reference a token for a different property. In rare cases,
77 | you may need to reference a token for a different property. This can be done using Tailwind's arbitrary value syntax:
78 |
79 | ```tsx
80 |
81 | ```
82 |
83 | Note: This is an escape hatch intended for edge cases. Figma's color system is well-designed and
84 | rarely requires referencing unrelated tokens.
85 |
--------------------------------------------------------------------------------
/figma-kit/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "figma-kit",
3 | "description": "A set of React components for building Figma plugins.",
4 | "homepage": "https://figma-kit.dev",
5 | "version": "0.0.0-semantic-release",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/tigranpetrossian/figma-kit"
10 | },
11 | "keywords": [
12 | "react",
13 | "figma",
14 | "figma react components",
15 | "figma design system",
16 | "UI3",
17 | "component-library"
18 | ],
19 | "type": "module",
20 | "files": [
21 | "dist",
22 | "*.css"
23 | ],
24 | "exports": {
25 | "./styles.css": "./dist/styles.css",
26 | "./figma-development-theme.css": "./dist/figma-development-theme.css",
27 | ".": {
28 | "require": "./dist/index.cjs",
29 | "import": "./dist/index.mjs",
30 | "types": "./dist/index.d.ts"
31 | },
32 | "./tailwind.preset.js": {
33 | "require": "./dist/tailwind.preset.js"
34 | },
35 | "./*": "./*"
36 | },
37 | "main": "dist/index.cjs",
38 | "module": "dist/index.mjs",
39 | "typings": "dist/index.d.ts",
40 | "sideEffects": false,
41 | "scripts": {
42 | "dev": "vite build --mode development --watch",
43 | "build": "pnpm build:js && pnpm build:css && pnpm build:figma-dev-theme-css && pnpm build:tailwind-preset",
44 | "build:js": "vite build",
45 | "build:css": "postcss src/styles/index.css -o dist/styles.css",
46 | "build:figma-dev-theme-css": "postcss src/styles/figma-development-theme.css -o dist/figma-development-theme.css",
47 | "build:tailwind-preset": "cp src/tailwind/preset.js dist/tailwind.preset.js",
48 | "test": "vitest"
49 | },
50 | "peerDependencies": {
51 | "react": "^18.2.0",
52 | "react-dom": "^18.2.0"
53 | },
54 | "dependencies": {
55 | "@emmetio/math-expression": "^1.0.5",
56 | "@radix-ui/react-alert-dialog": "^1.1.1",
57 | "@radix-ui/react-collapsible": "^1.1.0",
58 | "@radix-ui/react-context-menu": "^2.1.5",
59 | "@radix-ui/react-dialog": "^1.1.1",
60 | "@radix-ui/react-dropdown-menu": "^2.0.6",
61 | "@radix-ui/react-popover": "^1.1.1",
62 | "@radix-ui/react-radio-group": "^1.2.0",
63 | "@radix-ui/react-select": "^2.0.0",
64 | "@radix-ui/react-slider": "1.2.0",
65 | "@radix-ui/react-slot": "^1.1.0",
66 | "@radix-ui/react-switch": "^1.0.3",
67 | "@radix-ui/react-tabs": "^1.1.0",
68 | "@radix-ui/react-toggle-group": "^1.1.0",
69 | "@radix-ui/react-tooltip": "^1.0.7",
70 | "class-variance-authority": "^0.7.0",
71 | "merge-props": "^6.0.0",
72 | "react-textarea-autosize": "^8.5.3",
73 | "remeda": "^2.0.5"
74 | },
75 | "devDependencies": {
76 | "@types/react": "^18.2.66",
77 | "@types/react-dom": "^18.2.22"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/figma-kit/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-import'),
4 | require('postcss-nesting'),
5 | ],
6 | }
7 |
--------------------------------------------------------------------------------
/figma-kit/release.config.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('semantic-release').GlobalConfig}
3 | */
4 | module.exports = {
5 | plugins: [
6 | [
7 | '@semantic-release/commit-analyzer',
8 | {
9 | preset: 'conventionalcommits',
10 | releaseRules: [
11 | { type: 'feat', release: 'minor' },
12 | { type: 'fix', release: 'patch' },
13 | { type: 'perf', release: 'patch' },
14 | { type: 'docs', scope: 'README', release: 'patch' },
15 | ],
16 | },
17 | ],
18 | '@semantic-release/release-notes-generator',
19 | '@semantic-release/npm',
20 | '@semantic-release/github',
21 | ],
22 | branches: ['main', { name: 'beta', prerelease: true }],
23 | };
24 |
25 |
--------------------------------------------------------------------------------
/figma-kit/src/components/alert-dialog/alert-dialog.css:
--------------------------------------------------------------------------------
1 | .fp-AlertDialogContent {
2 | position: fixed;
3 | top: 50%;
4 | left: 50%;
5 | transform: translate(-50%, -50%);
6 | max-width: calc(100vw - 32px);
7 | max-height: 80%;
8 | display: flex;
9 | flex-direction: column;
10 | gap: var(--space-4);
11 | padding: var(--space-4);
12 |
13 | &:where(.fp-placement-center) {
14 | top: 50%;
15 | left: 50%;
16 | transform: translate(-50%, -50%);
17 | }
18 |
19 | &:where(.fp-placement-top) {
20 | top: min(10vh, 80px);
21 | left: 50%;
22 | transform: translateX(-50%);
23 | }
24 |
25 | &:where(.fp-size-1) {
26 | width: 288px;
27 | }
28 |
29 | &:where(.fp-size-2) {
30 | width: 352px;
31 | }
32 |
33 | &:where(.fp-size-3) {
34 | width: 448px;
35 | }
36 | }
37 |
38 | .fp-AlertDialogTitle {
39 | margin: 0;
40 | }
41 |
42 | .fp-AlertDialogActions {
43 | display: flex;
44 | justify-content: flex-end;
45 | align-items: center;
46 | gap: var(--space-2);
47 | padding-top: var(--space-2);
48 | }
49 |
--------------------------------------------------------------------------------
/figma-kit/src/components/alert-dialog/alert-dialog.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Button } from '@components/button';
3 | import * as AlertDialog from './alert-dialog';
4 |
5 | type Story = StoryObj;
6 |
7 | const meta: Meta = {
8 | title: 'Components/Alert Dialog',
9 | component: AlertDialog.Root,
10 | parameters: {
11 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/alert-dialog',
12 | radixComponentName: 'Alert Dialog',
13 | },
14 | };
15 |
16 | export default meta;
17 |
18 | export const Story: Story = {
19 | args: {},
20 | render: () => {
21 | return (
22 |
23 |
24 | Delete File
25 |
26 |
27 |
28 | Dialog with primary destructive action
29 |
30 | Moving the file out of Team Foo means some people might lose access to it.
31 |
32 |
33 |
34 | Cancel
35 |
36 |
37 | Delete File
38 |
39 |
40 |
41 |
42 | );
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/figma-kit/src/components/alert-dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from './alert-dialog';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/button/button.css:
--------------------------------------------------------------------------------
1 | .fp-Button {
2 | box-sizing: border-box;
3 | background-clip: border-box;
4 | background-color: transparent;
5 | user-select: none;
6 | appearance: none;
7 | border: 0;
8 | cursor: default;
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | flex-shrink: 0;
13 | width: fit-content;
14 | outline-width: 1px;
15 | outline-offset: -1px;
16 | outline-style: solid;
17 | outline-color: transparent;
18 | font-family: var(--font-family-default);
19 | font-size: var(--font-size-default);
20 | font-weight: var(--font-weight-default);
21 | letter-spacing: var(--letter-spacing-default);
22 | line-height: var(--line-height-default);
23 | border-radius: var(--radius-medium);
24 | }
25 |
26 | .fp-Button:where(.fp-variant-secondary) {
27 | --button-color-bg: transparent;
28 | --button-color-border: var(--figma-color-text);
29 | --button-color-text: var(--figma-color-border);
30 | }
31 |
32 | .fp-Button:where(.fp-variant-secondary) {
33 | color: var(--figma-color-text);
34 | outline-color: var(--figma-color-border);
35 |
36 | &:active {
37 | background-color: var(--figma-color-bg-pressed);
38 | }
39 |
40 | &:focus-visible {
41 | outline-color: var(--figma-color-border-selected);
42 | }
43 |
44 | &:disabled {
45 | color: var(--figma-color-text-disabled);
46 | outline-color: var(--figma-color-border-disabled);
47 | }
48 | }
49 |
50 | .fp-Button:where(.fp-variant-primary) {
51 | background-color: var(--figma-color-bg-brand);
52 | color: var(--figma-color-text-onbrand);
53 |
54 | &:active {
55 | background-color: var(--figma-color-bg-brand-pressed);
56 | }
57 |
58 | &:focus-visible {
59 | outline-color: var(--figma-color-border-onbrand-strong);
60 | box-shadow: 0 0 0 1px var(--figma-color-border-selected-strong);
61 | }
62 |
63 | &:disabled {
64 | color: var(--figma-color-text-ondisabled);
65 | background-color: var(--figma-color-bg-disabled);
66 | }
67 | }
68 | .fp-Button:where(.fp-variant-destructive) {
69 | background-color: var(--figma-color-bg-danger);
70 | color: var(--figma-color-text-ondanger);
71 |
72 | &:active {
73 | background-color: var(--figma-color-bg-danger-pressed);
74 | }
75 |
76 | &:focus-visible {
77 | outline-color: var(--figma-color-border-ondanger-strong);
78 | box-shadow: 0 0 0 1px var(--figma-color-border-danger-strong);
79 | }
80 |
81 | &:disabled {
82 | color: var(--figma-color-text-ondisabled);
83 | background-color: var(--figma-color-bg-disabled);
84 | }
85 | }
86 |
87 | .fp-Button:where(.fp-variant-success) {
88 | background-color: var(--figma-color-bg-success);
89 | color: var(--figma-color-text-onsuccess);
90 |
91 | &:active {
92 | background-color: var(--figma-color-bg-success-pressed);
93 | }
94 |
95 | &:focus-visible {
96 | outline-color: var(--figma-color-border-onbrand-strong);
97 | box-shadow: 0 0 0 1px var(--figma-color-border-success-strong);
98 | }
99 |
100 | &:disabled {
101 | color: var(--figma-color-text-ondisabled);
102 | background-color: var(--figma-color-bg-disabled);
103 | }
104 | }
105 |
106 | .fp-Button:where(.fp-variant-inverse) {
107 | background-color: var(--figma-color-bg-inverse);
108 | color: var(--figma-color-text-oninverse);
109 | font-weight: 400;
110 |
111 | &:active {
112 | background-color: var(--figma-color-bg-brand-pressed);
113 | }
114 |
115 | &:focus-visible {
116 | outline-color: var(--figma-color-border-onbrand-strong);
117 | box-shadow: 0 0 0 1px var(--figma-color-border-selected);
118 | }
119 |
120 | &:disabled {
121 | color: var(--figma-color-text-ondisabled);
122 | background-color: var(--figma-color-bg-disabled);
123 | }
124 | }
125 |
126 | .fp-Button:where(.fp-variant-text) {
127 | background-color: transparent;
128 | outline-color: transparent;
129 | color: var(--figma-color-text);
130 |
131 | &:hover {
132 | background-color: var(--figma-color-bg-hover);
133 | }
134 |
135 | &:active {
136 | background-color: var(--figma-color-bg-pressed);
137 | }
138 |
139 | &:focus-visible {
140 | outline-color: var(--figma-color-border-selected);
141 | }
142 |
143 | &:disabled {
144 | color: var(--figma-color-text-disabled);
145 | outline-color: var(--figma-color-border-disabled);
146 | }
147 | }
148 |
149 | .fp-Button:where(.fp-full-width) {
150 | width: 100%;
151 | max-width: 100%;
152 | }
153 |
154 | .fp-Button:where(.fp-size-small) {
155 | height: var(--space-6);
156 | padding: var(--space-1) var(--space-2);
157 | }
158 |
159 | .fp-Button:where(.fp-size-medium) {
160 | height: var(--space-8);
161 | padding: var(--space-2) var(--space-3);
162 | }
163 |
--------------------------------------------------------------------------------
/figma-kit/src/components/button/button.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import reactElementToJSXString from 'react-element-to-jsx-string';
3 | import { Button } from './button';
4 |
5 | type Story = StoryObj;
6 |
7 | const meta = {
8 | component: Button,
9 | title: 'Components/Button',
10 | args: {
11 | variant: undefined,
12 | },
13 | parameters: {
14 | controls: {
15 | expanded: true,
16 | },
17 | },
18 | argTypes: {
19 | variant: {
20 | description: 'Contextual variant of the button.',
21 | type: 'string',
22 | table: {
23 | type: {
24 | summary: 'enum',
25 | },
26 | defaultValue: {
27 | summary: 'secondary',
28 | },
29 | },
30 | options: ['primary', 'secondary', 'inverse', 'destructive', 'success', 'text'],
31 | control: { type: 'radio' },
32 | },
33 | size: {
34 | description: 'Size of the button.',
35 | type: 'string',
36 | table: {
37 | type: {
38 | summary: 'enum',
39 | },
40 | defaultValue: {
41 | summary: 'small',
42 | },
43 | },
44 | options: ['small', 'medium'],
45 | control: { type: 'radio' },
46 | },
47 | fullWidth: {
48 | description: 'Set to `true` for the button to fill its parent container.',
49 | type: 'boolean',
50 | },
51 | disabled: {
52 | description: 'Set to `true` to disable the button.',
53 | type: 'boolean',
54 | },
55 | },
56 | } satisfies Meta;
57 |
58 | export default meta;
59 |
60 | // eslint-disable-next-line
61 | const getCode = (args: any) => reactElementToJSXString( );
62 |
63 | export const Primary: Story = {
64 | args: {
65 | variant: 'primary',
66 | children: 'Primary',
67 | },
68 | parameters: {
69 | storySource: {
70 | get source() {
71 | return getCode(Primary.args);
72 | },
73 | },
74 | },
75 | };
76 |
77 | export const Secondary: Story = {
78 | args: {
79 | variant: 'secondary',
80 | children: 'Secondary',
81 | },
82 | parameters: {
83 | storySource: {
84 | get source() {
85 | return getCode(Secondary.args);
86 | },
87 | },
88 | },
89 | };
90 |
91 | export const Destructive: Story = {
92 | args: {
93 | variant: 'destructive',
94 | children: 'Destructive',
95 | },
96 | parameters: {
97 | storySource: {
98 | get source() {
99 | return getCode(Destructive.args);
100 | },
101 | },
102 | },
103 | };
104 |
105 | export const Success: Story = {
106 | args: {
107 | variant: 'success',
108 | children: 'Success',
109 | },
110 | parameters: {
111 | storySource: {
112 | get source() {
113 | return getCode(Success.args);
114 | },
115 | },
116 | },
117 | };
118 |
119 | export const Inverse: Story = {
120 | args: {
121 | variant: 'inverse',
122 | children: 'Inverse',
123 | },
124 | parameters: {
125 | storySource: {
126 | get source() {
127 | return getCode(Inverse.args);
128 | },
129 | },
130 | },
131 | };
132 |
133 | export const Text: Story = {
134 | args: {
135 | variant: 'text',
136 | children: 'Text',
137 | },
138 | parameters: {
139 | storySource: {
140 | get source() {
141 | return getCode(Text.args);
142 | },
143 | },
144 | },
145 | };
146 |
--------------------------------------------------------------------------------
/figma-kit/src/components/button/button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { VariantProps } from 'class-variance-authority';
3 | import { cva } from 'class-variance-authority';
4 |
5 | const button = cva('fp-Button', {
6 | variants: {
7 | variant: {
8 | primary: 'fp-variant-primary',
9 | secondary: 'fp-variant-secondary',
10 | inverse: 'fp-variant-inverse',
11 | destructive: 'fp-variant-destructive',
12 | success: 'fp-variant-success',
13 | text: 'fp-variant-text',
14 | },
15 | size: {
16 | small: 'fp-size-small',
17 | medium: 'fp-size-medium',
18 | },
19 | fullWidth: {
20 | true: 'fp-full-width',
21 | },
22 | },
23 |
24 | defaultVariants: {
25 | variant: 'secondary',
26 | size: 'small',
27 | },
28 | });
29 |
30 | type ButtonElement = React.ElementRef<'button'>;
31 | type ButtonProps = React.ComponentPropsWithoutRef<'button'> & VariantProps;
32 |
33 | const Button = React.forwardRef((props, ref) => {
34 | const { className, variant, size, fullWidth, ...buttonProps } = props;
35 |
36 | return ;
37 | });
38 |
39 | Button.displayName = 'Button';
40 |
41 | export type { ButtonProps };
42 | export { Button };
43 |
--------------------------------------------------------------------------------
/figma-kit/src/components/button/index.ts:
--------------------------------------------------------------------------------
1 | export * from './button';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/checkbox/checkbox.css:
--------------------------------------------------------------------------------
1 | .fp-CheckboxRoot {
2 | position: relative;
3 | display: grid;
4 | grid-template-columns: var(--space-4) auto;
5 | min-height: 24px;
6 | gap: var(--space-1) var(--space-2);
7 | }
8 |
9 | .fp-CheckboxInput {
10 | all: unset;
11 | box-sizing: border-box;
12 | display: block;
13 | width: var(--space-4);
14 | height: var(--space-4);
15 | margin: var(--space-1) 0;
16 | background-color: transparent;
17 | border: 1px solid var(--figma-color-border-strong);
18 | border-radius: var(--radius-medium);
19 | flex-shrink: 0;
20 |
21 | &:focus-visible {
22 | outline-offset: -1px;
23 | outline: 1px solid var(--figma-color-border-selected);
24 | }
25 |
26 | &:focus-visible:checked {
27 | outline-offset: 0;
28 | outline: 1px solid var(--figma-color-border-selected-strong);
29 | border-color: var(--figma-color-icon-onbrand);
30 | }
31 |
32 | &:checked {
33 | background-color: var(--figma-color-bg-brand);
34 | border-color: transparent;
35 | }
36 |
37 | &:disabled {
38 | border-color: var(--figma-color-border-disabled-strong);
39 | }
40 |
41 | &:disabled:checked {
42 | border-color: transparent;
43 | background-color: var(--figma-color-border-disabled-strong);
44 | }
45 | }
46 |
47 | .fp-CheckboxIndicator {
48 | display: block;
49 | pointer-events: none;
50 | position: absolute;
51 | top: var(--space-1);
52 | }
53 |
54 | .fp-CheckboxCheckmark,
55 | .fp-CheckboxIndeterminate {
56 | display: none;
57 | }
58 |
59 | .fp-CheckboxInput:checked ~ .fp-CheckboxIndicator .fp-CheckboxCheckmark {
60 | --color-icon: var(--figma-color-icon-onbrand);
61 | display: block;
62 | }
63 |
64 | .fp-CheckboxInput:indeterminate ~ .fp-CheckboxIndicator .fp-CheckboxIndeterminate {
65 | --color-icon: var(--figma-color-icon);
66 | display: block;
67 | }
68 |
69 | .fp-CheckboxInput:disabled:indeterminate ~ .fp-CheckboxIndicator .fp-CheckboxIndeterminate {
70 | --color-icon: var(--figma-color-icon-disabled);
71 | }
72 |
73 | .fp-CheckboxLabel {
74 | margin-top: var(--space-1);
75 | }
76 |
77 | .fp-CheckboxInput:disabled ~ .fp-CheckboxLabel {
78 | color: var(--figma-color-text-disabled);
79 | }
80 |
81 | .fp-CheckboxDescription {
82 | color: var(--figma-color-text-secondary);
83 | grid-area: 2 / 2;
84 | }
85 |
--------------------------------------------------------------------------------
/figma-kit/src/components/checkbox/checkbox.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import * as Checkbox from './checkbox';
3 |
4 | type Story = StoryObj;
5 |
6 | const meta: Meta = {
7 | title: 'Components/Checkbox',
8 | component: Checkbox.Root,
9 | };
10 |
11 | export default meta;
12 |
13 | export const WithLabel: Story = {
14 | render: () => {
15 | return (
16 |
17 |
18 | Clip content
19 |
20 | );
21 | },
22 | };
23 |
24 | export const WithoutLabel: Story = {
25 | render: () => {
26 | return (
27 |
28 |
29 |
30 | );
31 | },
32 | };
33 |
34 | export const Indeterminate: Story = {
35 | render: () => {
36 | return (
37 |
38 |
39 | Clip content
40 |
41 | );
42 | },
43 | };
44 |
45 | export const Disabled: Story = {
46 | render: () => {
47 | return (
48 |
49 |
50 | Clip content
51 |
52 | );
53 | },
54 | };
55 | export const DisabledChecked: Story = {
56 | render: () => {
57 | return (
58 |
59 |
60 | Clip content
61 |
62 | );
63 | },
64 | };
65 | export const DisabledIndeterminate: Story = {
66 | render: () => {
67 | return (
68 |
69 |
70 | Clip content
71 |
72 | );
73 | },
74 | };
75 |
76 | export const MultiLineLabel: Story = {
77 | render: () => {
78 | return (
79 |
80 |
81 | Clip content with label that spans multiple lines
82 |
83 | );
84 | },
85 | };
86 |
87 | export const Description: Story = {
88 | render: () => {
89 | return (
90 |
91 |
92 | Checkbox with description
93 |
94 | Helpful description of the option which may briefly highlight side effects or conditions of the option.
95 |
96 |
97 | );
98 | },
99 | };
100 |
--------------------------------------------------------------------------------
/figma-kit/src/components/checkbox/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useId } from 'react';
2 | import { cx } from 'class-variance-authority';
3 | import { Text, Label as LabelPrimitive } from '@components/text';
4 | import { useComposedRefs } from '@lib/react/use-compose-refs';
5 | import { CheckmarkIcon } from '@components/icons';
6 | import { CheckmarkIndeterminateIcon } from '@components/icons/checkmark-indeterminate';
7 | import { createContext } from '@lib/react/create-context';
8 |
9 | const [CheckboxContextProvider, useCheckboxContext] = createContext<{ id: string }>('Checkbox');
10 |
11 | type RootElement = React.ElementRef<'div'>;
12 | type RootProps = React.ComponentPropsWithoutRef<'div'>;
13 |
14 | const Root = React.forwardRef((props, ref) => {
15 | const { className, id: idProp, ...rootProps } = props;
16 | const generatedId = useId();
17 | const id = idProp ?? generatedId;
18 |
19 | return (
20 |
21 |
22 |
23 | );
24 | });
25 |
26 | type CheckboxElement = React.ElementRef<'input'>;
27 | type CheckboxProps = React.ComponentPropsWithoutRef<'input'> & {
28 | indeterminate?: boolean;
29 | };
30 |
31 | const Input = React.forwardRef((props, forwardedRef) => {
32 | const { className, indeterminate, ...checkboxProps } = props;
33 | const { id } = useCheckboxContext('Input');
34 | const inputRef = useIndeterminateState(indeterminate);
35 | const ref = useComposedRefs(forwardedRef, inputRef);
36 |
37 | return (
38 | <>
39 |
49 |
50 | >
51 | );
52 | });
53 |
54 | const Indicator = () => {
55 | return (
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | type LabelElement = React.ElementRef<'label'>;
64 | type LabelProps = React.ComponentPropsWithoutRef<'label'>;
65 |
66 | const Label = React.forwardRef((props, ref) => {
67 | const { className, ...labelProps } = props;
68 | const { id } = useCheckboxContext('Input');
69 | return (
70 |
78 | );
79 | });
80 |
81 | type DescriptionElement = React.ElementRef<'label'>;
82 | type DescriptionProps = React.ComponentPropsWithoutRef<'label'>;
83 |
84 | const Description = React.forwardRef((props, ref) => {
85 | const { className, ...desriptionProps } = props;
86 | const { id } = useCheckboxContext('Description');
87 | return (
88 |
95 | );
96 | });
97 |
98 | function useIndeterminateState(indeterminate: boolean | undefined) {
99 | return useCallback(
100 | (inputElement: HTMLInputElement) => {
101 | if (!inputElement) {
102 | return;
103 | }
104 |
105 | inputElement.indeterminate = !!indeterminate;
106 | },
107 | [indeterminate]
108 | );
109 | }
110 |
111 | Root.displayName = 'Checkbox.Root';
112 | Input.displayName = 'Checkbox.Input';
113 | Indicator.displayName = 'Checkbox.Indicator';
114 | Label.displayName = 'Checkbox.Label';
115 | Description.displayName = 'Checkbox.Description';
116 |
117 | export type { CheckboxProps };
118 | export { Root, Input, Label, Description };
119 |
--------------------------------------------------------------------------------
/figma-kit/src/components/checkbox/index.ts:
--------------------------------------------------------------------------------
1 | export * from './checkbox';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/collapsible/collapsible.css:
--------------------------------------------------------------------------------
1 | .fp-CollapsibleRoot {
2 | :where(.fp-CollapsibleContent &) {
3 | margin-left: calc(-1 * var(--space-1));
4 | }
5 | }
6 |
7 | .fp-CollapsibleTrigger {
8 | all: unset;
9 | display: flex;
10 | align-items: center;
11 | width: 100%;
12 | padding: var(--space-2) 0;
13 | font-family: var(--font-family-default);
14 | font-size: var(--font-size-default);
15 | font-weight: var(--font-weight-default);
16 | letter-spacing: var(--letter-spacing-default);
17 | line-height: var(--line-height-default);
18 | color: var(--figma-color-text-secondary);
19 | }
20 |
21 | .fp-CollapsibleIndicator {
22 | --color-icon: var(--figma-color-icon-secondary);
23 |
24 | [data-state='open'] > & {
25 | transform: rotate(90deg);
26 | }
27 | }
28 |
29 | .fp-CollapsibleContent {
30 | margin-left: var(--space-4);
31 | }
32 |
--------------------------------------------------------------------------------
/figma-kit/src/components/collapsible/collapsible.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Flex } from '@components/flex';
3 | import { Checkbox } from '../../index';
4 | import * as Collapsible from './collapsible';
5 |
6 | type Story = StoryObj;
7 |
8 | const meta: Meta = {
9 | title: 'Components/Collapsible',
10 | component: Collapsible.Root,
11 | parameters: {
12 | layout: 'padded',
13 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/collapsible',
14 | radixComponentName: 'Collapsible',
15 | },
16 | decorators: [
17 | (Story) => {
18 | return (
19 |
20 |
21 |
22 | );
23 | },
24 | ],
25 | };
26 |
27 | export default meta;
28 |
29 | export const Story: Story = {
30 | args: {},
31 | render: () => {
32 | return (
33 |
34 | State
35 |
36 |
37 |
38 |
39 | Reset scroll position
40 |
41 |
42 |
43 | Reset component state
44 |
45 |
46 |
47 | Reset video state
48 |
49 |
50 |
51 |
52 | );
53 | },
54 | };
55 |
56 | export const Nesting: Story = {
57 | args: {},
58 | render: () => {
59 | return (
60 |
61 | State
62 |
63 |
64 | State
65 |
66 |
67 | State
68 |
69 |
70 | State
71 |
72 |
73 |
74 |
75 | Reset scroll position
76 |
77 |
78 |
79 | Reset component state
80 |
81 |
82 |
83 | Reset video state
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | );
95 | },
96 | };
97 |
--------------------------------------------------------------------------------
/figma-kit/src/components/collapsible/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as RadixCollapsible from '@radix-ui/react-collapsible';
3 | import { cx } from 'class-variance-authority';
4 | import { ChevronRightIcon } from '@components/icons';
5 |
6 | type RootElement = React.ElementRef;
7 | type RootProps = RadixCollapsible.CollapsibleProps;
8 |
9 | const Root = React.forwardRef((props, ref) => {
10 | const { className, ...rootProps } = props;
11 |
12 | return ;
13 | });
14 |
15 | type TriggerElement = React.ElementRef;
16 | type TriggerProps = RadixCollapsible.CollapsibleTriggerProps;
17 |
18 | const Trigger = React.forwardRef((props, ref) => {
19 | const { className, children, ...triggerProps } = props;
20 |
21 | return (
22 |
23 |
24 | {children}
25 |
26 | );
27 | });
28 |
29 | type ContentElement = React.ElementRef;
30 | type ContentProps = RadixCollapsible.CollapsibleContentProps;
31 |
32 | const Content = React.forwardRef((props, ref) => {
33 | const { className, ...contentProps } = props;
34 |
35 | return ;
36 | });
37 |
38 | Root.displayName = 'Collapsible.Root';
39 | Trigger.displayName = 'Collapsible.Trigger';
40 | Content.displayName = 'Collapsible.Content';
41 |
42 | export type { RootProps, ContentProps, TriggerProps };
43 | export { Root, Content, Trigger };
44 |
--------------------------------------------------------------------------------
/figma-kit/src/components/collapsible/index.ts:
--------------------------------------------------------------------------------
1 | export * from './collapsible';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/color-picker/color-picker-alpha.tsx:
--------------------------------------------------------------------------------
1 | import { round } from 'remeda';
2 | import type { CSSProperties } from 'react';
3 | import { Slider } from '@components/slider';
4 | import { useColorPickerContext } from '@components/color-picker/color-picker';
5 | import { blendWithWhite, rgbaToCssString, rgbaToP3String } from '@lib/color';
6 |
7 | type AlphaProps = React.ComponentPropsWithoutRef<'div'>;
8 |
9 | const Alpha = (props: AlphaProps) => {
10 | const { className, style } = props;
11 | const { colorSpace, color, onColorChange } = useColorPickerContext('Hue');
12 | const trackBg = {
13 | srgb: {
14 | transparent: rgbaToCssString({ ...color, a: 0 }),
15 | opaque: rgbaToCssString({ ...color, a: 1 }),
16 | },
17 | p3: {
18 | transparent: rgbaToP3String({ ...color, a: 0 }),
19 | opaque: rgbaToP3String({ ...color, a: 1 }),
20 | },
21 | };
22 | const thumbBg = {
23 | srgb: rgbaToCssString(blendWithWhite(color)),
24 | p3: rgbaToP3String(blendWithWhite(color)),
25 | };
26 |
27 | const handleValueChange = (value: number[]) => {
28 | onColorChange({ mode: 'rgb', value: { ...color, a: round(value[0] / 100, 2) } });
29 | };
30 |
31 | return (
32 |
52 | );
53 | };
54 |
55 | export type { AlphaProps };
56 | export { Alpha };
57 |
--------------------------------------------------------------------------------
/figma-kit/src/components/color-picker/color-picker-hue.tsx:
--------------------------------------------------------------------------------
1 | import type { CSSProperties } from 'react';
2 | import { Slider } from '@components/slider';
3 | import type { ColorModel, ColorSpace } from '@components/color-picker/color-picker';
4 | import { useColorPickerContext } from '@components/color-picker/color-picker';
5 | import type { RGBA } from '@lib/color';
6 | import { hsvaToRgba, rgbaToHsva } from '@lib/color';
7 |
8 | type HueProps = React.ComponentPropsWithoutRef<'div'>;
9 |
10 | const Hue = (props: HueProps) => {
11 | const { className, style } = props;
12 | const { activeModel, colorSpace, hue, color, onColorChange } = useColorPickerContext('Hue');
13 | const strategy = colorModelStrategies[activeModel];
14 | const handleValueChange = (value: number[]) => {
15 | const newColor = strategy.setHue(color, value[0]);
16 | onColorChange({ mode: 'rgb', value: newColor });
17 | };
18 | const thumbColor = strategy.getThumbColor(hue, colorSpace);
19 |
20 | return (
21 |
31 | );
32 | };
33 |
34 | type ColorModelStrategy = {
35 | getHue: (color: RGBA) => number;
36 | setHue: (color: RGBA, hue: number) => RGBA;
37 | getThumbColor: (hue: number, colorSpace: ColorSpace) => string;
38 | };
39 |
40 | const standardModelStrategy: ColorModelStrategy = {
41 | getHue: (color) => Math.round(rgbaToHsva(color).h),
42 | setHue: (color, hue) => {
43 | const { s, v } = rgbaToHsva(color);
44 | return hsvaToRgba({ h: hue, s, v, a: color.a });
45 | },
46 | getThumbColor: (hue, colorSpace) => {
47 | const { r, g, b } = hsvaToRgba({ h: hue, s: 100, v: 100, a: 1 });
48 | return colorSpace === 'display-p3'
49 | ? `color(display-p3 ${+r.toFixed(4)} ${+g.toFixed(4)} ${+b.toFixed(4)})`
50 | : `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
51 | },
52 | };
53 |
54 | const colorModelStrategies: Record = {
55 | rgb: standardModelStrategy,
56 | hsl: standardModelStrategy,
57 | hsv: standardModelStrategy,
58 | hex: standardModelStrategy,
59 | };
60 |
61 | export type { HueProps };
62 | export { Hue };
63 |
--------------------------------------------------------------------------------
/figma-kit/src/components/color-picker/color-picker.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { useState } from 'react';
3 | import * as Select from '@components/select';
4 | import * as ColorPicker from '@components/color-picker';
5 | import type { ColorSpace } from '@components/color-picker';
6 |
7 | const Picker = () => {
8 | return
;
9 | };
10 |
11 | type Story = StoryObj;
12 |
13 | const meta: Meta = {
14 | title: 'Components/Color',
15 | component: Picker,
16 | parameters: {
17 | layout: 'fullscreen',
18 | },
19 | };
20 |
21 | export default meta;
22 |
23 | export const Story = () => {
24 | const [space, setSpace] = useState('srgb');
25 |
26 | return (
27 | <>
28 | setSpace(value)}>
29 |
30 |
31 | sRGB
32 | Display P3
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | >
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/figma-kit/src/components/color-picker/index.ts:
--------------------------------------------------------------------------------
1 | export * from './color-picker';
2 | export * from './color-picker-hue';
3 | export * from './color-picker-alpha';
4 | export * from './color-picker-area';
5 | export * from './color-picker-input';
6 |
--------------------------------------------------------------------------------
/figma-kit/src/components/context-menu/index.ts:
--------------------------------------------------------------------------------
1 | export * from './context-menu';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dialog.base/dialog.base.css:
--------------------------------------------------------------------------------
1 | .fp-DialogBaseContent {
2 | font-family: var(--font-family-default);
3 | color: var(--figma-color-text);
4 | background-color: var(--figma-color-bg);
5 | border-radius: var(--radius-large);
6 | box-shadow: var(--elevation-500);
7 | overflow-y: auto;
8 | outline: 0;
9 | }
10 |
11 | .fp-DialogBaseHeader {
12 | box-sizing: border-box;
13 | display: flex;
14 | align-items: center;
15 | height: var(--space-10);
16 | padding: var(--space-1) var(--space-2);
17 | border-bottom: 1px solid var(--figma-color-border);
18 | background-color: var(--figma-color-bg);
19 | }
20 |
21 | .fp-DialogBaseTitle {
22 | padding-left: var(--space-2);
23 | }
24 |
25 | .fp-DialogBaseControls {
26 | display: flex;
27 | align-items: center;
28 | margin-left: auto;
29 | }
30 |
31 | .fp-DialogBaseSection {
32 | box-sizing: border-box;
33 | padding: var(--space-4);
34 | border-bottom: 1px solid var(--figma-color-border);
35 |
36 | &:where(:last-child) {
37 | border-bottom: 0;
38 | }
39 |
40 | &:where(.fp-DialogBaseSection-base) {
41 | padding: var(--space-4);
42 | }
43 |
44 | &:where(.fp-DialogBaseSection-small) {
45 | padding: var(--space-2) var(--space-4);
46 | }
47 | }
48 |
49 | .fp-DialogBaseOverlay {
50 | position: fixed;
51 | inset: 0;
52 | background-color: var(--color-overlay-dialog);
53 | }
54 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dialog.base/dialog.base.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { VariantProps } from 'class-variance-authority';
3 | import { cva, cx } from 'class-variance-authority';
4 |
5 | type HeaderElement = React.ElementRef<'header'>;
6 | type HeaderProps = React.ComponentPropsWithoutRef<'header'>;
7 |
8 | const Header = React.forwardRef((props, ref) => {
9 | const { children, className, ...closeProps } = props;
10 |
11 | return (
12 |
15 | );
16 | });
17 |
18 | type SectionElement = React.ElementRef<'div'>;
19 | type SectionProps = React.ComponentPropsWithoutRef<'div'> & VariantProps;
20 |
21 | const Section = React.forwardRef((props, ref) => {
22 | const { className, size, ...sectionProps } = props;
23 |
24 | return
;
25 | });
26 |
27 | type ControlsElement = React.ElementRef<'div'>;
28 | type ControlsProps = React.ComponentPropsWithoutRef<'div'>;
29 |
30 | const Controls = React.forwardRef((props, ref) => {
31 | const { className, ...controlProps } = props;
32 | return
;
33 | });
34 |
35 | const section = cva('fp-DialogBaseSection', {
36 | variants: {
37 | size: {
38 | base: 'fp-DialogBaseSection-base',
39 | small: 'fp-DialogBaseSection-small',
40 | },
41 | },
42 | defaultVariants: {
43 | size: 'base',
44 | },
45 | });
46 |
47 | Header.displayName = 'Dialog.Header';
48 | Section.displayName = 'Dialog.Section';
49 | Controls.displayName = 'Dialog.Controls';
50 |
51 | export { Header, Section, Controls };
52 | export type { HeaderProps, SectionProps, ControlsProps };
53 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dialog.base/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dialog.base';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dialog/dialog.css:
--------------------------------------------------------------------------------
1 | .fp-DialogContent {
2 | position: fixed;
3 | max-width: calc(100vw - 32px);
4 | max-height: 80%;
5 |
6 | &:where(.fp-placement-center) {
7 | top: 50%;
8 | left: 50%;
9 | transform: translate(-50%, -50%);
10 | }
11 |
12 | &:where(.fp-placement-top) {
13 | top: min(10vh, 80px);
14 | left: 50%;
15 | transform: translateX(-50%);
16 | }
17 |
18 | &:where(.fp-size-1) {
19 | width: 288px;
20 | }
21 |
22 | &:where(.fp-size-2) {
23 | width: 352px;
24 | }
25 |
26 | &:where(.fp-size-3) {
27 | width: 448px;
28 | }
29 |
30 | &:where(.fp-size-fullscreen) {
31 | inset: 0;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dialog/dialog.tsx:
--------------------------------------------------------------------------------
1 | import type { CSSProperties } from 'react';
2 | import React from 'react';
3 | import * as RadixDialog from '@radix-ui/react-dialog';
4 | import type { VariantProps } from 'class-variance-authority';
5 | import { cva, cx } from 'class-variance-authority';
6 | import { IconButton } from '@components/icon-button';
7 | import { CloseIcon } from '@components/icons';
8 | import { Text } from '@components/text';
9 |
10 | type RootProps = RadixDialog.DialogProps;
11 | const Root = RadixDialog.Root;
12 | type PortalProps = RadixDialog.DialogPortalProps;
13 | const Portal = RadixDialog.Portal;
14 |
15 | type TriggerElement = React.ElementRef;
16 | type TriggerProps = Omit;
17 |
18 | const Trigger = React.forwardRef((props, ref) => {
19 | return ;
20 | });
21 |
22 | const content = cva(['fp-DialogBaseContent', 'fp-DialogContent'], {
23 | variants: {
24 | size: {
25 | '1': 'fp-size-1',
26 | '2': 'fp-size-2',
27 | '3': 'fp-size-3',
28 | fullscreen: 'fp-size-fullscreen',
29 | },
30 | placement: {
31 | center: 'fp-placement-center',
32 | top: 'fp-placement-top',
33 | },
34 | },
35 | defaultVariants: {
36 | size: '2',
37 | placement: 'top',
38 | },
39 | });
40 |
41 | type ContentElement = React.ElementRef;
42 | type ContentProps = RadixDialog.DialogContentProps &
43 | VariantProps & {
44 | width?: CSSProperties['width'];
45 | maxWidth?: CSSProperties['maxWidth'];
46 | height?: CSSProperties['height'];
47 | maxHeight?: CSSProperties['maxHeight'];
48 | };
49 |
50 | const Content = React.forwardRef((props, ref) => {
51 | const { children, className, size, placement, style, width, height, maxWidth, maxHeight, ...contentProps } = props;
52 |
53 | return (
54 |
62 | {children}
63 |
64 | );
65 | });
66 |
67 | type OverlayElement = React.ElementRef;
68 | type OverlayProps = Omit;
69 |
70 | const Overlay = React.forwardRef((props, ref) => {
71 | const { className, ...overlayProps } = props;
72 |
73 | return ;
74 | });
75 |
76 | type TitleElement = React.ElementRef;
77 | type TitleProps = Omit;
78 |
79 | const Title = React.forwardRef((props, ref) => {
80 | const { children, className, ...closeProps } = props;
81 |
82 | return (
83 |
84 | {children}
85 |
86 | );
87 | });
88 |
89 | type CloseElement = React.ElementRef;
90 | type CloseProps = Omit;
91 |
92 | const Close = React.forwardRef((props, ref) => {
93 | const { children, ...closeProps } = props;
94 |
95 | return (
96 |
97 | {children || (
98 |
99 |
100 |
101 | )}
102 |
103 | );
104 | });
105 |
106 | Trigger.displayName = 'Dialog.Trigger';
107 | Content.displayName = 'Dialog.Content';
108 | Overlay.displayName = 'Dialog.Overlay';
109 | Title.displayName = 'Dialog.Title';
110 | Close.displayName = 'Dialog.Close';
111 |
112 | export type { RootProps, TriggerProps, PortalProps, ContentProps, OverlayProps, TitleProps, CloseProps };
113 | export { Root, Trigger, Portal, Content, Overlay, Title, Close };
114 | export type { HeaderProps, SectionProps, ControlsProps } from '@components/dialog.base/';
115 | export { Header, Section, Controls } from '@components/dialog.base/';
116 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dialog/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dialog';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dropdown-menu/dropdown-menu.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import React from 'react';
3 | import { Button } from '@components/button';
4 | import * as DropdownMenu from './dropdown-menu';
5 |
6 | type Story = StoryObj;
7 |
8 | const meta: Meta = {
9 | component: DropdownMenu.Root,
10 | title: 'Components/Dropdown Menu',
11 | parameters: {
12 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/dropdown-menu',
13 | radixComponentName: 'Dropdown Menu',
14 | },
15 | decorators: [
16 | (Story) => (
17 |
18 |
19 |
20 | ),
21 | ],
22 | };
23 |
24 | export default meta;
25 |
26 | export const Story: Story = {
27 | render: (args) => {
28 | return (
29 |
30 |
31 | Menu
32 |
33 |
34 | Show version history
35 | Publish library…
36 | Export…
37 |
38 | Add to sidebar
39 |
40 | Create branch…
41 |
42 |
43 | File color profile...
44 |
45 | Display P3
46 | Change to sRGB
47 |
48 |
49 |
50 | Duplicate
51 | Rename
52 | Move to project…
53 | Delete…
54 |
55 | This
56 | Or this
57 | Or this one
58 |
59 | Display P3
60 | Change to sRGB
61 |
62 |
63 | );
64 | },
65 | };
66 |
--------------------------------------------------------------------------------
/figma-kit/src/components/dropdown-menu/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dropdown-menu';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/flex/flex.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Flex } from './flex';
3 |
4 | type Story = StoryObj;
5 |
6 | const meta: Meta = {
7 | title: 'Components/Flex',
8 | component: Flex,
9 | parameters: {
10 | controls: {
11 | expanded: true,
12 | },
13 | },
14 | argTypes: {
15 | direction: {
16 | description: 'The direction of the flex container.',
17 | type: 'string',
18 | table: {
19 | type: {
20 | summary: 'enum',
21 | },
22 | defaultValue: {
23 | summary: 'row',
24 | },
25 | },
26 | options: ['row', 'column', 'rowReverse', 'columnReverse'],
27 | control: { type: 'radio' },
28 | },
29 | align: {
30 | description: 'The alignment of the flex items along the cross axis.',
31 | type: 'string',
32 | table: {
33 | type: {
34 | summary: 'enum',
35 | },
36 | },
37 | options: ['start', 'center', 'end', 'baseline', 'stretch'],
38 | control: { type: 'radio' },
39 | },
40 | justify: {
41 | description: 'The alignment of the flex items along the main axis.',
42 | type: 'string',
43 | table: {
44 | type: {
45 | summary: 'enum',
46 | },
47 | },
48 | options: ['start', 'center', 'end', 'between'],
49 | control: { type: 'radio' },
50 | },
51 | wrap: {
52 | description: 'The wrapping behavior of the flex container.',
53 | type: 'string',
54 | table: {
55 | type: {
56 | summary: 'enum',
57 | },
58 | },
59 | options: ['nowrap', 'wrap', 'wrapReverse'],
60 | control: { type: 'radio' },
61 | },
62 | gap: {
63 | description: 'The gap between flex items.',
64 | type: 'string',
65 | table: {
66 | type: {
67 | summary: 'string',
68 | },
69 | },
70 | options: [
71 | '0',
72 | 'px',
73 | '0.5',
74 | '1',
75 | '1.5',
76 | '2',
77 | '2.5',
78 | '3',
79 | '3.5',
80 | '4',
81 | '5',
82 | '6',
83 | '7',
84 | '8',
85 | '9',
86 | '10',
87 | '11',
88 | '12',
89 | '13',
90 | '14',
91 | '15',
92 | '16',
93 | ],
94 | control: { type: 'select' },
95 | },
96 | columnGap: {
97 | description: 'The gap between flex columns.',
98 | type: 'string',
99 | table: {
100 | type: {
101 | summary: 'string',
102 | },
103 | },
104 | options: [
105 | '0',
106 | 'px',
107 | '0_5',
108 | '1',
109 | '1_5',
110 | '2',
111 | '2_5',
112 | '3',
113 | '3_5',
114 | '4',
115 | '5',
116 | '6',
117 | '7',
118 | '8',
119 | '9',
120 | '10',
121 | '11',
122 | '12',
123 | '13',
124 | '14',
125 | '15',
126 | '16',
127 | ],
128 | control: { type: 'select' },
129 | },
130 | rowGap: {
131 | description: 'The gap between flex rows.',
132 | type: 'string',
133 | table: {
134 | type: {
135 | summary: 'string',
136 | },
137 | },
138 | options: [
139 | '0',
140 | 'px',
141 | '0_5',
142 | '1',
143 | '1_5',
144 | '2',
145 | '2_5',
146 | '3',
147 | '3_5',
148 | '4',
149 | '5',
150 | '6',
151 | '7',
152 | '8',
153 | '9',
154 | '10',
155 | '11',
156 | '12',
157 | '13',
158 | '14',
159 | '15',
160 | '16',
161 | ],
162 | control: { type: 'select' },
163 | },
164 | },
165 | };
166 |
167 | export default meta;
168 |
169 | const PreviewBox = () =>
;
170 |
171 | const flexCode = `
172 |
173 | `;
174 |
175 | export const Story: Story = {
176 | parameters: {
177 | storySource: {
178 | source: flexCode,
179 | },
180 | },
181 | args: {
182 | align: 'center',
183 | justify: 'center',
184 | gap: '2',
185 | },
186 | render(args) {
187 | return (
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 | );
196 | },
197 | };
198 |
--------------------------------------------------------------------------------
/figma-kit/src/components/flex/flex.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { VariantProps } from 'class-variance-authority';
3 | import { cva } from 'class-variance-authority';
4 |
5 | const gapVariants = {
6 | '0': 'fp-gap-0',
7 | px: 'fp-gap-px',
8 | '0.5': 'fp-gap-0_5',
9 | '1': 'fp-gap-1',
10 | '1.5': 'fp-gap-1_5',
11 | '2': 'fp-gap-2',
12 | '2.5': 'fp-gap-2_5',
13 | '3': 'fp-gap-3',
14 | '3.5': 'fp-gap-3_5',
15 | '4': 'fp-gap-4',
16 | '5': 'fp-gap-5',
17 | '6': 'fp-gap-6',
18 | '7': 'fp-gap-7',
19 | '8': 'fp-gap-8',
20 | '9': 'fp-gap-9',
21 | '10': 'fp-gap-10',
22 | '11': 'fp-gap-11',
23 | '12': 'fp-gap-12',
24 | '13': 'fp-gap-13',
25 | '14': 'fp-gap-14',
26 | '15': 'fp-gap-15',
27 | '16': 'fp-gap-16',
28 | };
29 |
30 | const flex = cva('fp-Flex', {
31 | variants: {
32 | direction: {
33 | row: 'fp-direction-row',
34 | column: 'fp-direction-column',
35 | rowReverse: 'fp-direction-row-reverse',
36 | columnReverse: 'fp-direction-column-reverse',
37 | },
38 | align: {
39 | start: 'fp-align-start',
40 | center: 'fp-align-center',
41 | end: 'fp-align-end',
42 | baseline: 'fp-align-baseline',
43 | stretch: 'fp-align-stretch',
44 | },
45 | justify: {
46 | start: 'fp-justify-start',
47 | center: 'fp-justify-center',
48 | end: 'fp-justify-end',
49 | between: 'fp-justify-between',
50 | },
51 | wrap: {
52 | nowrap: 'fp-wrap-nowrap',
53 | wrap: 'fp-wrap-wrap',
54 | wrapReverse: 'fp-wrap-wrap-reverse',
55 | },
56 | gap: gapVariants,
57 | columnGap: gapVariants,
58 | rowGap: gapVariants,
59 | },
60 | defaultVariants: {
61 | direction: 'row',
62 | },
63 | });
64 |
65 | type FlexElement = React.ElementRef<'div'>;
66 | type FlexProps = React.ComponentPropsWithoutRef<'div'> & VariantProps;
67 |
68 | const Flex = React.forwardRef((props, ref) => {
69 | const { className, direction, align, justify, wrap, gap, columnGap, rowGap, ...flexProps } = props;
70 |
71 | return (
72 |
77 | );
78 | });
79 |
80 | Flex.displayName = 'Flex';
81 |
82 | export type { FlexProps };
83 | export { Flex };
84 |
--------------------------------------------------------------------------------
/figma-kit/src/components/flex/index.ts:
--------------------------------------------------------------------------------
1 | export * from './flex';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon-button/icon-button.css:
--------------------------------------------------------------------------------
1 | .fp-IconButton {
2 | box-sizing: border-box;
3 | background-clip: border-box;
4 | background-color: transparent;
5 | user-select: none;
6 | appearance: none;
7 | border: 0;
8 | padding: 0;
9 | cursor: default;
10 | display: flex;
11 | align-items: center;
12 | justify-content: center;
13 | flex-shrink: 0;
14 | --color-icon: var(--figma-color-icon);
15 | border-radius: var(--radius-medium);
16 |
17 | &:where(:hover:not(:disabled)) {
18 | background-color: var(--figma-color-bg-pressed);
19 | }
20 |
21 | &:focus-visible {
22 | outline-offset: -1px;
23 | outline: 1px solid var(--figma-color-border-selected);
24 | }
25 |
26 | &:disabled {
27 | --color-icon: var(--figma-color-icon-disabled);
28 | }
29 | }
30 |
31 | .fp-IconButton:where(.fp-size-small) {
32 | width: var(--space-6);
33 | height: var(--space-6);
34 | }
35 |
36 | .fp-IconButton:where(.fp-size-medium) {
37 | width: var(--space-8);
38 | height: var(--space-8);
39 | }
40 |
41 | .fp-IconButton:where(.fp-active-appearance-subtle) {
42 | &[data-state='open'] {
43 | background-color: var(--figma-color-bg-selected);
44 | --color-icon: var(--figma-color-icon-brand);
45 | }
46 | }
47 |
48 | .fp-IconButton:where(.fp-active-appearance-solid) {
49 | &[data-state='open'] {
50 | background-color: var(--figma-color-bg-selected-strong);
51 | --color-icon: var(--figma-color-icon-onbrand);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon-button/icon-button.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import React from 'react';
3 | import { TooltipProvider } from '@components/tooltip';
4 | import { PlusIcon, StylesIcon } from '@components/icons';
5 | import * as Popover from '@components/popover';
6 | import { Text } from '@components/text';
7 | import { IconButton } from './icon-button';
8 |
9 | type Story = StoryObj;
10 |
11 | const meta: Meta = {
12 | component: IconButton,
13 | title: 'Components/Icon Button',
14 | parameters: { controls: { expanded: true } },
15 | argTypes: {
16 | 'aria-label': {
17 | description: 'Text for screen readers, also used as tooltip text by default.',
18 | type: {
19 | name: 'string',
20 | required: true,
21 | },
22 | },
23 | size: {
24 | description: 'Size of the button.',
25 | table: {
26 | type: {
27 | summary: 'enum',
28 | },
29 | defaultValue: {
30 | summary: 'small',
31 | },
32 | },
33 | options: ['small', 'medium'],
34 | control: { type: 'radio' },
35 | },
36 | activeAppearance: {
37 | description: 'Appearance of the button when in active/pressed mode',
38 | table: {
39 | type: {
40 | summary: 'enum',
41 | },
42 | defaultValue: {
43 | summary: 'subtle',
44 | },
45 | },
46 | options: ['subtle', 'solid'],
47 | control: { type: 'radio' },
48 | },
49 | disabled: {
50 | type: 'boolean',
51 | },
52 | tooltipContent: {
53 | description: 'Custom content for the tooltip. Defaults to aria-label if not specified.',
54 | type: 'string',
55 | },
56 | disableTooltip: {
57 | description: "Disables the tooltip. Use sparingly when the button's function is clear.",
58 | type: 'boolean',
59 | },
60 | },
61 | decorators: [
62 | (Story) => (
63 |
64 |
65 |
66 | ),
67 | ],
68 | };
69 |
70 | export default meta;
71 |
72 | const code = `
73 |
74 |
75 |
76 | `;
77 |
78 | export const Default: Story = {
79 | parameters: {
80 | storySource: {
81 | source: code,
82 | },
83 | },
84 | render: (args) => {
85 | return (
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | Popover
95 |
96 |
97 |
98 |
99 |
100 | A sample popover for demonstrating icon button active state.
101 |
102 |
103 |
104 | );
105 | },
106 | args: {
107 | 'aria-label': 'Open popover',
108 | activeAppearance: 'subtle',
109 | size: 'small',
110 | children: ,
111 | },
112 | };
113 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon-button/icon-button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { VariantProps } from 'class-variance-authority';
3 | import { cva } from 'class-variance-authority';
4 | import { Tooltip } from '@components/tooltip';
5 |
6 | const iconButton = cva('fp-IconButton', {
7 | variants: {
8 | size: {
9 | small: 'fp-size-small',
10 | medium: 'fp-size-medium',
11 | },
12 | activeAppearance: {
13 | subtle: 'fp-active-appearance-subtle',
14 | solid: 'fp-active-appearance-solid',
15 | },
16 | },
17 | defaultVariants: {
18 | size: 'small',
19 | activeAppearance: 'subtle',
20 | },
21 | });
22 |
23 | type IconButtonElement = React.ElementRef<'button'>;
24 | type IconButtonProps = React.ComponentPropsWithoutRef<'button'> &
25 | VariantProps & {
26 | 'aria-label': string;
27 | tooltipContent?: React.ReactNode;
28 | disableTooltip?: boolean;
29 | };
30 |
31 | const IconButton = React.forwardRef((props, ref) => {
32 | const {
33 | className,
34 | size,
35 | activeAppearance,
36 | 'aria-label': ariaLabel,
37 | tooltipContent,
38 | disableTooltip,
39 | ...iconButtonProps
40 | } = props;
41 | const buttonElement = (
42 |
48 | );
49 |
50 | return disableTooltip ? buttonElement : {buttonElement} ;
51 | });
52 |
53 | IconButton.displayName = 'IconButton';
54 |
55 | export type { IconButtonProps };
56 | export { IconButton };
57 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon-button/index.ts:
--------------------------------------------------------------------------------
1 | export * from './icon-button';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon/icon.css:
--------------------------------------------------------------------------------
1 | .fp-Icon {
2 | display: block;
3 | flex-shrink: 0;
4 | pointer-events: none;
5 |
6 | &:where(.fp-size-1) {
7 | width: var(--space-1);
8 | }
9 |
10 | &:where(.fp-size-2) {
11 | width: var(--space-2);
12 | }
13 |
14 | &:where(.fp-size-2_5) {
15 | width: var(--space-2_5);
16 | }
17 |
18 | &:where(.fp-size-3) {
19 | width: var(--space-3);
20 | }
21 |
22 | &:where(.fp-size-3_5) {
23 | width: var(--space-3_5);
24 | }
25 |
26 | &:where(.fp-size-4) {
27 | width: var(--space-4);
28 | }
29 |
30 | &:where(.fp-size-5) {
31 | width: var(--space-5);
32 | }
33 |
34 | &:where(.fp-size-6) {
35 | width: var(--space-6);
36 | }
37 |
38 | &:where(.fp-size-7) {
39 | width: var(--space-7);
40 | }
41 |
42 | &:where(.fp-size-8) {
43 | width: var(--space-8);
44 | }
45 |
46 | &:where(.fp-size-9) {
47 | width: var(--space-9);
48 | }
49 |
50 | &:where(.fp-size-10) {
51 | width: var(--space-10);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon/icon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { VariantProps } from 'class-variance-authority';
3 | import { cva } from 'class-variance-authority';
4 |
5 | const DEFAULT_SIZE = '6';
6 | const DEFAULT_VIEWBOX = '0 0 24 24';
7 |
8 | type Options = {
9 | path: React.ReactElement;
10 | displayName?: string;
11 | viewBox?: string;
12 | };
13 |
14 | const icon = cva('fp-Icon', {
15 | variants: {
16 | size: {
17 | '1': 'fp-size-1',
18 | '2': 'fp-size-2',
19 | '2_5': 'fp-size-2_5',
20 | '3': 'fp-size-3',
21 | '3_5': 'fp-size-3_5',
22 | '4': 'fp-size-4',
23 | '5': 'fp-size-5',
24 | '6': 'fp-size-6',
25 | '7': 'fp-size-7',
26 | '8': 'fp-size-8',
27 | '9': 'fp-size-9',
28 | '10': 'fp-size-10',
29 | },
30 | },
31 | defaultVariants: {
32 | size: DEFAULT_SIZE,
33 | },
34 | });
35 |
36 | type IconElement = React.ElementRef<'svg'>;
37 | type IconProps = React.ComponentPropsWithoutRef<'svg'> & VariantProps;
38 |
39 | function createIcon(options: Options) {
40 | const { path, viewBox = DEFAULT_VIEWBOX, displayName } = options;
41 |
42 | const Component = React.forwardRef((props, ref) => {
43 | const { size, className, ...iconProps } = props;
44 |
45 | return (
46 |
54 | {path}
55 |
56 | );
57 | });
58 |
59 | Component.displayName = displayName || 'Icon';
60 |
61 | return Component;
62 | }
63 |
64 | export type { IconProps };
65 | export { createIcon };
66 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icon/index.ts:
--------------------------------------------------------------------------------
1 | export * from './icon';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/checkmark-indeterminate.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const CheckmarkIndeterminateIcon = createIcon({
4 | displayName: 'CheckmarkIndeterminate',
5 | viewBox: '0 0 16 16',
6 | path: ,
7 | });
8 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/checkmark.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const CheckmarkIcon = createIcon({
4 | displayName: 'Checkmark',
5 | viewBox: '0 0 16 16',
6 | path: (
7 |
13 | ),
14 | });
15 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/chevron-down.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const ChevronDownIcon = createIcon({
4 | displayName: 'ChevronDown',
5 | path: (
6 |
7 |
8 |
9 | ),
10 | });
11 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/chevron-left.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const ChevronLeftIcon = createIcon({
4 | displayName: 'ChevronLeft',
5 | path: ,
6 | });
7 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/chevron-right.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const ChevronRightIcon = createIcon({
4 | displayName: 'ChevronRight',
5 | path: ,
6 | });
7 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/chevron-up.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const ChevronUpIcon = createIcon({
4 | displayName: 'ChevronUp',
5 | path: ,
6 | });
7 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/circle.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const CircleIcon = createIcon({
4 | displayName: 'Circle',
5 | viewBox: '0 0 16 16',
6 | path: ,
7 | });
8 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/close.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const CloseIcon = createIcon({
4 | displayName: 'Close',
5 | path: ,
6 | });
7 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/index.ts:
--------------------------------------------------------------------------------
1 | export { PlusIcon } from './plus';
2 | export { CloseIcon } from './close';
3 | export { ChevronLeftIcon } from './chevron-left';
4 | export { ChevronRightIcon } from './chevron-right';
5 | export { ChevronUpIcon } from './chevron-up';
6 | export { ChevronDownIcon } from './chevron-down';
7 | export { CheckmarkIcon } from './checkmark';
8 | export { CircleIcon } from './circle';
9 | export { StylesIcon } from './styles';
10 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/plus.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const PlusIcon = createIcon({
4 | displayName: 'Plus',
5 | path: ,
6 | });
7 |
--------------------------------------------------------------------------------
/figma-kit/src/components/icons/styles.tsx:
--------------------------------------------------------------------------------
1 | import { createIcon } from '@components/icon';
2 |
3 | export const StylesIcon = createIcon({
4 | displayName: 'Styles',
5 | path: (
6 |
12 | ),
13 | });
14 |
--------------------------------------------------------------------------------
/figma-kit/src/components/input/index.ts:
--------------------------------------------------------------------------------
1 | export * from './input';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/input/input.css:
--------------------------------------------------------------------------------
1 | .fp-Input {
2 | all: unset;
3 | box-sizing: border-box;
4 | display: block;
5 | width: 100%;
6 | height: var(--space-6);
7 | padding: var(--space-1) 0 var(--space-1) var(--space-2);
8 | background-color: var(--figma-color-bg-secondary);
9 | border-radius: var(--radius-medium);
10 | font-family: var(--font-family-default);
11 | font-size: var(--font-size-default);
12 | font-weight: var(--font-weight-default);
13 | letter-spacing: var(--letter-spacing-default);
14 | line-height: var(--line-height-default);
15 | color: var(--figma-color-text);
16 | outline-width: 1px;
17 | outline-style: solid;
18 | outline-offset: -1px;
19 | outline-color: transparent;
20 |
21 | &:hover:where(:not(:disabled, :focus)) {
22 | outline-color: var(--figma-color-border);
23 | }
24 |
25 | &:focus {
26 | outline-color: var(--figma-color-border-selected);
27 | }
28 |
29 | &:disabled {
30 | background-color: var(--figma-color-bg);
31 | outline-color: var(--figma-color-border-disabled);
32 | color: var(--figma-color-text-disabled);
33 | }
34 |
35 | &::placeholder {
36 | color: var(--figma-color-text-tertiary);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/figma-kit/src/components/input/input.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Input } from './input';
3 |
4 | type Story = StoryObj;
5 |
6 | const meta: Meta = {
7 | component: Input,
8 | title: 'Components/Input',
9 | args: {
10 | placeholder: '',
11 | disabled: false,
12 | },
13 | argTypes: {
14 | selectOnClick: {
15 | type: 'boolean',
16 | description: 'Enable content selection on click.',
17 | },
18 | },
19 | };
20 |
21 | export default meta;
22 |
23 | export const Basic: Story = {
24 | args: {
25 | placeholder: 'Basic input',
26 | },
27 | };
28 |
29 | export const SelectOnClick: Story = {
30 | args: {
31 | value: 'Some value',
32 | selectOnClick: true,
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/figma-kit/src/components/input/input.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import mergeProps from 'merge-props';
3 | import { cx } from 'class-variance-authority';
4 | import { useSelectOnInputClick } from '@lib/react/use-select-on-input-click';
5 | import { useComposedRefs } from '@lib/react/use-compose-refs';
6 |
7 | type InputElement = React.ElementRef<'input'>;
8 | type InputProps = React.ComponentPropsWithoutRef<'input'> & {
9 | selectOnClick?: boolean;
10 | };
11 |
12 | const Input = React.forwardRef((props, forwardedRef) => {
13 | const { type = 'text', className, selectOnClick = false, ...rest } = props;
14 | const ref = useRef(null);
15 | const composedRef = useComposedRefs(ref, forwardedRef);
16 | const { onMouseLeave, onMouseUp, onFocus } = useSelectOnInputClick();
17 | const inputProps = selectOnClick ? mergeProps({ onMouseLeave, onMouseUp, onFocus }, rest) : rest;
18 |
19 | return ;
20 | });
21 |
22 | Input.displayName = 'Input';
23 |
24 | export type { InputProps };
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/figma-kit/src/components/menu.base/menu.base.css:
--------------------------------------------------------------------------------
1 | .fp-MenuContent {
2 | box-sizing: border-box;
3 | -webkit-font-smoothing: antialiased;
4 | -moz-osx-font-smoothing: grayscale;
5 | cursor: default;
6 | padding: var(--space-2);
7 | background-color: var(--color-bg-menu);
8 | border-radius: var(--radius-large);
9 | font-size: var(--font-size-menu);
10 | font-family: var(--font-family-default);
11 | font-weight: var(--font-weight-default);
12 | letter-spacing: var(--letter-spacing-default);
13 | color: var(--color-text-menu);
14 | box-shadow: var(--elevation-400);
15 | }
16 |
17 | .fp-MenuItem {
18 | position: relative;
19 | display: flex;
20 | align-items: center;
21 | height: var(--space-6);
22 | padding: 0 var(--space-2);
23 | color: var(--color-text-menu);
24 | line-height: 1rem;
25 | white-space: nowrap;
26 | border-radius: var(--radius-medium);
27 |
28 | &[data-highlighted] {
29 | outline: 0;
30 | background-color: var(--color-bg-menu-selected);
31 | }
32 |
33 | &[data-disabled] {
34 | color: var(--color-text-menu-tertiary);
35 | }
36 | }
37 |
38 | .fp-MenuSeparator {
39 | margin: var(--space-2) 0;
40 | height: 1px;
41 | background-color: var(--color-border-menu);
42 | }
43 |
44 | .fp-MenuLabel {
45 | display: flex;
46 | align-items: center;
47 | height: var(--space-6);
48 | padding: 0 var(--space-4);
49 | color: var(--color-text-menu-tertiary);
50 |
51 | &:where(.fp-MenuContent:has(.fp-MenuCheckboxItem, .fp-MenuRadioItem) &) {
52 | padding-left: var(--space-6);
53 | }
54 | }
55 |
56 | .fp-MenuGroup {
57 | padding: var(--space-2) 0;
58 |
59 | &:first-child {
60 | padding-top: 0;
61 | }
62 |
63 | &:last-child {
64 | padding-bottom: 0;
65 | }
66 |
67 | &:not(:first-child) {
68 | border-top: 1px solid var(--color-border-menu);
69 | }
70 | }
71 |
72 | .fp-MenuSubtriggerCaret {
73 | margin-left: auto;
74 | margin-right: calc(-1 * var(--space-2));
75 | padding-left: var(--space-6);
76 | --color-icon: var(--color-icon-menu);
77 | }
78 |
79 | .fp-MenuCheckboxItem,
80 | .fp-MenuRadioItem {
81 | padding-left: var(--space-6);
82 | }
83 |
84 | .fp-MenuItemIndicator {
85 | position: absolute;
86 | left: var(--space-1);
87 | --color-icon: var(--color-icon-menu);
88 | }
89 |
--------------------------------------------------------------------------------
/figma-kit/src/components/popover/index.ts:
--------------------------------------------------------------------------------
1 | export * from './popover';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/popover/popover.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import React from 'react';
3 | import { IconButton } from '../icon-button';
4 | import { PlusIcon, StylesIcon } from '../icons';
5 | import * as Popover from './popover';
6 |
7 | type Story = StoryObj;
8 |
9 | const meta: Meta = {
10 | title: 'Components/Popover',
11 | component: Popover.Root,
12 | parameters: {
13 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/popover',
14 | radixComponentName: 'Popover',
15 | },
16 | };
17 |
18 | export default meta;
19 |
20 | export const Story: Story = {
21 | args: {},
22 | render: () => {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Text styles
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | },
47 | };
48 |
--------------------------------------------------------------------------------
/figma-kit/src/components/popover/popover.tsx:
--------------------------------------------------------------------------------
1 | import type { CSSProperties } from 'react';
2 | import React from 'react';
3 | import * as RadixPopover from '@radix-ui/react-popover';
4 | import { cx } from 'class-variance-authority';
5 | import { IconButton } from '@components/icon-button';
6 | import { CloseIcon } from '@components/icons';
7 | import { Text } from '@components/text';
8 |
9 | type RootProps = RadixPopover.PopoverProps;
10 | type AnchorProps = RadixPopover.PopoverAnchorProps;
11 | const Root = RadixPopover.Root;
12 | const Anchor = RadixPopover.Anchor;
13 | type PortalProps = RadixPopover.PopoverPortalProps;
14 | const Portal = RadixPopover.Portal;
15 |
16 | type TriggerElement = React.ElementRef;
17 | type TriggerProps = Omit;
18 |
19 | const Trigger = React.forwardRef((props, ref) => {
20 | return ;
21 | });
22 |
23 | type ContentElement = React.ElementRef;
24 | type ContentProps = RadixPopover.PopoverContentProps & {
25 | width?: CSSProperties['width'];
26 | maxWidth?: CSSProperties['maxWidth'];
27 | height?: CSSProperties['height'];
28 | maxHeight?: CSSProperties['maxHeight'];
29 | };
30 |
31 | const Content = React.forwardRef((props, ref) => {
32 | const { className, style, width, height, maxWidth, maxHeight, ...contentProps } = props;
33 |
34 | return (
35 |
41 | );
42 | });
43 |
44 | type TitleElement = React.ElementRef;
45 | type TitleProps = React.ComponentPropsWithoutRef;
46 |
47 | // TODO: needs an implementation of `aria-labelledby`
48 | const Title = React.forwardRef((props, ref) => {
49 | const { className, ...closeProps } = props;
50 |
51 | return ;
52 | });
53 |
54 | type CloseElement = React.ElementRef;
55 | type CloseProps = Omit;
56 |
57 | const Close = React.forwardRef((props, ref) => {
58 | const { children, ...closeProps } = props;
59 |
60 | return (
61 |
62 | {children || (
63 |
64 |
65 |
66 | )}
67 |
68 | );
69 | });
70 |
71 | Trigger.displayName = 'Popover.Trigger';
72 | Content.displayName = 'Popover.Content';
73 | Title.displayName = 'Popover.Title';
74 | Close.displayName = 'Popover.Close';
75 |
76 | export type { RootProps, TriggerProps, PortalProps, ContentProps, TitleProps, CloseProps, AnchorProps };
77 | export { Root, Trigger, Content, Portal, Title, Close, Anchor };
78 |
79 | export type { HeaderProps, SectionProps, ControlsProps } from '@components/dialog.base/';
80 | export { Header, Section, Controls } from '@components/dialog.base/';
81 |
--------------------------------------------------------------------------------
/figma-kit/src/components/radio-group/index.ts:
--------------------------------------------------------------------------------
1 | export * from './radio-group';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/radio-group/radio-group.css:
--------------------------------------------------------------------------------
1 | .fp-RadioGroupRoot {
2 | display: flex;
3 |
4 | &[data-orientation='horizontal'] {
5 | gap: var(--space-5);
6 | }
7 |
8 | &[data-orientation='vertical'] {
9 | flex-direction: column;
10 | gap: var(--space-2);
11 | }
12 | }
13 |
14 | .fp-RadioGroupItem {
15 | all: unset;
16 | box-sizing: border-box;
17 | width: 16px;
18 | height: 16px;
19 | background-color: var(--figma-color-bg);
20 | border: 4px solid transparent;
21 | border-radius: var(--radius-full);
22 | outline-offset: -1px;
23 | outline: 1px solid var(--figma-color-icon);
24 |
25 | &:focus-visible {
26 | outline-color: var(--figma-color-border-selected);
27 | }
28 |
29 | &[data-state='checked'] {
30 | background-color: var(--figma-color-icon);
31 | border-color: var(--figma-color-bg);
32 | }
33 |
34 | &[data-disabled] {
35 | outline-color: var(--figma-color-icon-disabled);
36 | }
37 |
38 | &[data-disabled][data-state='checked'] {
39 | background-color: var(--figma-color-icon-disabled);
40 | }
41 | }
42 |
43 | .fp-RadioGroupLabel {
44 | display: flex;
45 | gap: var(--space-2);
46 |
47 | &[data-disabled] {
48 | color: var(--figma-color-text-disabled);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/figma-kit/src/components/radio-group/radio-group.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import React from 'react';
3 | import * as RadioGroup from './radio-group';
4 |
5 | type Story = StoryObj;
6 |
7 | const meta: Meta = {
8 | title: 'Components/Radio Group',
9 | component: RadioGroup.Root,
10 | parameters: {
11 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/radio-group',
12 | radixComponentName: 'Radio Group',
13 | },
14 | };
15 |
16 | export default meta;
17 |
18 | export const Horizontal: Story = {
19 | render: () => {
20 | return (
21 |
22 |
23 |
24 | Minimalist
25 |
26 |
27 |
28 | Modern
29 |
30 |
31 |
32 | Retro
33 |
34 |
35 | );
36 | },
37 | };
38 |
39 | export const Vertical: Story = {
40 | render: () => {
41 | return (
42 |
43 |
44 |
45 | Minimalist
46 |
47 |
48 |
49 | Modern
50 |
51 |
52 |
53 | Retro
54 |
55 |
56 | );
57 | },
58 | };
59 |
60 | export const Disabled: Story = {
61 | render: () => {
62 | return (
63 |
64 |
65 |
66 | Minimalist
67 |
68 |
69 |
70 | Modern
71 |
72 |
73 |
74 | Retro
75 |
76 |
77 | );
78 | },
79 | };
80 |
--------------------------------------------------------------------------------
/figma-kit/src/components/radio-group/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as RadixRadioGroup from '@radix-ui/react-radio-group';
3 | import { cx } from 'class-variance-authority';
4 | import { Label as LabelPrimitive, type LabelProps as LabelPrimitiveProps } from '@components/text';
5 | import { createContext } from '@lib/react/create-context';
6 |
7 | const [RadioGroupContextProvider, useRadioGroupContext] = createContext<{
8 | orientation: 'horizontal' | 'vertical';
9 | disabled: boolean | undefined;
10 | }>('RadioGroup');
11 |
12 | type RootElement = React.ElementRef;
13 | type RootProps = RadixRadioGroup.RadioGroupProps;
14 |
15 | const Root = React.forwardRef((props, ref) => {
16 | const { orientation = 'horizontal', disabled, className, ...rootProps } = props;
17 |
18 | return (
19 |
20 |
27 |
28 | );
29 | });
30 |
31 | type ItemElement = React.ElementRef;
32 | type ItemProps = RadixRadioGroup.RadioGroupItemProps;
33 |
34 | const Item = React.forwardRef((props, ref) => {
35 | const { className, ...itemProps } = props;
36 |
37 | return ;
38 | });
39 |
40 | type LabelElement = React.ElementRef<'label'>;
41 | type LabelProps = LabelPrimitiveProps;
42 |
43 | const Label = React.forwardRef((props, ref) => {
44 | const { orientation, disabled } = useRadioGroupContext('Label');
45 | const { className, ...labelProps } = props;
46 |
47 | return (
48 |
55 | );
56 | });
57 |
58 | Root.displayName = 'RadioGroup.Root';
59 | Item.displayName = 'RadioGroup.Item';
60 | Label.displayName = 'RadioGroup.Label';
61 |
62 | export type { RootProps, ItemProps, LabelProps };
63 | export { Root, Item, Label };
64 |
--------------------------------------------------------------------------------
/figma-kit/src/components/segmented-control/index.ts:
--------------------------------------------------------------------------------
1 | export * from './segmented-control';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/segmented-control/segmented-control.css:
--------------------------------------------------------------------------------
1 | .fp-SegmentedControlRoot {
2 | display: inline-flex;
3 | background-color: var(--figma-color-bg-secondary);
4 | border-radius: var(--radius-medium);
5 |
6 | &:where(.fp-full-width) {
7 | display: flex;
8 | }
9 | }
10 |
11 | .fp-SegmentedControlItem {
12 | all: unset;
13 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 | width: fit-content;
17 | flex: 1 0 auto;
18 | min-width: var(--space-6);
19 | height: var(--space-6);
20 | background-color: transparent;
21 | border-radius: var(--radius-medium);
22 | font-family: var(--font-family-default);
23 | font-size: var(--font-size-default);
24 | font-weight: var(--font-weight-default);
25 | letter-spacing: var(--letter-spacing-default);
26 | line-height: var(--line-height-default);
27 | white-space: nowrap;
28 | --color-icon: var(--figma-color-icon-secondary);
29 |
30 | &[aria-checked='true'] {
31 | --color-icon: var(--figma-color-icon);
32 | background-color: var(--figma-color-bg);
33 | box-shadow: inset 0 0 0 var(--space-px) var(--figma-color-border);
34 | }
35 |
36 | &:focus-visible {
37 | outline: 1px solid var(--figma-color-border-selected);
38 | outline-offset: -1px;
39 | }
40 |
41 | &:disabled {
42 | --color-icon: var(--figma-color-icon-disabled);
43 | }
44 | }
45 |
46 | .fp-SegmentedControlText {
47 | display: flex;
48 | align-items: center;
49 | padding: var(--space-2);
50 | gap: var(--space-1_5);
51 |
52 | [data-disabled] & {
53 | color: var(--figma-color-text-disabled);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/figma-kit/src/components/segmented-control/segmented-control.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as RadixToggleGroup from '@radix-ui/react-toggle-group';
3 | import { cx } from 'class-variance-authority';
4 | import { Text as TextPrimitive, type TextProps as TextPrimitiveProps } from '@components/text';
5 | import { useControllableState } from '@lib/react/use-controllable-state';
6 |
7 | type RootElement = React.ElementRef;
8 | type RootProps = Omit & {
9 | fullWidth?: boolean;
10 | };
11 |
12 | const Root = React.forwardRef((props, ref) => {
13 | const { className, fullWidth, value: valueProp, defaultValue: defaultValueProp, onValueChange, ...rootProps } = props;
14 |
15 | const [value, setValue] = useControllableState({
16 | prop: valueProp,
17 | defaultProp: defaultValueProp,
18 | onChange: onValueChange,
19 | });
20 |
21 | return (
22 | {
29 | if (value) {
30 | setValue(value);
31 | }
32 | }}
33 | />
34 | );
35 | });
36 |
37 | type ItemElement = React.ElementRef;
38 | type ItemProps = RadixToggleGroup.ToggleGroupItemProps;
39 |
40 | const Item = React.forwardRef((props, ref) => {
41 | const { className, ...itemProps } = props;
42 |
43 | return ;
44 | });
45 |
46 | type TextElement = React.ElementRef;
47 | type TextProps = TextPrimitiveProps;
48 |
49 | const Text = React.forwardRef((props, ref) => {
50 | const { className, ...textProps } = props;
51 |
52 | return ;
53 | });
54 |
55 | Root.displayName = 'SegmentedControl.Root';
56 | Item.displayName = 'SegmentedControl.Item';
57 | Text.displayName = 'SegmentedControl.Text';
58 |
59 | export type { RootProps, ItemProps, TextProps };
60 | export { Root, Item, Text };
61 |
--------------------------------------------------------------------------------
/figma-kit/src/components/select/index.ts:
--------------------------------------------------------------------------------
1 | export * from './select';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/select/select.css:
--------------------------------------------------------------------------------
1 | .fp-SelectTrigger {
2 | all: unset;
3 | appearance: none;
4 | box-sizing: border-box;
5 | display: flex;
6 | align-items: center;
7 | gap: var(--space-1);
8 | width: 100%;
9 | padding: 0 0 0 var(--space-2);
10 | height: var(--space-6);
11 | border: 1px solid var(--figma-color-border);
12 | border-radius: var(--radius-medium);
13 | font-family: var(--font-family-default);
14 | font-size: var(--font-size-default);
15 | font-weight: var(--font-weight-default);
16 | letter-spacing: var(--letter-spacing-default);
17 | color: var(--figma-color-text);
18 | }
19 |
20 | .fp-SelectTriggerIcon {
21 | margin-left: auto;
22 | }
23 |
24 | .fp-SelectScrollUpButton,
25 | .fp-SelectScrollDownButton {
26 | position: absolute;
27 | left: 0;
28 | right: 0;
29 | z-index: 1;
30 | display: flex;
31 | align-items: center;
32 | justify-content: center;
33 | height: var(--space-6);
34 | background-color: var(--color-bg-menu);
35 | --color-icon: var(--color-icon-menu);
36 |
37 | &:hover {
38 | background-color: var(--color-bg-menu-hover);
39 | }
40 | }
41 |
42 | .fp-SelectScrollUpButton {
43 | top: 0;
44 | border-radius: var(--radius-large) var(--radius-large) 0 0;
45 | }
46 |
47 | .fp-SelectScrollDownButton {
48 | bottom: 0;
49 | border-radius: 0 0 var(--radius-large) var(--radius-large);
50 | }
51 |
--------------------------------------------------------------------------------
/figma-kit/src/components/select/select.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as RadixSelect from '@radix-ui/react-select';
3 | import { cx } from 'class-variance-authority';
4 | import { ChevronDownIcon, ChevronUpIcon, CheckmarkIcon } from '@components/icons';
5 |
6 | type RootProps = RadixSelect.SelectProps;
7 |
8 | const Root = RadixSelect.Root;
9 | const Arrow = RadixSelect.Arrow;
10 |
11 | type TriggerElement = React.ElementRef;
12 | type TriggerProps = {
13 | placeholder?: string;
14 | } & RadixSelect.SelectTriggerProps;
15 |
16 | const Trigger = React.forwardRef((props, ref) => {
17 | const { placeholder, className, ...triggerProps } = props;
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | });
28 |
29 | type ContentElement = React.ElementRef;
30 | type ContentProps = RadixSelect.SelectContentProps & {
31 | portal?: boolean;
32 | };
33 |
34 | const Content = React.forwardRef((props, ref) => {
35 | const { children, portal = false, className, ...contentProps } = props;
36 | const Wrapper = portal ? RadixSelect.Portal : React.Fragment;
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 | {children}
45 |
46 |
47 |
48 |
49 |
50 | );
51 | });
52 |
53 | type ItemElement = React.ElementRef;
54 | type ItemProps = RadixSelect.SelectItemProps;
55 |
56 | const Item = React.forwardRef((props, ref) => {
57 | const { children, className, ...itemProps } = props;
58 |
59 | return (
60 |
61 |
62 |
63 |
64 | {children}
65 |
66 | );
67 | });
68 |
69 | type SeparatorElement = React.ElementRef;
70 | type SeparatorProps = RadixSelect.SelectSeparatorProps;
71 |
72 | const Separator = React.forwardRef((props, ref) => {
73 | const { className, ...separatorProps } = props;
74 | return ;
75 | });
76 |
77 | type LabelElement = React.ElementRef;
78 | type LabelProps = RadixSelect.SelectLabelProps;
79 |
80 | const Label = React.forwardRef((props, ref) => {
81 | const { className, ...labelProps } = props;
82 | return ;
83 | });
84 |
85 | type GroupElement = React.ElementRef;
86 | type GroupProps = RadixSelect.SelectGroupProps;
87 |
88 | const Group = React.forwardRef((props, ref) => {
89 | const { className, ...groupProps } = props;
90 | return ;
91 | });
92 |
93 | Trigger.displayName = 'Select.Trigger';
94 | Content.displayName = 'Select.Content';
95 | Item.displayName = 'Select.Item';
96 | Separator.displayName = 'Select.Separator';
97 | Group.displayName = 'Select.Group';
98 | Label.displayName = 'Select.Label';
99 |
100 | export type { RootProps, TriggerProps, ContentProps, ItemProps, SeparatorProps, GroupProps, LabelProps };
101 | export { Root, Trigger, Content, Item, Separator, Group, Label, Arrow };
102 |
--------------------------------------------------------------------------------
/figma-kit/src/components/slider/index.ts:
--------------------------------------------------------------------------------
1 | export * from './slider';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/slider/slider.css:
--------------------------------------------------------------------------------
1 | :root,
2 | .light,
3 | .light-theme {
4 | --slider-root-size: var(--space-6);
5 | --slider-track-size: var(--space-4);
6 | --slider-thumb-width: var(--space-4);
7 | --slider-track-border-color: #0000001a;
8 | --slider-track-bg: var(--figma-color-bg-secondary);
9 | --slider-thumb-bg: transparent;
10 | }
11 |
12 | .figma-dark {
13 | --slider-track-border-color: #ffffff1a;
14 | }
15 |
16 | .fp-SliderRoot {
17 | position: relative;
18 | display: flex;
19 | align-items: center;
20 | flex-grow: 1;
21 | outline: 0;
22 | border-color: transparent;
23 | border-style: solid;
24 |
25 | &[data-orientation='horizontal'] {
26 | height: var(--slider-root-size);
27 | border-width: 0 calc(var(--slider-thumb-width) / 2);
28 | }
29 |
30 | &[data-orientation='vertical'] {
31 | flex-direction: column;
32 | width: var(--slider-root-size);
33 | border-width: calc(var(--slider-thumb-width) / 2) 0;
34 | }
35 | }
36 |
37 | .fp-SliderTrack {
38 | box-sizing: border-box;
39 | position: relative;
40 | overflow: hidden;
41 | flex-grow: 1;
42 | background: var(--slider-track-bg);
43 | border-radius: var(--radius-full);
44 | outline: 1px solid var(--slider-track-border-color);
45 | outline-offset: -1px;
46 |
47 | &[data-orientation='horizontal'] {
48 | margin-left: calc((var(--slider-thumb-width) / 2) * -1);
49 | margin-right: calc((var(--slider-thumb-width) / 2) * -1);
50 | height: var(--slider-track-size);
51 | }
52 |
53 | &[data-orientation='vertical'] {
54 | margin-top: calc((var(--slider-thumb-width) / 2) * -1);
55 | margin-bottom: calc((var(--slider-thumb-width) / 2) * -1);
56 | width: var(--slider-track-size);
57 | }
58 | }
59 |
60 | .fp-SliderRange {
61 | position: absolute;
62 | display: block;
63 | background-color: var(--figma-color-bg-brand);
64 | border-radius: var(--radius-full);
65 |
66 | &[data-orientation='horizontal'] {
67 | height: var(--slider-track-size);
68 | margin-left: calc((var(--slider-thumb-width) / 2) * -1);
69 | margin-right: calc((var(--slider-thumb-width) / 2) * -1);
70 | }
71 |
72 | &[data-orientation='vertical'] {
73 | width: var(--slider-track-size);
74 | margin-top: calc((var(--slider-thumb-width) / 2) * -1);
75 | margin-bottom: calc((var(--slider-thumb-width) / 2) * -1);
76 | }
77 |
78 | &[data-disabled] {
79 | background-color: var(--figma-color-bg-disabled);
80 | }
81 | }
82 |
83 | .fp-SliderThumb {
84 | box-sizing: border-box;
85 | display: block;
86 | width: var(--slider-track-size);
87 | height: var(--slider-track-size);
88 | background-color: var(--slider-thumb-bg);
89 | border: 4px solid var(--figma-color-icon-onbrand);
90 | border-radius: var(--radius-full);
91 | box-shadow: var(--elevation-200);
92 | outline: 0;
93 |
94 | &[data-disabled] {
95 | background-color: var(--figma-color-bg-disabled-secondary);
96 | border: 0;
97 | box-shadow: none;
98 | }
99 |
100 | &.fp-SliderThumb-focusVisible:focus-visible {
101 | outline: 1px solid var(--figma-color-border-selected);
102 | }
103 |
104 | &.fp-SliderThumb-baseValue {
105 | --slider-thumb-bg: var(--figma-color-icon-onbrand);
106 | }
107 | }
108 |
109 | .fp-SliderHint {
110 | position: absolute;
111 | display: block;
112 | width: var(--space-1);
113 | height: var(--space-1);
114 | background-color: var(--figma-color-icon-tertiary);
115 | border-radius: var(--radius-full);
116 |
117 | &[data-orientation='horizontal'] {
118 | top: 50%;
119 | transform: translateY(-50%);
120 | }
121 |
122 | &[data-orientation='vertical'] {
123 | left: 50%;
124 | transform: translateX(-50%);
125 | }
126 |
127 | &.fp-SliderHint-baseValue {
128 | background-color: var(--figma-color-icon);
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/figma-kit/src/components/slider/slider.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Slider } from './slider';
3 |
4 | type Story = StoryObj;
5 |
6 | const meta: Meta = {
7 | title: 'Components/Slider',
8 | component: Slider,
9 | parameters: {
10 | controls: {
11 | expanded: true,
12 | },
13 | },
14 | decorators: [
15 | (Story) => {
16 | return (
17 |
18 |
19 |
20 | );
21 | },
22 | ],
23 | argTypes: {
24 | defaultValue: {
25 | //type: 'number[]',
26 | description: 'The initial value of the slider when it is first rendered.',
27 | table: {
28 | type: {
29 | summary: 'number[]',
30 | },
31 | },
32 | control: {
33 | type: 'object',
34 | },
35 | },
36 | value: {
37 | table: {
38 | type: {
39 | summary: 'number[]',
40 | },
41 | },
42 | description: 'The controlled value of the slider.',
43 | },
44 | onValueChange: {
45 | type: 'function',
46 | description: 'Event handler called when the value of the slider changes.',
47 | },
48 | onValueCommit: {
49 | type: 'function',
50 | description: 'Event handler called when the user is done changing the value.',
51 | },
52 | name: {
53 | type: 'string',
54 | description: 'The name of the input field in a form.',
55 | },
56 | disabled: {
57 | type: 'boolean',
58 | description: 'When true, prevents the user from interacting with the slider.',
59 | },
60 | orientation: {
61 | options: ['horizontal', 'vertical'],
62 | control: { type: 'radio' },
63 | table: {
64 | defaultValue: { summary: 'horizontal' },
65 | type: { summary: 'enum' },
66 | },
67 | description: 'The orientation of the slider, either "horizontal" or "vertical".',
68 | },
69 | dir: {
70 | options: ['ltr', 'rtl'],
71 | control: { type: 'radio' },
72 | table: {
73 | defaultValue: { summary: 'ltr' },
74 | type: { summary: 'enum' },
75 | },
76 | description: 'The text direction of the slider, either "ltr" or "rtl".',
77 | },
78 | inverted: {
79 | type: 'boolean',
80 | description: 'When true, inverts the slider values.',
81 | },
82 | min: {
83 | type: 'number',
84 | description: 'The minimum value of the slider.',
85 | },
86 | max: {
87 | type: 'number',
88 | description: 'The maximum value of the slider.',
89 | },
90 | step: {
91 | type: 'number',
92 | description: 'The step value of the slider.',
93 | },
94 | minStepsBetweenThumbs: {
95 | type: 'number',
96 | description: 'The minimum steps between slider thumbs.',
97 | },
98 | range: {
99 | type: 'boolean',
100 | description: 'When true, displays range element.',
101 | },
102 | rangeAnchor: {
103 | type: 'number',
104 | description: 'The starting point of the range. Defaults to min.',
105 | },
106 | },
107 | };
108 |
109 | export default meta;
110 |
111 | export const Horizontal: Story = {
112 | decorators: [
113 | (Story) => {
114 | return (
115 |
116 |
117 |
118 | );
119 | },
120 | ],
121 | args: {},
122 | };
123 |
124 | export const Vertical: Story = {
125 | decorators: [
126 | (Story) => {
127 | return (
128 |
129 |
130 |
131 | );
132 | },
133 | ],
134 | args: {
135 | orientation: 'vertical',
136 | },
137 | };
138 |
139 | export const RangeAnchor: Story = {
140 | decorators: [
141 | (Story) => {
142 | return (
143 |
144 |
145 |
146 | );
147 | },
148 | ],
149 | args: {
150 | defaultValue: [50],
151 | rangeAnchor: 50,
152 | baseValue: 50,
153 | hints: [50],
154 | },
155 | };
156 |
157 | export const Hints: Story = {
158 | decorators: [
159 | (Story) => {
160 | return (
161 |
162 |
163 |
164 | );
165 | },
166 | ],
167 | args: {
168 | baseValue: 400,
169 | defaultValue: [400],
170 | hints: [100, 200, 300, 400, 500, 600, 700, 800, 900],
171 | min: 100,
172 | max: 900,
173 | },
174 | };
175 |
176 | export const Disabled: Story = {
177 | decorators: [
178 | (Story) => {
179 | return (
180 |
181 |
182 |
183 | );
184 | },
185 | ],
186 | args: {
187 | disabled: true,
188 | baseValue: 400,
189 | defaultValue: [400],
190 | hints: [100, 200, 300, 400, 500, 600, 700, 800, 900],
191 | min: 100,
192 | max: 900,
193 | },
194 | };
195 |
--------------------------------------------------------------------------------
/figma-kit/src/components/switch/index.ts:
--------------------------------------------------------------------------------
1 | export * from './switch';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/switch/switch.css:
--------------------------------------------------------------------------------
1 | .fp-switchRoot {
2 | appearance: none;
3 | box-sizing: border-box;
4 | position: relative;
5 | display: block;
6 | width: var(--space-6);
7 | height: var(--space-3);
8 | padding: 0;
9 | background: linear-gradient(90deg, var(--figma-color-bg-brand) 0px 24px, var(--figma-color-icon-tertiary) 24px 48px);
10 | background-repeat: no-repeat;
11 | background-size: 200% 100%;
12 | background-clip: padding-box;
13 | border-radius: var(--radius-full);
14 | border: 0;
15 | transition: background-position 0.1s ease-out;
16 |
17 | &:focus-visible {
18 | outline-offset: 2px;
19 | outline: 2px solid var(--figma-color-border-selected);
20 | }
21 |
22 | &[data-disabled] {
23 | background: var(--figma-color-icon-disabled);
24 | border-color: var(--figma-color-icon-disabled);
25 | }
26 |
27 | &[data-state='unchecked'] {
28 | background-position: -24px;
29 | }
30 |
31 | &[data-state='checked'] {
32 | background-position: 0;
33 | }
34 | }
35 |
36 | .fp-switchThumb {
37 | box-sizing: border-box;
38 | position: absolute;
39 | top: 1px;
40 | left: 1px;
41 | height: var(--space-2_5);
42 | width: var(--space-2_5);
43 | border-radius: var(--radius-full);
44 | background: var(--figma-color-icon-onbrand);
45 | transition: all 0.1s ease-out;
46 |
47 | &[data-disabled] {
48 | background-color: var(--figma-color-bg);
49 | border-color: var(--figma-color-bg);
50 | }
51 |
52 | &[data-state='unchecked'] {
53 | left: 1px;
54 | }
55 |
56 | &[data-state='checked'] {
57 | left: calc(100% - var(--space-2_5) - 1px);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/figma-kit/src/components/switch/switch.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Switch } from './switch';
3 |
4 | type Story = StoryObj;
5 |
6 | const meta = {
7 | component: Switch,
8 | title: 'Components/Switch',
9 | parameters: {
10 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/switch',
11 | radixComponentName: 'Switch',
12 | controls: { expanded: true },
13 | },
14 | argTypes: {
15 | defaultChecked: {
16 | type: 'boolean',
17 | description:
18 | 'The state of the switch when it is initially rendered. Use when you do not need to control its state.',
19 | },
20 | checked: {
21 | type: 'boolean',
22 | description: 'The controlled state of the switch. Must be used in conjunction with onCheckedChange.',
23 | },
24 | onCheckedChange: {
25 | type: 'function',
26 | description: 'Event handler called when the checked state of the switch changes.',
27 | },
28 | disabled: {
29 | type: 'boolean',
30 | description: 'When true, prevents the user from interacting with the switch.',
31 | },
32 | required: {
33 | type: 'boolean',
34 | description: 'When true, indicates that an input field must be filled out before submitting the form.',
35 | },
36 | name: {
37 | type: 'string',
38 | description: 'The name of the input field in a form.',
39 | },
40 | value: {
41 | type: 'string',
42 | description: 'The value of the switch input when submitted in a form.',
43 | },
44 | },
45 | } satisfies Meta;
46 |
47 | export default meta;
48 |
49 | const uncheckedCode = `
50 |
51 | `;
52 |
53 | const checkedCode = `
54 |
55 | `;
56 |
57 | const disabledCode = `
58 |
59 | `;
60 |
61 | // Limitation - no Mixed state support
62 | export const Unchecked: Story = {
63 | parameters: {
64 | storySource: {
65 | source: uncheckedCode,
66 | },
67 | },
68 | args: {},
69 | };
70 |
71 | export const Checked: Story = {
72 | parameters: {
73 | storySource: {
74 | source: checkedCode,
75 | },
76 | },
77 | args: {
78 | checked: true,
79 | },
80 | };
81 |
82 | export const Disabled: Story = {
83 | parameters: {
84 | storySource: {
85 | source: disabledCode,
86 | },
87 | },
88 | args: {
89 | checked: true,
90 | disabled: true,
91 | },
92 | };
93 |
--------------------------------------------------------------------------------
/figma-kit/src/components/switch/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as RadixSwitch from '@radix-ui/react-switch';
2 | import React from 'react';
3 | import { cx } from 'class-variance-authority';
4 |
5 | type SwitchElement = React.ElementRef;
6 | type SwitchProps = Omit;
7 |
8 | const Switch = React.forwardRef((props, ref) => {
9 | const { className, ...switchProps } = props;
10 | return (
11 |
12 |
13 |
14 | );
15 | });
16 |
17 | Switch.displayName = 'Switch';
18 |
19 | export type { SwitchProps };
20 | export { Switch };
21 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tabs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tabs';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tabs/tabs.css:
--------------------------------------------------------------------------------
1 | .fp-TabsList {
2 | display: flex;
3 | overflow-y: auto;
4 | gap: var(--space-2);
5 | }
6 |
7 | .fp-TabsTrigger {
8 | all: unset;
9 | box-sizing: border-box;
10 | display: flex;
11 | align-items: center;
12 | justify-content: center;
13 | flex-shrink: 0;
14 | height: var(--space-6);
15 | padding: 0 var(--space-2);
16 | font-family: var(--font-family-default);
17 | font-size: var(--font-size-default);
18 | font-weight: var(--font-weight-default);
19 | letter-spacing: var(--letter-spacing-default);
20 | line-height: var(--line-height-default);
21 | white-space: nowrap;
22 |
23 | &:where([data-state='inactive']) {
24 | color: var(--figma-color-text-secondary);
25 | --color-icon: var(--figma-color-icon-secondary);
26 | }
27 |
28 | &:where([data-state='active']) {
29 | font-weight: var(--font-weight-strong);
30 | color: var(--figma-color-text);
31 | --color-icon: var(--figma-color-icon);
32 | background: var(--figma-color-bg-secondary);
33 | border-radius: var(--radius-medium);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tabs/tabs.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { useState } from 'react';
3 | import { IconButton } from '@components/icon-button';
4 | import { StylesIcon } from '@components/icons';
5 | import { Text } from '@components/text';
6 | import * as Popover from '../popover';
7 | import * as Tabs from './tabs';
8 |
9 | type Story = StoryObj;
10 |
11 | const meta: Meta = {
12 | title: 'Components/Tabs',
13 | component: Tabs.Root,
14 | parameters: {
15 | radixUrl: 'https://www.radix-ui.com/primitives/docs/components/tabs',
16 | radixComponentName: 'Tabs',
17 | },
18 | };
19 |
20 | export default meta;
21 |
22 | export const Story: Story = {
23 | render() {
24 | return (
25 |
26 |
27 | Custom
28 | Libraries
29 | Carburetors
30 |
31 |
32 | Custom Content
33 |
34 |
35 | Libraries Content
36 |
37 |
38 | Carburetors Content
39 |
40 |
41 | );
42 | },
43 | };
44 |
45 | export const WithinPopover = () => {
46 | const [activeTab, setActiveTab] = useState('custom');
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Custom
60 | Libraries
61 | Carburetors
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | Custom Content
71 |
72 |
73 | Libraries Content
74 |
75 |
76 | Carburetors Content
77 |
78 |
79 |
80 |
81 |
82 | );
83 | };
84 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tabs/tabs.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import * as RadixTabs from '@radix-ui/react-tabs';
3 | import { cx } from 'class-variance-authority';
4 | import { composeRefs } from '@lib/react/use-compose-refs';
5 |
6 | type RootElement = React.ElementRef;
7 | type RootProps = RadixTabs.TabsProps;
8 |
9 | const Root = React.forwardRef((props, ref) => {
10 | const { className, ...rootProps } = props;
11 | return ;
12 | });
13 |
14 | type ListElement = React.ElementRef;
15 | type ListProps = RadixTabs.TabsListProps;
16 |
17 | const List = React.forwardRef((props, ref) => {
18 | const { className, ...listProps } = props;
19 | return ;
20 | });
21 |
22 | type TriggerElement = React.ElementRef;
23 | type TriggerProps = RadixTabs.TabsTriggerProps;
24 |
25 | const Trigger = React.forwardRef((props, forwardedRef) => {
26 | const { className, ...triggerProps } = props;
27 | const triggerRef = useFixedTriggerWidth();
28 | const ref = composeRefs(forwardedRef, triggerRef);
29 |
30 | return ;
31 | });
32 |
33 | /**
34 | * Hardcode the initial trigger width onto the element to prevent layout shifts when the font-weight changes with state.
35 | * An alternative solution would be using overlaying pseudo-elements in CSS, but this would complicate the API for the consumer,
36 | * requiring them to manually specify the label and somehow slot icons when used.
37 | * Note:
38 | * This won't handle the unlikely case of the trigger label changing after it's been rendered.
39 | */
40 | function useFixedTriggerWidth() {
41 | return useCallback((node: TriggerElement) => {
42 | if (node !== null) {
43 | node.style.width = node.getBoundingClientRect().width + 'px';
44 | }
45 | }, []);
46 | }
47 |
48 | type ContentElement = React.ElementRef;
49 | type ContentProps = RadixTabs.TabsContentProps;
50 |
51 | const Content = React.forwardRef((props, ref) => {
52 | const { className, ...contentProps } = props;
53 | return ;
54 | });
55 |
56 | Root.displayName = 'Tabs.Root';
57 | List.displayName = 'Tabs.List';
58 | Trigger.displayName = 'Tabs.Trigger';
59 | Content.displayName = 'Tabs.Content';
60 |
61 | export type { RootProps, ListProps, TriggerProps, ContentProps };
62 | export { Root, List, Trigger, Content };
63 |
--------------------------------------------------------------------------------
/figma-kit/src/components/text/index.ts:
--------------------------------------------------------------------------------
1 | export * from './text';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/text/text.css:
--------------------------------------------------------------------------------
1 | .fp-Text {
2 | margin: 0;
3 | font-family: var(--font-family-default);
4 | color: var(--figma-color-text);
5 | font-size: var(--font-size, var(--font-size-default));
6 | line-height: var(--line-height, var(--line-height-3));
7 | letter-spacing: var(--letter-spacing, var(--letter-spacing-3));
8 | font-weight: var(--font-weight, var(--font-weight-default));
9 |
10 | /**
11 | Use custom properties to avoid specificy issues when nesting Text.
12 | Nested Text components inherit properties of the parent Text, unless customized.
13 | At the same time, Text falls back to default values without requiring :root level styling. */
14 | &:where(.fp-size-small) {
15 | --font-size: var(--font-size-1);
16 | --line-height: var(--line-height-1);
17 | --letter-spacing: var(--letter-spacing-1);
18 | }
19 |
20 | &:where(.fp-size-medium) {
21 | --font-size: var(--font-size-3);
22 | --line-height: var(--line-height-3);
23 | --letter-spacing: --letter-spacing-3;
24 | }
25 |
26 | &:where(.fp-size-large) {
27 | --font-size: var(--font-size-5);
28 | --line-height: var(--line-height-5);
29 | --letter-spacing: var(--letter-spacing-5);
30 | }
31 |
32 | &:where(.fp-weight-default) {
33 | --font-weight: var(--font-weight-default);
34 | }
35 |
36 | &:where(.fp-weight-strong) {
37 | --font-weight: var(--font-weight-strong);
38 | }
39 |
40 | &:where(.fp-align-start) {
41 | text-align: start;
42 | }
43 |
44 | &:where(.fp-align-center) {
45 | text-align: center;
46 | }
47 |
48 | &:where(.fp-align-end) {
49 | text-align: end;
50 | }
51 |
52 | &:where(.fp-block) {
53 | display: block;
54 | }
55 |
56 | & strong {
57 | font-weight: var(--font-weight-strong);
58 | }
59 |
60 | & code {
61 | font-family: var(--font-family-monospace);
62 | font-size: var(var(--font-size-2));
63 | background-color: var(--figma-color-bg-brand-tertiary);
64 | padding: 0.05rem 0.15rem;
65 | border-radius: var(--radius-extra-small);
66 | }
67 |
68 | mark {
69 | background-color: var(--figma-color-bg-onselected);
70 | }
71 | }
72 |
73 | .fp-Link {
74 | color: var(--figma-color-text-brand);
75 | text-decoration: none;
76 |
77 | &:focus-visible {
78 | outline: 1px solid var(--figma-color-border-selected-strong);
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/figma-kit/src/components/text/text.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import React from 'react';
3 | import * as Typography from './text';
4 |
5 | const meta: Meta = {
6 | title: 'Components/Text',
7 | component: Typography.Text,
8 | decorators: [
9 | (Story) => {
10 | return (
11 |
12 |
13 |
14 | );
15 | },
16 | ],
17 | argTypes: {
18 | size: {
19 | control: 'select',
20 | options: ['small', 'medium', 'large'],
21 | table: {
22 | defaultValue: { summary: '3' },
23 | },
24 | },
25 | weight: {
26 | control: 'radio',
27 | options: ['default', 'strong'],
28 | table: {
29 | defaultValue: { summary: 'default' },
30 | },
31 | },
32 | align: {
33 | control: 'radio',
34 | options: ['start', 'center', 'end'],
35 | table: {
36 | defaultValue: { summary: 'start' },
37 | },
38 | },
39 | block: {
40 | control: 'boolean',
41 | table: {
42 | defaultValue: { summary: 'false' },
43 | },
44 | },
45 | asChild: {
46 | table: {
47 | type: { summary: 'boolean' },
48 | defaultValue: { summary: 'false' },
49 | },
50 | },
51 | },
52 | };
53 |
54 | export default meta;
55 |
56 | const basicTextCode = `
57 |
58 | `;
59 |
60 | export const BasicText: StoryObj = {
61 | parameters: {
62 | storySource: {
63 | source: basicTextCode,
64 | },
65 | },
66 | args: {
67 | size: 'medium',
68 | weight: 'default',
69 | align: 'start',
70 | block: false,
71 | children: 'Basic text',
72 | },
73 | };
74 |
75 | const inlineSemanticsCode = `
76 |
77 | This is strong text to highlight important points. This is emphasized text to indicate
78 | subtle importance. Use inline code
for code snippets. This is marked text to draw
79 | attention. This is a link to Figma's documentation.
80 |
81 | `;
82 |
83 | export const InlineSemantics: StoryObj = {
84 | parameters: {
85 | storySource: {
86 | source: inlineSemanticsCode,
87 | },
88 | },
89 | args: {
90 | children: (
91 | <>
92 | This is strong text to highlight important points. This is emphasized text to indicate
93 | subtle importance. Use inline code
for code snippets. This is marked text to draw
94 | attention. This is a link to{' '}
95 | Figma's documentation .
96 | >
97 | ),
98 | },
99 | };
100 |
101 | const linkCode = `
102 | Link;
103 | `;
104 |
105 | export const Link: StoryObj = {
106 | parameters: {
107 | storySource: {
108 | source: linkCode,
109 | },
110 | },
111 | render() {
112 | return Link ;
113 | },
114 | };
115 |
--------------------------------------------------------------------------------
/figma-kit/src/components/text/text.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { VariantProps } from 'class-variance-authority';
3 | import { cx, cva } from 'class-variance-authority';
4 | import { Slot } from '@radix-ui/react-slot';
5 |
6 | const text = cva('fp-Text', {
7 | variants: {
8 | size: {
9 | small: 'fp-size-small',
10 | medium: 'fp-size-medium',
11 | large: 'fp-size-large',
12 | },
13 | weight: {
14 | default: 'fp-weight-default',
15 | strong: 'fp-weight-strong',
16 | },
17 | align: {
18 | start: 'fp-align-start',
19 | center: 'fp-align-center',
20 | end: 'fp-align-end',
21 | },
22 | block: {
23 | true: 'fp-block',
24 | },
25 | },
26 | });
27 |
28 | type TextElement = React.ElementRef<'span'>;
29 | type TextProps = React.ComponentPropsWithoutRef<'span'> &
30 | VariantProps & {
31 | asChild?: boolean;
32 | };
33 |
34 | const Text = React.forwardRef((props, ref) => {
35 | const { asChild, className, size, weight, align, block, ...textProps } = props;
36 | const Element = asChild ? Slot : 'span';
37 |
38 | return (
39 |
50 | );
51 | });
52 |
53 | Text.displayName = 'Text';
54 |
55 | type LabelElement = React.ElementRef<'label'>;
56 | type LabelProps = React.ComponentPropsWithoutRef<'label'> & VariantProps;
57 |
58 | const Label = React.forwardRef((props, ref) => {
59 | const { className, size, weight, align, block, ...labelProps } = props;
60 |
61 | return (
62 |
73 | );
74 | });
75 |
76 | Label.displayName = 'Label';
77 |
78 | type ParagraphElement = React.ElementRef<'p'>;
79 | type ParagraphProps = React.ComponentPropsWithoutRef<'p'> & VariantProps;
80 |
81 | const Paragraph = React.forwardRef((props, ref) => {
82 | const { className, size, weight, align, block, ...paragraphProps } = props;
83 |
84 | return (
85 |
96 | );
97 | });
98 |
99 | type LinkElement = React.ElementRef<'a'>;
100 | type LinkProps = React.ComponentPropsWithoutRef<'a'> &
101 | VariantProps & {
102 | asChild?: boolean;
103 | };
104 |
105 | Paragraph.displayName = 'Paragraph';
106 |
107 | const Link = React.forwardRef((props, ref) => {
108 | const { asChild, className, size, weight, align, block, ...linkProps } = props;
109 | const Element = asChild ? Slot : 'a';
110 |
111 | return (
112 |
126 | );
127 | });
128 |
129 | Link.displayName = 'Link';
130 |
131 | export type { TextProps, LabelProps, ParagraphProps, LinkProps };
132 | export { Text, Label, Paragraph, Link };
133 |
--------------------------------------------------------------------------------
/figma-kit/src/components/textarea/index.ts:
--------------------------------------------------------------------------------
1 | export * from './textarea';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/textarea/textarea.css:
--------------------------------------------------------------------------------
1 | .fp-textarea {
2 | all: unset;
3 | box-sizing: border-box;
4 | display: block;
5 | width: 100%;
6 | height: var(--space-6);
7 | padding: var(--space-1) var(--space-2);
8 | background-color: var(--figma-color-bg-secondary);
9 | border-radius: var(--radius-medium);
10 | font-family: var(--font-family-default);
11 | font-size: var(--font-size-default);
12 | font-weight: var(--font-weight-default);
13 | letter-spacing: var(--letter-spacing-default);
14 | line-height: var(--line-height-default);
15 | color: var(--figma-color-text);
16 | outline-width: 1px;
17 | outline-style: solid;
18 | outline-offset: -1px;
19 | outline-color: transparent;
20 | resize: none;
21 | word-break: break-word;
22 |
23 | &:hover:where(:not(:disabled, :focus)) {
24 | outline-color: var(--figma-color-border);
25 | }
26 |
27 | &:focus {
28 | outline-color: var(--figma-color-border-selected);
29 | }
30 |
31 | &:disabled {
32 | background-color: var(--figma-color-bg);
33 | outline-color: var(--figma-color-border-disabled);
34 | color: var(--figma-color-text-disabled);
35 | }
36 |
37 | &::placeholder {
38 | color: var(--figma-color-text-tertiary);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/figma-kit/src/components/textarea/textarea.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Textarea } from './textarea';
3 |
4 | type Story = StoryObj;
5 |
6 | const meta: Meta = {
7 | component: Textarea,
8 | title: 'Components/Textarea',
9 | decorators: [
10 | (Story) => {
11 | return (
12 |
13 |
14 |
15 | );
16 | },
17 | ],
18 | argTypes: {
19 | disabled: {
20 | type: 'boolean',
21 | },
22 | minRows: {
23 | type: 'number',
24 | },
25 | },
26 | parameters: {
27 | docs: {
28 | description: {
29 | component: 'Textarea that grows vertically to accommodate content.',
30 | },
31 | },
32 | },
33 | };
34 |
35 | export default meta;
36 |
37 | export const Default: Story = {
38 | args: {
39 | placeholder: 'Textarea that grows vertically to accommodate content.',
40 | },
41 | };
42 |
--------------------------------------------------------------------------------
/figma-kit/src/components/textarea/textarea.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { TextareaAutosizeProps } from 'react-textarea-autosize';
3 | import TextareaAutoSize from 'react-textarea-autosize';
4 | import { cx } from 'class-variance-authority';
5 |
6 | type TextareaElement = React.ElementRef<'textarea'>;
7 | type TextareaProps = TextareaAutosizeProps;
8 |
9 | const Textarea = React.forwardRef((props, ref) => {
10 | const { className, ...textareaProps } = props;
11 | return ;
12 | });
13 |
14 | Textarea.displayName = 'Textarea';
15 |
16 | export type { TextareaProps };
17 | export { Textarea };
18 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tooltip/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tooltip';
2 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tooltip/tooltip.css:
--------------------------------------------------------------------------------
1 | .fp-tooltip {
2 | box-sizing: border-box;
3 | padding: var(--space-1) var(--space-2);
4 | background-color: var(--color-bg-tooltip);
5 | font-family: var(--font-family-default);
6 | font-size: var(--font-size-default);
7 | font-weight: var(--font-weight-default);
8 | letter-spacing: var(--letter-spacing-default);
9 | line-height: var(--line-height-default);
10 | min-height: var(--space-6);
11 | color: var(--color-text-tooltip);
12 | white-space: pre-wrap;
13 | word-break: break-word;
14 | border-radius: var(--radius-medium);
15 | box-shadow: var(--elevation-300, 0 2px 7px rgba(0, 0, 0, 0.15));
16 | }
17 |
18 | .fp-tooltip-arrow {
19 | fill: var(--color-bg-tooltip);
20 | width: var(--space-3_5);
21 | height: var(--space-1_5);
22 | position: relative;
23 | bottom: 1px;
24 | }
25 |
--------------------------------------------------------------------------------
/figma-kit/src/components/tooltip/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as RadixTooltip from '@radix-ui/react-tooltip';
3 | import { cx } from 'class-variance-authority';
4 |
5 | const { TooltipProvider } = RadixTooltip;
6 | type TooltipProviderProps = RadixTooltip.TooltipProviderProps;
7 |
8 | type ContentElement = React.ElementRef;
9 | type ContentProps = RadixTooltip.TooltipContentProps;
10 |
11 | const Content = React.forwardRef((props, ref) => {
12 | const { className, ...contentProps } = props;
13 | return ;
14 | });
15 |
16 | type ArrowProps = RadixTooltip.TooltipArrowProps;
17 |
18 | const Arrow = (props: ArrowProps) => {
19 | const { className, ...arrowProps } = props;
20 | return ;
21 | };
22 |
23 | type TooltipElement = React.ElementRef;
24 | type TooltipProps = Omit & {
25 | children: React.ReactNode;
26 | container?: HTMLElement | null | undefined;
27 | content: React.ReactNode;
28 | };
29 |
30 | const Tooltip = React.forwardRef((props, ref) => {
31 | const {
32 | defaultOpen,
33 | open,
34 | onOpenChange,
35 | delayDuration,
36 | disableHoverableContent,
37 | container,
38 | forceMount,
39 | children,
40 | content,
41 | ...contentProps
42 | } = props;
43 | const rootProps = { open, defaultOpen, onOpenChange, delayDuration, disableHoverableContent };
44 |
45 | return (
46 |
47 | {children}
48 |
49 |
50 | {content}
51 |
52 |
53 |
54 |
55 | );
56 | });
57 |
58 | Tooltip.displayName = 'Tooltip';
59 |
60 | export { TooltipProvider, Tooltip };
61 | export type { TooltipProps, TooltipProviderProps };
62 |
--------------------------------------------------------------------------------
/figma-kit/src/components/value-field/index.ts:
--------------------------------------------------------------------------------
1 | export { Multi, Root, Label } from './value-field-elements';
2 | export { Numeric } from './value-field-numeric';
3 | export { Hex } from './value-field-hex';
4 | export type { MultiProps, RootProps, LabelProps } from './value-field-elements';
5 | export type { NumericProps } from './value-field-numeric';
6 | export type { HexProps } from './value-field-hex';
7 |
--------------------------------------------------------------------------------
/figma-kit/src/components/value-field/types.ts:
--------------------------------------------------------------------------------
1 | type ParserResult =
2 | | {
3 | valid: true;
4 | value: V;
5 | }
6 | | {
7 | valid: false;
8 | };
9 |
10 | type IncrementTargets = Record | null;
11 |
12 | type Formatter = {
13 | parse: (input: string, value: V) => ParserResult;
14 | format: (value: V) => string;
15 | incrementBy?: (value: V, amount: number, incrementTargets: IncrementTargets) => V;
16 | getIncrementTargets?: (element: HTMLInputElement) => IncrementTargets;
17 | getIncrementSelection?: (incrementTargets: IncrementTargets) => [start: number, end: number];
18 | };
19 |
20 | export type { IncrementTargets, Formatter, ParserResult };
21 |
--------------------------------------------------------------------------------
/figma-kit/src/components/value-field/value-field-base.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from 'react';
2 | import { cx } from 'class-variance-authority';
3 | import mergeProps from 'merge-props';
4 | import type { InputProps } from '@components/input';
5 | import { Input } from '@components/input';
6 | import { useComposedRefs } from '@lib/react/use-compose-refs';
7 | import { DEFAULT_BIG_NUDGE, DEFAULT_SMALL_NUDGE } from '@lib/constants';
8 | import { useValueFieldContext } from '@components/value-field/value-field-elements';
9 | import type { Formatter } from './types';
10 |
11 | type BaseProps = Omit & {
12 | value: V;
13 | onChange: (value: V) => void;
14 | inputRef?: React.Ref;
15 | smallNudge?: number;
16 | bigNudge?: number;
17 | formatter: Formatter;
18 | };
19 |
20 | const Base = (props: BaseProps) => {
21 | const {
22 | className,
23 | inputRef: forwardedRef,
24 | value: valueProp,
25 | onChange,
26 | smallNudge = DEFAULT_SMALL_NUDGE,
27 | bigNudge = DEFAULT_BIG_NUDGE,
28 | formatter,
29 | disabled,
30 | ...fieldProps
31 | } = props;
32 | const ref = useRef(null);
33 | const composedRef = useComposedRefs(forwardedRef, ref);
34 | const [editingValue, setEditingValue] = useState(null);
35 | const inputValue = editingValue ?? formatter.format(valueProp);
36 | const context = useValueFieldContext('ValueField');
37 |
38 | const submit = (input: string) => {
39 | const parserResult = formatter.parse(input, valueProp);
40 |
41 | if (input.length === 0 || !parserResult.valid || parserResult.value === valueProp) {
42 | return revert();
43 | }
44 |
45 | setEditingValue(null);
46 | onChange(parserResult.value);
47 | };
48 |
49 | const revert = () => {
50 | setEditingValue(null);
51 | };
52 |
53 | const handleChange = (event: React.ChangeEvent) => {
54 | setEditingValue(event.currentTarget.value);
55 | };
56 |
57 | const handleKeyDown = (event: React.KeyboardEvent) => {
58 | const inputElement = event.currentTarget;
59 |
60 | if (event.key === 'Enter') {
61 | event.preventDefault();
62 | inputElement.blur();
63 | }
64 |
65 | if (event.key === 'Escape') {
66 | event.preventDefault();
67 | revert();
68 | // TODO: Needs better solution
69 | // Delegate selection to the next tick to make sure it happens after value is set.
70 | requestAnimationFrame(() => {
71 | inputElement.blur();
72 | });
73 | }
74 |
75 | if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
76 | if (!formatter.incrementBy) {
77 | return;
78 | }
79 |
80 | event.preventDefault();
81 | const parseResult = formatter.parse(inputElement.value, valueProp);
82 | const oldValue = parseResult.valid ? parseResult.value : valueProp;
83 | const nudge = event.shiftKey ? bigNudge : smallNudge;
84 | const amount = event.key === 'ArrowUp' ? nudge : -nudge;
85 | const incrementTargets = formatter.getIncrementTargets ? formatter.getIncrementTargets(inputElement) : null;
86 | const newValue = formatter.incrementBy(oldValue, amount, incrementTargets);
87 | submit(formatter.format(newValue));
88 | // TODO: Needs better solution
89 | // Delegate selection to the next tick to make sure it happens after value is set.
90 | requestAnimationFrame(() => {
91 | if (incrementTargets && formatter.getIncrementSelection) {
92 | const [start, end] = formatter.getIncrementSelection(incrementTargets);
93 | inputElement.setSelectionRange(start, end);
94 | } else {
95 | inputElement.select();
96 | }
97 | });
98 | }
99 | };
100 |
101 | const handleBlur = (event: React.FocusEvent) => {
102 | submit(event.currentTarget.value);
103 | };
104 |
105 | return (
106 |
118 | );
119 | };
120 |
121 | Base.displayName = 'ValueField.Base';
122 |
123 | export type { BaseProps };
124 | export { Base };
125 |
--------------------------------------------------------------------------------
/figma-kit/src/components/value-field/value-field-elements.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { cx } from 'class-variance-authority';
3 | import { createContext } from '@lib/react/create-context';
4 |
5 | const [ValueFieldProvider, useValueFieldContext] = createContext<{ disabled?: boolean } | null>(
6 | 'ValueFieldProvider',
7 | null
8 | );
9 |
10 | type RootElement = React.ElementRef<'label'>;
11 | type RootProps = React.ComponentPropsWithoutRef<'label'> & {
12 | disabled?: boolean;
13 | };
14 |
15 | const Root = React.forwardRef((props, ref) => {
16 | const { className, disabled, ...rootProps } = props;
17 | const context = useValueFieldContext('Root');
18 |
19 | return (
20 |
21 |
27 |
28 | );
29 | });
30 |
31 | type MultiElement = React.ElementRef<'div'>;
32 | type MultiProps = React.ComponentPropsWithoutRef<'div'> & {
33 | disabled?: boolean;
34 | };
35 |
36 | const Multi = React.forwardRef((props, ref) => {
37 | const { className, disabled, ...multiProps } = props;
38 |
39 | return (
40 |
41 |
47 |
48 | );
49 | });
50 |
51 | type LabelElement = React.ElementRef<'span'>;
52 | type LabelProps = React.ComponentPropsWithoutRef<'span'>;
53 |
54 | const Label = React.forwardRef((props, ref) => {
55 | const { className, ...labelProps } = props;
56 | const context = useValueFieldContext('Root');
57 |
58 | return (
59 |
65 | );
66 | });
67 |
68 | Root.displayName = 'ValueField.Root';
69 | Label.displayName = 'ValueField.Label';
70 | Multi.displayName = 'ValueField.Multi';
71 |
72 | export type { RootProps, LabelProps, MultiProps };
73 | export { Root, Label, Multi, useValueFieldContext };
74 |
--------------------------------------------------------------------------------
/figma-kit/src/components/value-field/value-field.css:
--------------------------------------------------------------------------------
1 | .fp-ValueFieldRoot {
2 | box-sizing: border-box;
3 | display: flex;
4 | align-items: center;
5 | border-radius: var(--radius-medium);
6 | padding: 0 1px;
7 | background-color: var(--figma-color-bg-secondary);
8 | font-family: var(--font-family-default);
9 | font-size: var(--font-size-default);
10 | font-weight: var(--font-weight-default);
11 | letter-spacing: var(--letter-spacing-default);
12 | line-height: var(--line-height-default);
13 | outline-width: 1px;
14 | outline-style: solid;
15 | outline-offset: -1px;
16 | outline-color: transparent;
17 |
18 | &:hover:where(:not([data-disabled])) {
19 | outline-color: var(--figma-color-border);
20 | }
21 |
22 | &:focus-within {
23 | outline-color: var(--figma-color-border-selected);
24 | }
25 |
26 | &[data-disabled] {
27 | background-color: var(--figma-color-bg);
28 | outline-color: var(--figma-color-border-disabled);
29 | color: var(--figma-color-text-disabled);
30 | }
31 |
32 | .fp-ValueFieldBase {
33 | cursor: default;
34 | outline: 0;
35 | }
36 | }
37 |
38 | .fp-ValueFieldLabel {
39 | display: flex;
40 | align-items: center;
41 | justify-content: center;
42 | align-self: stretch;
43 | flex-shrink: 0;
44 | color: var(--figma-color-text-secondary);
45 | --color-icon: var(--figma-color-icon-secondary);
46 | height: var(--space-6);
47 | width: var(--space-6);
48 |
49 | /* Usually used for suffixes.
50 | TODO: Replace with a variant. */
51 | &:last-child {
52 | width: var(--space-5);
53 | }
54 |
55 | & + .fp-ValueFieldBase {
56 | padding-left: 0;
57 | }
58 |
59 | &[data-disabled] {
60 | color: var(--figma-color-text-disabled);
61 | --color-icon: var(--figma-color-icon-disabled);
62 | }
63 | }
64 |
65 | .fp-ValueFieldMulti {
66 | display: flex;
67 | border-radius: var(--radius-medium);
68 | outline-offset: -1px;
69 | box-sizing: border-box;
70 | border: 1px solid transparent;
71 | height: var(--space-6);
72 | background-color: var(--figma-color-bg-secondary);
73 |
74 | &:hover:where(:not([data-disabled])) {
75 | border: 1px solid var(--figma-color-border);
76 | }
77 |
78 | &:focus-within {
79 | outline: 1px solid var(--figma-color-border-selected);
80 | }
81 |
82 | &[data-disabled] {
83 | border: 1px solid var(--figma-color-border-disabled);
84 | background-color: var(--figma-color-bg);
85 | }
86 |
87 | .fp-ValueFieldRoot {
88 | height: var(--space-6);
89 | flex-grow: 1;
90 | outline: 0;
91 | border-radius: 0;
92 | background-color: transparent;
93 | margin-top: -1px;
94 |
95 | &:not(:first-child) {
96 | border-left: 1px solid var(--figma-color-bg);
97 | }
98 |
99 | &:first-child {
100 | border-top-left-radius: var(--radius-medium);
101 | border-bottom-left-radius: var(--radius-medium);
102 | }
103 |
104 | &:last-child {
105 | border-top-right-radius: var(--radius-medium);
106 | border-bottom-right-radius: var(--radius-medium);
107 | }
108 | }
109 |
110 | .fp-ValueFieldBase {
111 | background-color: transparent;
112 | padding-left: calc(var(--space-2) - 1px);
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/figma-kit/src/components/value-field/value-field.mdx:
--------------------------------------------------------------------------------
1 | import {
2 | Meta,
3 | Story,
4 | Source,
5 | CodeOrSourceMdx,
6 | Canvas,
7 | ArgTypes,
8 | Controls,
9 | Description
10 | } from '@storybook/blocks';
11 | import * as ValueFieldStories from './value-field.stories.tsx'
12 |
13 |
14 |
15 | # Value Field
16 | #### Resilient input for controlling various properties
17 |
18 |
19 | ## Overview
20 |
21 | The Value Field, despite its simple appearance, is one of the most complex components in Figma.
22 | In many ways, it was the main reason this library was conceived in the first place.
23 |
24 | The component allows users to adjust various properties of different elements such as color,
25 | width, height, angle, font size, and more. It is designed to be extremely resilient to incorrect input.
26 |
27 | ## Building blocks
28 | The Value Field doesn't ship as a set of ready-made components as they appear in Figma. Instead,
29 | it provides the necessary primitives to create the variations needed for your plugin. The
30 | following sections describe these primitives in detail.
31 |
32 |
33 | ## Numeric
34 |
35 | Basic numeric input. Most value fields (opacity, color channels, width, height) can be built
36 | using this input in combination with other elements from ValueField.
37 |
38 | Similar to Figma's property fields, and unlike regular form input, Numeric field commits the
39 | value by calling `onChange` on Enter or `blur`, and reverts on `Escape`.
40 |
41 | Numeric input is a controlled component, meaning `value` and `onChange` props are required for
42 | it to function.
43 |
44 |
45 |
46 |
47 |
48 | ## Icons and labels
49 |
50 | Use a combination of `Root` and `Label` to create value fields with icons or labels
51 |
52 | ### With label
53 |
54 |
55 | ### With icon
56 |
57 |
58 |
59 | ## Multi-value inputs
60 |
61 | Creates multi-value inputs by using `Multi` wrapper. The following example replicates Figma's
62 | RGBA input by using 4 numeric inputs in a `Multi`.
63 |
64 |
65 | Notice the usage of `targetRange`, `min`, and `max` props in the code above. Figma natively stores
66 | colors in `RGBA` format with channel values normalized to 0-1 range. Using `targetRange`
67 | of 0-255 native values are converted to the correct display range internally.
68 |
69 | ## Hex
70 |
71 | This component replicates the hex input functionality in Figma. It accepts user input and
72 | adjusts to the nearest valid hex value. Values Users can increment and decrement values using the
73 | keyboard, either for the entire input or individual color channels.
74 |
75 | The hex input can be used can be used in conjunction with numeric input to create a primary
76 | color input similar to the one in Figma.
77 |
78 |
79 |
80 |
81 | ## Hex with alpha
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/figma-kit/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as Tabs from './components/tabs';
2 | import * as SegmentedControl from './components/segmented-control';
3 | import * as RadioGroup from './components/radio-group';
4 | import * as Checkbox from './components/checkbox';
5 | import * as Select from './components/select';
6 | import * as DropdownMenu from './components/dropdown-menu';
7 | import * as ContextMenu from './components/context-menu';
8 | import * as Popover from './components/popover';
9 | import * as Dialog from './components/dialog';
10 | import * as AlertDialog from './components/alert-dialog';
11 | import * as ValueField from './components/value-field';
12 | import * as ColorPicker from './components/color-picker';
13 | import * as Collapsible from './components/collapsible';
14 |
15 | export { Text, Label, Paragraph, Link } from './components/text';
16 | export { Flex } from './components/flex';
17 | export { Button } from './components/button';
18 | export { IconButton } from './components/icon-button';
19 | export { Input } from './components/input';
20 | export { Textarea } from './components/textarea';
21 | export { Slider } from './components/slider';
22 | export { Switch } from './components/switch';
23 | export { Tooltip, TooltipProvider } from './components/tooltip';
24 | export { createIcon } from './components/icon';
25 | export { Tabs };
26 | export { SegmentedControl };
27 | export { RadioGroup };
28 | export { Checkbox };
29 | export { Select };
30 | export { DropdownMenu };
31 | export { ContextMenu };
32 | export { Popover };
33 | export { Dialog };
34 | export { AlertDialog };
35 | export { ValueField };
36 | export { ColorPicker };
37 | export { Collapsible };
38 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/color.ts:
--------------------------------------------------------------------------------
1 | import { round } from 'remeda';
2 |
3 | type WithAlpha = T & { a: number };
4 |
5 | type RGB = { r: number; g: number; b: number };
6 | type HSV = { h: number; s: number; v: number };
7 | type HSL = { h: number; s: number; l: number };
8 | type RGBA = WithAlpha;
9 | type HSVA = WithAlpha;
10 | type HSLA = WithAlpha;
11 | type HEX = string;
12 | type P3String = `color(display-p3 ${number} ${number} ${number}${` / ${number}` | ''})`;
13 | type RGBString = `rgb(${number} ${number} ${number}${` / ${number}` | ''})`;
14 |
15 | function decimalToHex(number: number): string {
16 | const hex = round(number * 255, 0).toString(16);
17 | return hex.length < 2 ? '0' + hex : hex;
18 | }
19 |
20 | function rgbaToHex({ r, g, b, a }: RGBA): HEX {
21 | const alphaHex = a < 1 ? decimalToHex(a) : '';
22 | return '#' + decimalToHex(r) + decimalToHex(g) + decimalToHex(b) + alphaHex;
23 | }
24 |
25 | function hslaToHex(hsla: HSLA): HEX {
26 | return rgbaToHex(hslaToRgba(hsla));
27 | }
28 |
29 | function hsvaToHex(hsla: HSVA): HEX {
30 | return rgbaToHex(hsvaToRgba(hsla));
31 | }
32 |
33 | function rgbaToHsva({ r, g, b, a }: RGBA): HSVA {
34 | const max = Math.max(r, g, b);
35 | const delta = max - Math.min(r, g, b);
36 |
37 | const hh = delta ? (max === r ? (g - b) / delta : max === g ? 2 + (b - r) / delta : 4 + (r - g) / delta) : 0;
38 |
39 | return {
40 | h: 60 * (hh < 0 ? hh + 6 : hh),
41 | s: max ? (delta / max) * 100 : 0,
42 | v: max * 100,
43 | a,
44 | };
45 | }
46 |
47 | function hsvaToRgba({ h, s, v, a }: HSVA): RGBA {
48 | h = (h / 360) * 6;
49 | s = s / 100;
50 | v = v / 100;
51 |
52 | const hh = Math.floor(h),
53 | b = v * (1 - s),
54 | c = v * (1 - (h - hh) * s),
55 | d = v * (1 - (1 - h + hh) * s),
56 | module = hh % 6;
57 |
58 | return {
59 | r: [v, c, b, b, d, v][module],
60 | g: [d, v, v, c, b, b][module],
61 | b: [b, b, d, v, v, c][module],
62 | a: a,
63 | };
64 | }
65 |
66 | function hslaToHsva({ h, s, l, a }: HSLA): HSVA {
67 | s *= (l < 50 ? l : 100 - l) / 100;
68 |
69 | return {
70 | h: h,
71 | s: s > 0 ? ((2 * s) / (l + s)) * 100 : 0,
72 | v: l + s,
73 | a,
74 | };
75 | }
76 |
77 | function hsvaToHsla({ h, s, v, a }: HSVA): HSLA {
78 | const hh = ((200 - s) * v) / 100;
79 |
80 | return {
81 | h,
82 | s: hh > 0 && hh < 200 ? ((s * v) / 100 / (hh <= 100 ? hh : 200 - hh)) * 100 : 0,
83 | l: hh / 2,
84 | a,
85 | };
86 | }
87 |
88 | function hslaToRgba(hsla: HSLA): RGBA {
89 | return hsvaToRgba(hslaToHsva(hsla));
90 | }
91 |
92 | function rgbaToHsla(rgba: RGBA): HSLA {
93 | return hsvaToHsla(rgbaToHsva(rgba));
94 | }
95 |
96 | function rgbaToP3String(color: RGBA): P3String {
97 | const r = round(color.r, 4);
98 | const g = round(color.g, 4);
99 | const b = round(color.b, 4);
100 | const a = round(color.a, 2);
101 | return a < 1 ? `color(display-p3 ${r} ${g} ${b} / ${a})` : `color(display-p3 ${r} ${g} ${b})`;
102 | }
103 |
104 | function rgbaToCssString(color: RGBA): RGBString {
105 | const r = Math.round(color.r * 255);
106 | const g = Math.round(color.g * 255);
107 | const b = Math.round(color.b * 255);
108 | const a = color.a;
109 |
110 | return a < 1 ? `rgb(${r} ${g} ${b} / ${a})` : `rgb(${r} ${g} ${b})`;
111 | }
112 |
113 | function blendWithWhite(color: RGBA): RGBA {
114 | const { r, g, b, a } = color;
115 | return {
116 | r: r + (1 - r) * (1 - a),
117 | g: g + (1 - g) * (1 - a),
118 | b: b + (1 - b) * (1 - a),
119 | a: 1,
120 | };
121 | }
122 |
123 | function roundHsva(hsva: HSVA): HSVA {
124 | return {
125 | h: round(hsva.h, 0),
126 | s: round(hsva.s, 0),
127 | v: round(hsva.v, 0),
128 | a: round(hsva.a, 2),
129 | };
130 | }
131 |
132 | function roundHsla(hsla: HSLA): HSLA {
133 | return {
134 | h: round(hsla.h, 0),
135 | s: round(hsla.s, 0),
136 | l: round(hsla.l, 0),
137 | a: round(hsla.a, 2),
138 | };
139 | }
140 |
141 | export type { RGB, HSV, HSL, RGBA, HSVA, HSLA, HEX, P3String, RGBString };
142 |
143 | export {
144 | rgbaToHex,
145 | rgbaToHsva,
146 | hsvaToRgba,
147 | hslaToHsva,
148 | hsvaToHsla,
149 | hslaToRgba,
150 | rgbaToHsla,
151 | rgbaToP3String,
152 | rgbaToCssString,
153 | blendWithWhite,
154 | roundHsva,
155 | roundHsla,
156 | hslaToHex,
157 | hsvaToHex,
158 | };
159 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_SMALL_NUDGE = 1;
2 | export const DEFAULT_BIG_NUDGE = 10;
3 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/dom/focus.ts:
--------------------------------------------------------------------------------
1 | type FocusableTarget = HTMLElement | { focus(): void };
2 |
3 | function isSelectableInput(element: unknown): element is FocusableTarget & { select: () => void } {
4 | return (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && 'select' in element;
5 | }
6 |
7 | /**
8 | * Focus a given element and optionally select its content if it is a selectable input.
9 | *
10 | * @param element - The element to focus. Optional.
11 | * @param options
12 | * @param options.select - Whether to select the content of the element if it is a selectable input (default: false).
13 | */
14 | export function focus(element?: FocusableTarget | null, { select = false } = {}) {
15 | const previouslyFocusedElement = document.activeElement;
16 |
17 | if (element && element.focus) {
18 | element.focus();
19 | }
20 |
21 | if (element !== previouslyFocusedElement && isSelectableInput(element) && select) {
22 | element.select();
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/dom/set-input-value.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Set the value of an HTMLInputElement and optionally dispatch an input event.
3 | *
4 | * @param inputElement - The HTMLInputElement to set the value for.
5 | * @param value - The value to set on the input element.
6 | * @param dispatchEvent - Whether to dispatch an input event (default: true).
7 | */
8 | export const setInputElementValue = (inputElement: HTMLInputElement | null, value: string, dispatchEvent = true) => {
9 | if (!inputElement) return;
10 |
11 | inputElement.value = value;
12 |
13 | if (dispatchEvent) {
14 | inputElement.dispatchEvent(
15 | new Event('input', {
16 | bubbles: true,
17 | cancelable: true,
18 | })
19 | );
20 | }
21 | };
22 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/number/clamp.ts:
--------------------------------------------------------------------------------
1 | export function clamp(value: number, min: number, max?: number) {
2 | if (!max) {
3 | max = min;
4 | min = -Infinity;
5 | }
6 |
7 | return Math.min(Math.max(value, min), max);
8 | }
9 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/number/normalize.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Map a value to a specified range.
3 | * https://stats.stackexchange.com/a/281164
4 | *
5 | * @param sourceRange {[number, number]} - input range
6 | * @param targetRange {[number, number]} - The target range to map the value to
7 | * @return {function(number): number} A function that takes a number and maps it to the specified range
8 | *
9 | * @example:
10 | * normalize([0, 1], [0, 100])(0.5) -> 50
11 | * normalize([0, 1], [0, 255])(1) -> 255
12 | */
13 | export function normalize(sourceRange: [number, number], targetRange: [number, number]) {
14 | const [sourceMin, sourceMax] = sourceRange;
15 | const [targetMin, targetMax] = targetRange;
16 |
17 | return function (value: number): number {
18 | if (sourceMin === sourceMax || targetMin === targetMax) {
19 | return targetMin;
20 | }
21 |
22 | return ((value - sourceMin) / (sourceMax - sourceMin)) * (targetMax - targetMin) + targetMin;
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/react/create-context.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * MIT License
4 | * Copyright (c) 2022 WorkOS
5 | * https://github.com/radix-ui/primitives/blob/main/LICENSE
6 | * */
7 | import React from 'react';
8 |
9 | function createContext(
10 | rootComponentName: string,
11 | defaultContext?: ContextValueType
12 | ) {
13 | const Context = React.createContext(defaultContext);
14 |
15 | function Provider(props: ContextValueType & { children: React.ReactNode }) {
16 | const { children, ...context } = props;
17 | // Only re-memoize when prop values change
18 | // eslint-disable-next-line react-hooks/exhaustive-deps
19 | const value = React.useMemo(() => context, Object.values(context)) as ContextValueType;
20 | return {children} ;
21 | }
22 |
23 | function useContext(consumerName: string) {
24 | const context = React.useContext(Context);
25 | if (context) return context;
26 | if (defaultContext !== undefined) return defaultContext;
27 | // if a defaultContext wasn't specified, it's a required context.
28 | throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``);
29 | }
30 |
31 | Provider.displayName = rootComponentName + 'Provider';
32 | return [Provider, useContext] as const;
33 | }
34 |
35 | export { createContext };
36 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/react/use-compose-refs.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * MIT License
4 | * Copyright (c) 2022 WorkOS
5 | * https://github.com/radix-ui/primitives/blob/main/LICENSE
6 | * */
7 | import * as React from 'react';
8 |
9 | type PossibleRef = React.Ref | undefined;
10 |
11 | /**
12 | * Set a given ref to a given value
13 | * This utility takes care of different types of refs: callback refs and RefObject(s)
14 | */
15 | function setRef(ref: PossibleRef, value: T) {
16 | if (typeof ref === 'function') {
17 | ref(value);
18 | } else if (ref !== null && ref !== undefined) {
19 | (ref as React.MutableRefObject).current = value;
20 | }
21 | }
22 |
23 | /**
24 | * A utility to compose multiple refs together
25 | * Accepts callback refs and RefObject(s)
26 | */
27 | function composeRefs(...refs: PossibleRef[]) {
28 | return (node: T) => refs.forEach((ref) => setRef(ref, node));
29 | }
30 |
31 | /**
32 | * A custom hook that composes multiple refs
33 | * Accepts callback refs and RefObject(s)
34 | */
35 | function useComposedRefs(...refs: PossibleRef[]) {
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | return React.useCallback(composeRefs(...refs), refs);
38 | }
39 |
40 | export { composeRefs, useComposedRefs };
41 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/react/use-controllable-state.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * MIT License
4 | * Copyright (c) 2022 WorkOS
5 | * https://github.com/radix-ui/primitives/blob/main/LICENSE
6 | * */
7 | import * as React from 'react';
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | function useCallbackRef any>(callback: T | undefined): T {
11 | const callbackRef = React.useRef(callback);
12 |
13 | React.useEffect(() => {
14 | callbackRef.current = callback;
15 | });
16 |
17 | // https://github.com/facebook/react/issues/19240
18 | return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
19 | }
20 |
21 | type UseControllableStateParams = {
22 | prop?: T | undefined;
23 | defaultProp?: T | undefined;
24 | onChange?: (state: T) => void;
25 | };
26 |
27 | type SetStateFn = (prevState?: T) => T;
28 |
29 | function useControllableState({ prop, defaultProp, onChange = () => {} }: UseControllableStateParams) {
30 | const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange });
31 | const isControlled = prop !== undefined;
32 | const value = isControlled ? prop : uncontrolledProp;
33 | const handleChange = useCallbackRef(onChange);
34 |
35 | const setValue: React.Dispatch> = React.useCallback(
36 | (nextValue) => {
37 | if (isControlled) {
38 | const setter = nextValue as SetStateFn;
39 | const value = typeof nextValue === 'function' ? setter(prop) : nextValue;
40 | if (value !== prop) handleChange(value as T);
41 | } else {
42 | setUncontrolledProp(nextValue);
43 | }
44 | },
45 | [isControlled, prop, setUncontrolledProp, handleChange]
46 | );
47 |
48 | return [value, setValue] as const;
49 | }
50 |
51 | function useUncontrolledState({ defaultProp, onChange }: Omit, 'prop'>) {
52 | const uncontrolledState = React.useState(defaultProp);
53 | const [value] = uncontrolledState;
54 | const prevValueRef = React.useRef(value);
55 | const handleChange = useCallbackRef(onChange);
56 |
57 | React.useEffect(() => {
58 | if (prevValueRef.current !== value) {
59 | handleChange(value as T);
60 | prevValueRef.current = value;
61 | }
62 | }, [value, prevValueRef, handleChange]);
63 |
64 | return uncontrolledState;
65 | }
66 |
67 | export { useControllableState };
68 |
--------------------------------------------------------------------------------
/figma-kit/src/lib/react/use-select-on-input-click.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useCallback, useState } from 'react';
3 |
4 | /**
5 | * Selects the target element's contents on click, unless the user is selecting manually.
6 | * */
7 | export function useSelectOnInputClick() {
8 | const [mustSelect, setMustSelect] = useState(true);
9 |
10 | const handleFocus = useCallback(() => {
11 | setMustSelect(true);
12 | }, []);
13 |
14 | const handleMouseLeave = useCallback(() => {
15 | setMustSelect(false);
16 | }, []);
17 |
18 | const handleMouseUp = useCallback(
19 | (event: React.MouseEvent) => {
20 | if (mustSelect && event.currentTarget.selectionStart === event.currentTarget.selectionEnd) {
21 | event.currentTarget.select();
22 | setMustSelect(false);
23 | }
24 | },
25 | [mustSelect]
26 | );
27 |
28 | return {
29 | onMouseLeave: handleMouseLeave,
30 | onMouseUp: handleMouseUp,
31 | onFocus: handleFocus,
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @import 'tokens/font.css';
2 | @import 'tokens/space.css';
3 | @import 'tokens/radius.css';
4 | @import 'tokens/font-size.css';
5 | @import 'tokens/font-weight.css';
6 | @import 'tokens/line-height.css';
7 | @import 'tokens/letter-spacing.css';
8 | @import 'tokens/shadow.css';
9 | @import 'tokens/components.css';
10 | @import '../components/flex/flex.css';
11 | @import '../components/text/text.css';
12 | @import '../components/icon/icon.css';
13 | @import '../components/input/input.css';
14 | @import '../components/value-field/value-field.css';
15 | @import '../components/tooltip/tooltip.css';
16 | @import '../components/textarea/textarea.css';
17 | @import '../components/menu.base/menu.base.css';
18 | @import '../components/select/select.css';
19 | @import '../components/button/button.css';
20 | @import '../components/icon-button/icon-button.css';
21 | @import '../components/switch/switch.css';
22 | @import '../components/slider/slider.css';
23 | @import '../components/dialog.base/dialog.base.css';
24 | @import '../components/dialog/dialog.css';
25 | @import '../components/alert-dialog/alert-dialog.css';
26 | @import '../components/tabs/tabs.css';
27 | @import '../components/checkbox/checkbox.css';
28 | @import '../components/radio-group/radio-group.css';
29 | @import '../components/segmented-control/segmented-control.css';
30 | @import '../components/collapsible/collapsible.css';
31 | @import '../components/color-picker/color-picker.css';
32 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/components.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-bg-menu: #1e1e1e;
3 | --color-bg-menu-hover: #2c2c2c;
4 | --color-bg-menu-selected: var(--figma-color-bg-selected-strong);
5 | --color-border-menu: #383838;
6 | --color-text-menu: var(--figma-color-text-oncomponent);
7 | --color-text-menu-secondary: var(--figma-color-text-oncomponent-secondary);
8 | --color-text-menu-tertiary: var(--figma-color-text-oncomponent-tertiary);
9 | --color-icon-menu: var(--figma-color-icon-oncomponent);
10 | --shadow-menu: var(--elevation-400);
11 | --font-size-menu: 12px;
12 |
13 | --color-bg-tooltip: #1e1e1e;
14 | --color-text-tooltip: #fff;
15 | --shadow-tooltip: var(--elevation-300);
16 |
17 | --color-overlay-dialog: #00000080;
18 |
19 | /* Aliased for brevity when using in icon attributes */
20 | --color-icon: var(--figma-color-icon);
21 | --color-icon-secondary: var(--figma-color-icon-secondary);
22 | --color-icon-tertiary: var(--figma-color-icon-tertiary);
23 | }
24 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/font-size.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --font-size-1: 0.5625rem;
3 | --font-size-2: 0.625rem;
4 | --font-size-3: 0.6875rem;
5 | --font-size-4: 0.75rem;
6 | --font-size-5: 0.8125rem;
7 | --font-size-6: 0.875rem;
8 | --font-size-7: 0.9375rem;
9 | --font-size-8: 1rem;
10 | --font-size-9: 1.25rem;
11 | --font-size-10: 1.5rem;
12 | --font-size-default: var(--font-size-3);
13 | }
14 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/font-weight.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --font-weight-default: 450;
3 | --font-weight-strong: 550;
4 | }
5 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/font.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --font-family-default: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
3 | sans-serif, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
4 | SFProLocalRange;
5 | --font-family-monospace: 'Roboto Mono', monospace;
6 | }
7 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/letter-spacing.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --letter-spacing-1: 0.00281rem;
3 | --letter-spacing-2: 0.00625rem;
4 | --letter-spacing-3: 0.00344rem;
5 | --letter-spacing-4: 0;
6 | --letter-spacing-5: -0.002rem;
7 | --letter-spacing-6: -0.00525rem;
8 | --letter-spacing-7: -0.00469rem;
9 | --letter-spacing-8: -0.011rem;
10 | --letter-spacing-9: -0.013812rem;
11 | --letter-spacing-10: -0.0255rem;
12 | --letter-spacing-default: var(--letter-spacing-3);
13 | }
14 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/line-height.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --line-height-1: 0.875rem;
3 | --line-height-2: 0.875rem;
4 | --line-height-3: 1rem;
5 | --line-height-4: 1rem;
6 | --line-height-5: 1.375rem;
7 | --line-height-6: 1.5rem;
8 | --line-height-7: 1.5625rem;
9 | --line-height-8: 1.5625rem;
10 | --line-height-9: 2rem;
11 | --line-height-10: 2rem;
12 | --line-height-default: var(--line-height-3);
13 | }
14 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/radius.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --radius-extra-small: 0.0625rem;
3 | --radius-small: 0.125rem;
4 | --radius-medium: 0.3125rem;
5 | --radius-large: 0.8125rem;
6 | --radius-full: 9999px;
7 | --radius-default: var(--radius-small);
8 | }
9 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/shadow.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --elevation-100: 0px 0px 0.5px rgba(0, 0, 0, 0.3), 0px 1px 3px rgba(0, 0, 0, 0.15);
3 | --elevation-200: 0px 0px 0.5px rgba(0, 0, 0, 0.18), 0px 3px 8px rgba(0, 0, 0, 0.1), 0px 1px 3px rgba(0, 0, 0, 0.1);
4 | --elevation-300: 0px 0px 0.5px rgba(0, 0, 0, 0.15), 0px 5px 12px rgba(0, 0, 0, 0.13), 0px 1px 3px rgba(0, 0, 0, 0.1);
5 | --elevation-400: 0px 0px 0.5px rgba(0, 0, 0, 0.12), 0px 10px 16px rgba(0, 0, 0, 0.12), 0px 2px 5px rgba(0, 0, 0, 0.15);
6 | --elevation-500: 0px 0px 0.5px rgba(0, 0, 0, 0.08), 0px 10px 24px rgba(0, 0, 0, 0.18), 0px 2px 5px rgba(0, 0, 0, 0.15);
7 | }
8 |
9 | .dark,
10 | .dark-theme {
11 | --elevation-100: 0px 0px 0.5px rgba(0, 0, 0, 0.5), 0px 1px 3px rgba(0, 0, 0, 0.4),
12 | inset 0px 0.5px 0px rgba(255, 255, 255, 0.1), inset 0px 0px 0.5px rgba(255, 255, 255, 0.3);
13 |
14 | --elevation-200: 0px 3px 8px rgba(0, 0, 0, 0.35), 0px 1px 3px rgba(0, 0, 0, 0.5),
15 | inset 0px 0.5px 0px rgba(255, 255, 255, 0.08), inset 0px 0px 0.5px rgba(255, 255, 255, 0.3);
16 |
17 | --elevation-300: 0px 5px 12px rgba(0, 0, 0, 0.35), 0px 1px 3px rgba(0, 0, 0, 0.5),
18 | inset 0px 0.5px 0px rgba(255, 255, 255, 0.08), inset 0px 0px 0.5px rgba(255, 255, 255, 0.3);
19 |
20 | --elevation-400: 0px 10px 16px rgba(0, 0, 0, 0.35), 0px 2px 5px rgba(0, 0, 0, 0.35),
21 | inset 0px 0.5px 0px rgba(255, 255, 255, 0.08), inset 0px 0px 0.5px rgba(255, 255, 255, 0.35);
22 |
23 | --elevation-500: 0px 10px 24px rgba(0, 0, 0, 0.45), 0px 3px 5px rgba(0, 0, 0, 0.35),
24 | inset 0px 0.5px 0px rgba(255, 255, 255, 0.08), inset 0px 0px 0.5px rgba(255, 255, 255, 0.35);
25 | }
26 |
--------------------------------------------------------------------------------
/figma-kit/src/styles/tokens/space.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --space-0: 0rem;
3 | --space-px: 0.0625rem;
4 | --space-0_5: 0.125rem;
5 | --space-1: 0.25rem;
6 | --space-1_5: 0.375rem;
7 | --space-2: 0.5rem;
8 | --space-2_5: 0.625rem;
9 | --space-3: 0.75rem;
10 | --space-3_5: 0.875rem;
11 | --space-4: 1rem;
12 | --space-5: 1.25rem;
13 | --space-6: 1.5rem;
14 | --space-7: 1.75rem;
15 | --space-8: 2rem;
16 | --space-9: 2.25rem;
17 | --space-10: 2.5rem;
18 | --space-11: 2.75rem;
19 | --space-12: 3rem;
20 | --space-13: 3.25rem;
21 | --space-14: 3.5rem;
22 | --space-15: 3.75rem;
23 | --space-16: 4rem;
24 | }
25 |
--------------------------------------------------------------------------------
/figma-kit/test/setup.ts:
--------------------------------------------------------------------------------
1 | import { afterEach } from 'vitest';
2 | import { cleanup } from '@testing-library/react';
3 | import '@testing-library/jest-dom/vitest';
4 |
5 | afterEach(() => {
6 | cleanup();
7 | });
8 |
--------------------------------------------------------------------------------
/figma-kit/test/value-field-base.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest';
2 | import { useState } from 'react';
3 | import { render } from '@testing-library/react';
4 | import { userEvent } from '@testing-library/user-event';
5 | import type { Formatter } from '@components/value-field/types';
6 | import * as ValueField from '@components/value-field//value-field-base';
7 |
8 | const LABEL = 'test-field';
9 | const INITIAL_VALUE = 30;
10 | const user = userEvent.setup();
11 |
12 | const formatter: Formatter = {
13 | parse: (input: string) => {
14 | if (input.length > 0 && !isNaN(Number(input))) {
15 | return { valid: true, value: Number(input) };
16 | }
17 |
18 | return { valid: false };
19 | },
20 | format: (value: number) => `${value}`,
21 | };
22 |
23 | describe('given a basic field', () => {
24 | const VALID_VALUE = '40';
25 | const INVALID_VALUE = 'dogs';
26 |
27 | const TestControl = ({ onChange }: { onChange?: (value: number) => void }) => {
28 | const [value, setValue] = useState(INITIAL_VALUE);
29 |
30 | const handleChange = (value: number) => {
31 | onChange?.(value);
32 | setValue(value);
33 | };
34 |
35 | return ;
36 | };
37 |
38 | it('formats correctly', () => {
39 | const { getByLabelText } = render( );
40 | const field = getByLabelText(LABEL);
41 | expect(field).toHaveValue(formatter.format(INITIAL_VALUE));
42 | });
43 |
44 | it('saves on blur', async () => {
45 | const { getByLabelText } = render( );
46 | const field = getByLabelText(LABEL);
47 | await user.type(field, VALID_VALUE);
48 | await user.keyboard('{Tab}');
49 | expect(field).not.toHaveFocus();
50 | expect(field).toHaveValue(VALID_VALUE);
51 | });
52 |
53 | it('reverts invalid values', async () => {
54 | const { getByLabelText } = render( );
55 | const field = getByLabelText(LABEL);
56 | await user.type(field, INVALID_VALUE);
57 | await user.keyboard('{Tab}');
58 | expect(field).not.toHaveFocus();
59 | expect(field).toHaveValue(formatter.format(INITIAL_VALUE));
60 | });
61 |
62 | it('saves on Enter', async () => {
63 | const { getByLabelText } = render( );
64 | const field = getByLabelText(LABEL);
65 | await user.type(field, VALID_VALUE);
66 | await user.keyboard('{Enter}');
67 | expect(field).not.toHaveFocus();
68 | expect(field).toHaveValue(VALID_VALUE);
69 | });
70 |
71 | it('reverts on Escape', async () => {
72 | const { getByLabelText } = render( );
73 | const field = getByLabelText(LABEL);
74 | await user.type(field, VALID_VALUE);
75 | expect(field).toHaveValue(VALID_VALUE);
76 | await user.keyboard('{Escape}');
77 | expect(field).not.toHaveFocus();
78 | expect(field).toHaveValue(formatter.format(INITIAL_VALUE));
79 | });
80 |
81 | it("doesn't fire onChange when submitted value is the same", async () => {
82 | const changeHandler = vi.fn();
83 | const { getByLabelText } = render( );
84 | const field = getByLabelText(LABEL);
85 | await user.type(field, `${INITIAL_VALUE}`);
86 | await user.keyboard('{Enter}');
87 | expect(changeHandler).not.toBeCalled();
88 | });
89 |
90 | it.todo("doesn't close parent modal on Escape"); // feature unimplemented
91 | it.todo('increments correctly');
92 | it.todo('selects value on click');
93 | });
94 |
--------------------------------------------------------------------------------
/figma-kit/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "types": ["@testing-library/jest-dom"],
5 | "baseUrl": ".",
6 | "paths": {
7 | "@components/*": ["src/components/*"],
8 | "@examples/*": ["src/examples/*"],
9 | "@lib/*": ["src/lib/*"]
10 | }
11 | },
12 | "include": ["src", "test"],
13 | }
14 |
--------------------------------------------------------------------------------
/figma-kit/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 | import dts from 'vite-plugin-dts';
5 | import tsconfigPaths from 'vite-tsconfig-paths';
6 | import { dependencies } from './package.json';
7 |
8 | const extensions: Record = {
9 | cjs: 'cjs',
10 | es: 'mjs',
11 | };
12 |
13 | export default defineConfig((env) => {
14 | return {
15 | plugins: [tsconfigPaths(), react(), dts({ rollupTypes: true })],
16 |
17 | build: {
18 | cssCodeSplit: true,
19 | emptyOutDir: env.mode !== 'development',
20 | lib: {
21 | entry: 'src/index.ts',
22 | formats: ['es', 'cjs'],
23 | name: 'figma-kit',
24 | fileName: (format, entryName) => {
25 | return `${entryName}.${extensions[format]}`;
26 | },
27 | },
28 | rollupOptions: {
29 | external: [...Object.keys(dependencies), 'react', 'react-dom', 'react/jsx-runtime'],
30 | output: {
31 | globals: {
32 | react: 'React',
33 | 'react-dom': 'ReactDOM',
34 | },
35 | },
36 | },
37 | },
38 | test: {
39 | environment: 'happy-dom',
40 | setupFiles: './test/setup.ts',
41 | },
42 | };
43 | });
44 |
--------------------------------------------------------------------------------
/generate-react-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "usesTypeScript": true,
3 | "usesStyledComponents": false,
4 | "usesCssModule": false,
5 | "cssPreprocessor": "css",
6 | "testLibrary": "Testing Library",
7 | "component": {
8 | "default": {
9 | "customTemplates": {
10 | "index": "./generator-templates/default/index.ts",
11 | "component": "./generator-templates/default/template-name.tsx",
12 | "story": "./generator-templates/default/template-name.stories.tsx"
13 | },
14 | "path": "figma-kit/src/components",
15 | "withIndex": true,
16 | "withStyle": false,
17 | "withTest": false,
18 | "withStory": true,
19 | "withLazy": false
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/media/github-banner-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tigranpetrossian/figma-kit/7152a2e633e23f1a98c516e020f7f415909073ab/media/github-banner-dark.png
--------------------------------------------------------------------------------
/media/github-banner-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tigranpetrossian/figma-kit/7152a2e633e23f1a98c516e020f7f415909073ab/media/github-banner-light.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "figma-kit-root",
3 | "private": true,
4 | "scripts": {
5 | "site:dev": "pnpm --filter website dev",
6 | "dev": "pnpm --filter figma-kit dev",
7 | "build": "pnpm --filter figma-kit build",
8 | "lint": "eslint \"**/{src,test}/**/*.{ts,tsx}\" --ext ts,tsx --fix --max-warnings 0",
9 | "test": "pnpm --filter figma-kit test",
10 | "prepare": "husky",
11 | "storybook": "storybook dev -p 6006 --no-open",
12 | "build-storybook": "storybook build"
13 | },
14 | "devDependencies": {
15 | "@chromatic-com/storybook": "^1.5.0",
16 | "@commitlint/cli": "^19.2.1",
17 | "@commitlint/config-conventional": "^19.1.0",
18 | "@storybook/addon-essentials": "8.1.5",
19 | "@storybook/addon-interactions": "8.1.5",
20 | "@storybook/addon-links": "8.1.5",
21 | "@storybook/addon-storysource": "8.1.5",
22 | "@storybook/blocks": "8.1.5",
23 | "@storybook/react": "8.1.5",
24 | "@storybook/react-vite": "8.1.5",
25 | "@storybook/test": "8.1.5",
26 | "@testing-library/jest-dom": "^6.4.2",
27 | "@testing-library/react": "^14.2.2",
28 | "@testing-library/user-event": "^14.5.2",
29 | "@types/node": "^20.11.30",
30 | "@types/react": "^18.2.66",
31 | "@typescript-eslint/eslint-plugin": "^7.2.0",
32 | "@typescript-eslint/parser": "^7.2.0",
33 | "@vitejs/plugin-react": "^4.2.1",
34 | "conventional-changelog-conventionalcommits": "^7.0.2",
35 | "eslint": "^8.57.0",
36 | "eslint-config-prettier": "^9.1.0",
37 | "eslint-import-resolver-typescript": "^3.6.1",
38 | "eslint-plugin-import": "^2.29.1",
39 | "eslint-plugin-react-hooks": "^4.6.0",
40 | "eslint-plugin-react-refresh": "^0.4.6",
41 | "eslint-plugin-storybook": "^0.8.0",
42 | "happy-dom": "^14.3.9",
43 | "husky": "^9.0.11",
44 | "lint-staged": "^15.2.2",
45 | "postcss": "^8.4.38",
46 | "postcss-cli": "^11.0.0",
47 | "postcss-import": "^16.1.0",
48 | "postcss-nesting": "^12.1.5",
49 | "postcss-prefixer": "^3.0.0",
50 | "postcss-variables-prefixer": "^1.2.0",
51 | "prettier": "^3.2.5",
52 | "react-element-to-jsx-string": "^15.0.0",
53 | "semantic-release": "^24.0.0",
54 | "storybook": "8.1.5",
55 | "storybook-dark-mode": "^4.0.1",
56 | "typescript": "^5.4.5",
57 | "vite": "^5.2.7",
58 | "vite-plugin-dts": "^3.9.0",
59 | "vite-tsconfig-paths": "^4.3.2",
60 | "vitest": "^1.5.0"
61 | },
62 | "lint-staged": {
63 | "**/{src,test}/**/*.{ts,tsx}": [
64 | "eslint --cache --fix --max-warnings 0",
65 | "prettier --write"
66 | ]
67 | },
68 | "packageManager": "pnpm@9.2.0",
69 | "pnpm": {
70 | "patchedDependencies": {
71 | "@radix-ui/react-slider@1.2.0": "patches/@radix-ui__react-slider@1.2.0.patch"
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/patches/@radix-ui__react-slider@1.2.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/dist/index.js b/dist/index.js
2 | index 3fe3bfcc8e0949846745df15dcd41e98e870bbfc..a509a32159a02a5f175662beec9d03a4547ab856 100644
3 | --- a/dist/index.js
4 | +++ b/dist/index.js
5 | @@ -464,7 +464,7 @@ var SliderThumbImpl = React.forwardRef(
6 | style: {
7 | transform: "var(--radix-slider-thumb-transform)",
8 | position: "absolute",
9 | - [orientation.startEdge]: `calc(${percent}% + ${thumbInBoundsOffset}px)`
10 | + [orientation.startEdge]: `${percent}%`
11 | },
12 | children: [
13 | /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Collection.ItemSlot, { scope: props.__scopeSlider, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
14 | diff --git a/dist/index.mjs b/dist/index.mjs
15 | index f78187804a6afb4aa3d5c0118d4afb8e12f1b25e..d2cf849e2b66fe973018f1bdf50a23cc7c99b70a 100644
16 | --- a/dist/index.mjs
17 | +++ b/dist/index.mjs
18 | @@ -421,7 +421,7 @@ var SliderThumbImpl = React.forwardRef(
19 | style: {
20 | transform: "var(--radix-slider-thumb-transform)",
21 | position: "absolute",
22 | - [orientation.startEdge]: `calc(${percent}% + ${thumbInBoundsOffset}px)`
23 | + [orientation.startEdge]: `${percent}%`
24 | },
25 | children: [
26 | /* @__PURE__ */ jsx(Collection.ItemSlot, { scope: props.__scopeSlider, children: /* @__PURE__ */ jsx(
27 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'figma-kit'
3 | - 'website'
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
5 | "module": "ESNext",
6 | "strict": true,
7 | "skipLibCheck": true,
8 | "moduleResolution": "bundler",
9 | "useDefineForClassFields": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "pretty": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx"
15 | },
16 | "include": ["src", ".storybook"]
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/website/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default {
18 | // other rules...
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | sourceType: 'module',
22 | project: ['./tsconfig.json', './tsconfig.node.json'],
23 | tsconfigRootDir: __dirname,
24 | },
25 | }
26 | ```
27 |
28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
31 |
--------------------------------------------------------------------------------
/website/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview"
9 | },
10 | "dependencies": {
11 | "react": "^18.2.0",
12 | "react-dom": "^18.2.0",
13 | "figma-kit": "workspace:*",
14 | "tailwind-merge": "^2.3.0"
15 | },
16 | "devDependencies": {
17 | "@types/react": "^18.2.66",
18 | "@types/react-dom": "^18.2.22",
19 | "autoprefixer": "^10.4.19",
20 | "postcss": "^8.4.38",
21 | "tailwindcss": "^3.4.3"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/website/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/website/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/website/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function App() {
4 | return Hello
5 | }
6 |
7 | export { App };
8 |
--------------------------------------------------------------------------------
/website/src/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tigranpetrossian/figma-kit/7152a2e633e23f1a98c516e020f7f415909073ab/website/src/index.css
--------------------------------------------------------------------------------
/website/src/lib/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tigranpetrossian/figma-kit/7152a2e633e23f1a98c516e020f7f415909073ab/website/src/lib/.keep
--------------------------------------------------------------------------------
/website/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { App } from 'App';
4 | import './index.css';
5 |
6 | ReactDOM.createRoot(document.getElementById('root')!).render(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/website/src/views/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tigranpetrossian/figma-kit/7152a2e633e23f1a98c516e020f7f415909073ab/website/src/views/.keep
--------------------------------------------------------------------------------
/website/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/website/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ['class'],
4 | content: ['./src/**/*.{ts,tsx}'],
5 | };
6 |
--------------------------------------------------------------------------------
/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "*": ["src/*"]
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/website/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/website/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [tsconfigPaths(), react()],
9 | server: {
10 | open: true,
11 | },
12 | });
13 |
--------------------------------------------------------------------------------