diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 30685c898..371b45136 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1 +1,2 @@ -public/storybook \ No newline at end of file +public/storybook +public/ui diff --git a/packages/app/next.config.mjs b/packages/app/next.config.mjs index a8e5f8ca0..bd8b33adb 100644 --- a/packages/app/next.config.mjs +++ b/packages/app/next.config.mjs @@ -31,6 +31,11 @@ const config = { destination: '/storybook/index.html', permanent: false, }, + { + source: '/ui', + destination: '/ui/index.html', + permanent: false, + }, ]; }, }; diff --git a/packages/storybook-addon-theme/package.json b/packages/storybook-addon-theme/package.json index cf484a798..b93eb44d7 100644 --- a/packages/storybook-addon-theme/package.json +++ b/packages/storybook-addon-theme/package.json @@ -1,5 +1,6 @@ { "name": "storybook-addon-theme", + "private": true, "version": "0.0.1", "access": "public", "main": "./src/index.tsx", diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts new file mode 100644 index 000000000..bfb8759b4 --- /dev/null +++ b/packages/ui/.storybook/main.ts @@ -0,0 +1,37 @@ +import tsconfigpath from 'vite-tsconfig-paths'; +import { UserConfig, mergeConfig } from 'vite'; +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.tsx'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-storysource', + '@storybook/addon-a11y', + 'storybook-addon-theme/register', + ], + staticDirs: ['../public'], + core: { + disableTelemetry: true, + builder: '@storybook/builder-vite', + }, + framework: { + name: '@storybook/react-vite', + options: {}, + }, + typescript: { + check: false, + reactDocgen: 'react-docgen', + }, + async viteFinal(config: UserConfig) { + return mergeConfig(config, { + plugins: [tsconfigpath()], + define: { + __STORYBOOK_FUEL_UI__: '"true"', + }, + }); + }, +}; +export default config; diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx new file mode 100644 index 000000000..70ababedc --- /dev/null +++ b/packages/ui/.storybook/preview.tsx @@ -0,0 +1,44 @@ +import '@fontsource-variable/inter/slnt.css'; +import '../src/theme/index.css'; + +import { withThemeDecorator } from 'storybook-addon-theme'; +import { Preview } from '@storybook/react'; +import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; +import { Theme } from '../src/components/Theme'; +import { Toaster } from '../src/components/Toast'; +import { ReactNode } from 'react'; + +function ThemeWrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} + +const preview: Preview = { + decorators: [withThemeDecorator(ThemeWrapper)], + + parameters: { + actions: { + argTypesRegex: '^on[A-Z].*', + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + options: { + storySort: { + order: ['Base', 'Layout', 'Form', 'Overlay', 'UI', 'Helpers', 'Web3'], + }, + }, + viewport: { + viewports: INITIAL_VIEWPORTS, + }, + }, +}; + +export default preview; diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 000000000..0aa7f9ab9 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,116 @@ +{ + "name": "@fuels/ui", + "version": "0.0.1", + "access": "public", + "private": true, + "main": "./src/index.tsx", + "exports": { + ".": "./src/index.tsx", + "./tailwind-preset": "./src/theme/tailwind-preset.ts", + "./styles.css": "./src/theme/index.css" + }, + "typesVersions": { + "*": { + "tailwind-preset": [ + "./src/theme/tailwind-preset.ts" + ] + } + }, + "publishConfig": { + "main": "./dist/index.cjs.js", + "module": "./dist/index.cjs.js", + "types": "./dist/index.d.ts", + "typings": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.esm.js", + "require": "./dist/index.cjs.jg", + "default": "./dist/index.cjs.js", + "types": "./dist/index.d.ts", + "typings": "./dist/index.d.ts" + }, + "./tailwind-preset": { + "import": "./dist/theme/index.cjs.js", + "require": "./dist/theme/index.cjs.js", + "default": "./dist/theme/index.cjs.js", + "types": "./dist/theme/tailwind-preset.d.ts", + "typings": "./dist/theme/tailwind-preset.d.ts" + }, + "./styles.css": "./dist/index.css" + }, + "files": [ + "dist" + ] + }, + "scripts": { + "build:storybook": "pnpm ts:check && storybook build -o ../app/public/ui", + "build:preview": "pnpm build:storybook", + "build": "tsup --dts", + "build:watch": "tsup --watch", + "dev:storybook": "storybook dev -p 6006", + "test": "jest --verbose --passWithNoTests", + "test:dev": "pnpm ts:check && pnpm test", + "ts:check": "tsc --noEmit", + "generate:defs": "node scripts/create-defs.mjs" + }, + "dependencies": { + "@fontsource-variable/inter": "5.0.8", + "@fuel-ts/math": "0.59.0", + "@radix-ui/colors": "3.0.0-rc.4", + "@radix-ui/react-accordion": "1.1.2", + "@radix-ui/react-aspect-ratio": "1.0.3", + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-toast": "1.1.5", + "@radix-ui/themes": "^1.1.2", + "@react-aria/focus": "3.14.1", + "@tabler/icons-react": "2.35.0", + "@tailwindcss/typography": "0.5.10", + "clsx": "2.0.0", + "csstype": "3.1.2", + "deepmerge-json": "1.5.0", + "framer-motion": "10.16.4", + "modern-normalize": "2.0.0", + "radix-ui-themes-with-tailwind": "1.2.6", + "react": "^18.2.0", + "react-aria": "3.28.0", + "react-dom": "^18.2.0", + "react-stately": "3.26.0", + "react-use": "17.4.0", + "tailwind-merge": "1.14.0", + "tailwind-variants": "0.1.14", + "tailwindcss-animate": "1.0.7", + "tailwindcss-radix": "2.8.0", + "tailwindcss-themer": "3.1.0" + }, + "devDependencies": { + "@chialab/esbuild-plugin-meta-url": "0.17.7", + "@storybook/addon-a11y": "^7.4.5", + "@storybook/addon-actions": "^7.4.5", + "@storybook/addon-essentials": "^7.4.5", + "@storybook/addon-interactions": "^7.4.5", + "@storybook/addon-links": "^7.4.5", + "@storybook/addon-storysource": "^7.4.5", + "@storybook/addon-viewport": "7.4.5", + "@storybook/addons": "^7.4.5", + "@storybook/react": "^7.4.5", + "@storybook/react-vite": "^7.4.5", + "@storybook/testing-library": "^0.2.1", + "@storybook/types": "^7.4.5", + "@types/lodash": "4.14.199", + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", + "autoprefixer": "10.4.16", + "globby": "13.2.2", + "lodash": "^4.17.21", + "postcss": "8.4.30", + "postcss-import": "15.1.0", + "storybook": "^7.4.5", + "storybook-addon-theme": "workspace:*", + "tailwindcss": "3.3.3", + "tsconfig-paths-webpack-plugin": "^4.1.0", + "typescript": "5.2.2", + "vite": "^4.4.9", + "vite-tsconfig-paths": "^4.2.1" + } +} diff --git a/packages/ui/postcss.config.js b/packages/ui/postcss.config.js new file mode 100644 index 000000000..e569373fd --- /dev/null +++ b/packages/ui/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/ui/public/assets/eth.svg b/packages/ui/public/assets/eth.svg new file mode 100644 index 000000000..7b3245b2a --- /dev/null +++ b/packages/ui/public/assets/eth.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/public/favicon.svg b/packages/ui/public/favicon.svg new file mode 100644 index 000000000..dff6a0dbb --- /dev/null +++ b/packages/ui/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/public/icons.svg b/packages/ui/public/icons.svg new file mode 100644 index 000000000..76d27f961 --- /dev/null +++ b/packages/ui/public/icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/public/logo.svg b/packages/ui/public/logo.svg new file mode 100644 index 000000000..dff6a0dbb --- /dev/null +++ b/packages/ui/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/scripts/create-defs.mjs b/packages/ui/scripts/create-defs.mjs new file mode 100644 index 000000000..7a3ab5eac --- /dev/null +++ b/packages/ui/scripts/create-defs.mjs @@ -0,0 +1,283 @@ +import { promises as fs } from 'fs'; +import { globby } from 'globby'; +import _ from 'lodash'; +import path from 'path'; +import prettier from 'prettier'; +import ts from 'typescript'; +import * as url from 'url'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const COMPONENT_DIR = path.join(__dirname, '../src/components'); +const INDEX_FILE = path.join(__dirname, '../src/index.tsx'); + +const PRETTIER_CONFIG = { + printWidth: 80, + semi: true, + tabWidth: 2, + useTabs: false, + singleQuote: true, + bracketSpacing: true, + arrowParens: 'always', + quoteProps: 'as-needed', +}; + +function prettierFormat(str) { + return prettier.format(str, { parser: 'typescript', ...PRETTIER_CONFIG }); +} + +function extractExports(sourceFile) { + const exports = { + valueExports: [], + typeExports: [], + }; + + function visit(node) { + if (ts.isExportAssignment(node)) { + exports.valueExports.push('default'); + } else if (ts.isExportDeclaration(node)) { + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + if (node.isTypeOnly) { + exports.typeExports.push(element.name.getText(sourceFile)); + } else { + exports.valueExports.push(element.name.getText(sourceFile)); + } + } + } + } else if ( + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isVariableStatement(node) || + ts.isEnumDeclaration(node) + ) { + if ( + node.modifiers && + node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + exports.valueExports.push(declaration.name.getText(sourceFile)); + } + } else { + exports.valueExports.push(node.name.text); + } + } + } else if ( + ts.isTypeAliasDeclaration(node) || + ts.isInterfaceDeclaration(node) + ) { + if ( + node.modifiers && + node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + exports.typeExports.push(node.name.text); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + exports.valueExports = _.uniq(exports.valueExports); + exports.typeExports = _.uniq(exports.typeExports); + return exports; +} + +async function getAllComponents() { + const allComponents = await globby( + [ + '**/*.tsx', + '**/use**.ts', + '!**/*.stories.tsx', + '!**/*.test.{tsx,ts}', + '!**/styles.ts', + ], + { + deep: 2, + cwd: COMPONENT_DIR, + absolute: true, + onlyFiles: true, + }, + ); + + const components = await Promise.all( + allComponents + .map(async (comp) => { + const name = path.parse(comp).name; + const baseComponent = path.parse(path.dirname(comp)).name; + const component = name === baseComponent ? null : name; + const sourceFile = ts.createSourceFile( + comp, + await fs.readFile(comp, 'utf8'), + ); + + const { valueExports, typeExports } = extractExports(sourceFile); + return { + dir: path.dirname(comp), + component, + baseComponent, + exports: [...new Set(valueExports)], + types: [...new Set(typeExports)], + }; + }) + .filter(Boolean), + ); + + return components; +} + +function createExportStr(from, exports, isType, pathPrefix = '') { + const listStr = exports?.sort().join(','); + return exports.length + ? `export ${ + isType ? 'type' : '' + } { ${listStr} } from './${pathPrefix}${from}';` + : ''; +} + +function createNestedExportStr(main, nested, isType) { + if (!nested.length) return ''; + const key = isType ? 'types' : 'exports'; + let items = []; + for (const n of nested) { + let list = n[key] + .filter((i) => main.indexOf(i) === -1) + .filter((i) => items.every((item) => item.list.indexOf(i) === -1)); + const from = n.component; + items = items.concat({ from, list }); + } + + return items + .map((n) => { + const res = createExportStr(n.from, n.list, isType); + return res.length ? res : null; + }) + .filter(Boolean) + .sort() + .join('\n'); +} + +async function createComponentIndex(components) { + const mainComponents = components.filter((s) => !s.component); + for (const item of mainComponents) { + const component = item.baseComponent; + const nested = components.filter( + (c) => + c.baseComponent === component && + c.component && + !c.component?.startsWith('index'), + ); + + const mainExportsStr = createExportStr(component, item.exports); + const mainTypesStr = createExportStr(component, item.types, true); + const nestedExportStr = createNestedExportStr(item.exports, nested); + const nestedTypesStr = createNestedExportStr(item.types, nested, true); + + const index = [ + mainExportsStr, + mainTypesStr, + nestedExportStr, + nestedTypesStr, + ].join('\n\n'); + + const content = await prettierFormat(index); + await fs.writeFile(`${item.dir}/index.tsx`, content); + } +} + +async function createMainComponentsIndex(components) { + const mainComponents = components.filter((s) => !s.component); + let list = []; + + for (const item of mainComponents) { + const mainPath = `${item.dir}/index.tsx`; + const mainStr = await fs.readFile(mainPath, 'utf8'); + const sourceFile = ts.createSourceFile(mainPath, mainStr); + const { valueExports, typeExports } = extractExports(sourceFile); + const uniqueExports = [...new Set(valueExports)]; + const uniqueTypes = [...new Set(typeExports)]; + const exportsStr = createExportStr( + item.baseComponent, + uniqueExports, + false, + 'components/', + ); + const typesStr = createExportStr( + item.baseComponent, + uniqueTypes, + true, + 'components/', + ); + if (exportsStr.length) list.push(exportsStr); + if (typesStr.length) list.push(typesStr); + } + + list = list.join('\n\n'); + const content = await prettierFormat(list); + await fs.writeFile(INDEX_FILE, content); +} + +async function pkgJSON(components) { + const pkgJSONBuffer = await fs.readFile( + path.join(__dirname, '../package.json'), + ); + let pkgJSON = JSON.parse(pkgJSONBuffer.toString()); + let typesVersions = { '*': {} }; + let exportsConfig = {}; + + const comps = Array.from(new Set(components.map((c) => c.baseComponent))); + comps.forEach((component) => { + const name = component; + const folder = path.join('./dist/components', name); + typesVersions['*'][name] = [`./${folder}/index.d.ts`]; + exportsConfig[`./${name}`] = { + import: `./${folder}/index.esm.js`, + types: `./${folder}/index.d.ts`, + typings: `./${folder}/index.d.ts`, + }; + }); + typesVersions['*']['.'] = ['./dist/index.d.ts']; + exportsConfig['.'] = { + import: './dist/index.esm.mjs', + types: './dist/index.d.ts', + typings: './dist/index.d.ts', + }; + exportsConfig['./index.css'] = './dist/index.css'; + exportsConfig['./theme'] = { + import: './dist/theme.esm.js', + types: './dist/theme.d.ts', + typings: './dist/theme.d.ts', + }; + + pkgJSON.exports = exportsConfig; + pkgJSON.typesVersions = typesVersions; + + await fs.writeFile( + path.join(__dirname, '../package.json'), + JSON.stringify(pkgJSON, null, 2), + ); +} + +export async function tsup(components) { + const comps = Array.from(new Set(components.map((c) => c.baseComponent))); + const entryPoints = comps.map((c) => `src/components/${c}/index.tsx`); + + await fs.writeFile( + path.join(__dirname, '../tsup.text'), + JSON.stringify({ entry: entryPoints }), + ); +} + +async function main() { + const components = await getAllComponents(); + // const allIndex = await globby(`${COMPONENT_DIR}/**/index.tsx`); + // await Promise.all(allIndex.map((i) => fs.rm(i))); + + // await createComponentIndex(components); + // await createMainComponentsIndex(components); + await pkgJSON(components); + // await tsup(components); +} + +main(); diff --git a/packages/ui/src/components/Accordion/Accordion.stories.tsx b/packages/ui/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 000000000..a89a59a16 --- /dev/null +++ b/packages/ui/src/components/Accordion/Accordion.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Box } from '../Box'; + +import { Accordion } from './Accordion'; + +const meta: Meta = { + title: 'UI/Accordion', + component: Accordion, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + What's Fuel? + + The world's fastest modular execution layer. + + + + Is it really fast? + Yes, it's blazingly fast. + + + + ), +}; diff --git a/packages/ui/src/components/Accordion/Accordion.tsx b/packages/ui/src/components/Accordion/Accordion.tsx new file mode 100644 index 000000000..ca242ece6 --- /dev/null +++ b/packages/ui/src/components/Accordion/Accordion.tsx @@ -0,0 +1,72 @@ +import * as AC from '@radix-ui/react-accordion'; +import { IconChevronDown } from '@tabler/icons-react'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; +import { Icon } from '../Icon/Icon'; + +import { styles } from './styles'; + +export type AccordionProps = PropsOf; +export type AccordionContentProps = PropsOf; +export type AccordionItemProps = PropsOf; +export type AccordionHeaderProps = PropsOf; +export type AccordionTriggerProps = PropsOf; + +export const AccordionRoot = createComponent({ + id: 'Accordion', + className: () => styles().root(), + baseElement: AC.Root, +}); + +export const AccordionContent = createComponent< + AccordionContentProps, + typeof AC.Content +>({ + id: 'AccordionContent', + className: () => styles().content(), + baseElement: AC.Content, +}); + +export const AccordionItem = createComponent< + AccordionItemProps, + typeof AC.Item +>({ + id: 'AccordionItem', + className: () => styles().item(), + baseElement: AC.Item, +}); + +export const AccordionHeader = createComponent< + AccordionHeaderProps, + typeof AC.Header +>({ + id: 'AccordionHeader', + className: () => styles().header(), + baseElement: AC.Header, +}); + +export const AccordionTrigger = createComponent< + AccordionTriggerProps, + typeof AC.Trigger +>({ + id: 'AccordionTrigger', + baseElement: AC.Trigger, + render: (Comp, { children, className, ...props }) => { + const classes = styles(); + return ( + + + {children} + + + + ); + }, +}); + +export const Accordion = withNamespace(AccordionRoot, { + Item: AccordionItem, + Content: AccordionContent, + Trigger: AccordionTrigger, +}); diff --git a/packages/ui/src/components/Accordion/index.tsx b/packages/ui/src/components/Accordion/index.tsx new file mode 100644 index 000000000..9313c46eb --- /dev/null +++ b/packages/ui/src/components/Accordion/index.tsx @@ -0,0 +1,18 @@ +'use client'; + +export { + Accordion, + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionRoot, + AccordionTrigger, +} from './Accordion'; + +export type { + AccordionContentProps, + AccordionHeaderProps, + AccordionItemProps, + AccordionProps, + AccordionTriggerProps, +} from './Accordion'; diff --git a/packages/ui/src/components/Accordion/styles.ts b/packages/ui/src/components/Accordion/styles.ts new file mode 100644 index 000000000..7e0f1686f --- /dev/null +++ b/packages/ui/src/components/Accordion/styles.ts @@ -0,0 +1,23 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + root: 'max-w-full', + content: [ + 'overflow-hidden p-4 text-color', + 'state-open:animate-accordion-open', + 'state-closed:animate-accordion-closed', + ], + item: ['overflow-hidden rounded-none not-first:mt-1'], + trigger: [ + 'group bg-card-bg rounded-md transition-colors px-4 flex text-lg font-medium', + 'w-full h-[45px] items-center justify-between border border-border', + 'focus:outline-none text-heading hover:text-accent', + 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent', + ], + header: 'flex', + icon: [ + 'transition-transform text-icon group-hover:rotate-180 group-state-open:rotate-180', + ], + }, +}); diff --git a/packages/ui/src/components/Alert/Alert.stories.tsx b/packages/ui/src/components/Alert/Alert.stories.tsx new file mode 100644 index 000000000..dadb3d907 --- /dev/null +++ b/packages/ui/src/components/Alert/Alert.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconAlertCircle } from '@tabler/icons-react'; + +import { Icon } from '../Icon/Icon'; + +import { Alert } from './Alert'; + +const meta: Meta = { + title: 'UI/Alert', + component: Alert, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + You will need admin privileges to install and access this application. + + + ), +}; diff --git a/packages/ui/src/components/Alert/Alert.tsx b/packages/ui/src/components/Alert/Alert.tsx new file mode 100644 index 000000000..ad94e3400 --- /dev/null +++ b/packages/ui/src/components/Alert/Alert.tsx @@ -0,0 +1,31 @@ +import { Callout as RC } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type AlertProps = PropsOf; +export type AlertIconProps = PropsOf; +export type AlertTextProps = PropsOf; + +export const AlertRoot = createComponent({ + id: 'Alert', + baseElement: RC.Root, + defaultProps: { + color: 'blue', + }, +}); + +export const AlertIcon = createComponent({ + id: 'AlertIcon', + baseElement: RC.Icon, +}); + +export const AlertText = createComponent({ + id: 'AlertText', + baseElement: RC.Text, +}); + +export const Alert = withNamespace(AlertRoot, { + Icon: AlertIcon, + Text: AlertText, +}); diff --git a/packages/ui/src/components/Alert/index.tsx b/packages/ui/src/components/Alert/index.tsx new file mode 100644 index 000000000..9f73dd22b --- /dev/null +++ b/packages/ui/src/components/Alert/index.tsx @@ -0,0 +1,5 @@ +'use client'; + +export { Alert, AlertIcon, AlertRoot, AlertText } from './Alert'; + +export type { AlertIconProps, AlertProps, AlertTextProps } from './Alert'; diff --git a/packages/ui/src/components/AlertDialog/AlertDialog.stories.tsx b/packages/ui/src/components/AlertDialog/AlertDialog.stories.tsx new file mode 100644 index 000000000..e2d56e1cf --- /dev/null +++ b/packages/ui/src/components/AlertDialog/AlertDialog.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HStack } from '../Box'; +import { Button } from '../Button/Button'; + +import { AlertDialog } from './AlertDialog'; + +const meta: Meta = { + title: 'Overlay/AlertDialog', + component: AlertDialog, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + Revoke access + + Are you sure? This application will no longer be accessible and any + existing sessions will be expired. + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/AlertDialog/AlertDialog.tsx b/packages/ui/src/components/AlertDialog/AlertDialog.tsx new file mode 100644 index 000000000..f0beca084 --- /dev/null +++ b/packages/ui/src/components/AlertDialog/AlertDialog.tsx @@ -0,0 +1,77 @@ +import { AlertDialog as RAD } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type AlertDialogProps = PropsOf; +export type AlertDialogTriggerProps = PropsOf; +export type AlertDialogContentProps = PropsOf; +export type AlertDialogTitleProps = PropsOf; +export type AlertDialogDescriptionProps = PropsOf; +export type AlertDialogActionProps = PropsOf; +export type AlertDialogCancelProps = PropsOf; + +export const AlertDialogRoot = createComponent< + AlertDialogProps, + typeof RAD.Root +>({ + id: 'AlertDialog', + baseElement: RAD.Root, +}); + +export const AlertDialogTrigger = createComponent< + AlertDialogTriggerProps, + typeof RAD.Trigger +>({ + id: 'AlertDialogTrigger', + baseElement: RAD.Trigger, +}); + +export const AlertDialogContent = createComponent< + AlertDialogContentProps, + typeof RAD.Content +>({ + id: 'AlertDialogContent', + baseElement: RAD.Content, +}); + +export const AlertDialogTitle = createComponent< + AlertDialogTitleProps, + typeof RAD.Title +>({ + id: 'AlertDialogTitle', + baseElement: RAD.Title, +}); + +export const AlertDialogDescription = createComponent< + AlertDialogDescriptionProps, + typeof RAD.Description +>({ + id: 'AlertDialogDescription', + baseElement: RAD.Description, +}); + +export const AlertDialogAction = createComponent< + AlertDialogActionProps, + typeof RAD.Action +>({ + id: 'AlertDialogAction', + baseElement: RAD.Action, +}); + +export const AlertDialogCancel = createComponent< + AlertDialogCancelProps, + typeof RAD.Cancel +>({ + id: 'AlertDialogCancel', + baseElement: RAD.Cancel, +}); + +export const AlertDialog = withNamespace(AlertDialogRoot, { + Trigger: AlertDialogTrigger, + Content: AlertDialogContent, + Title: AlertDialogTitle, + Description: AlertDialogDescription, + Action: AlertDialogAction, + Cancel: AlertDialogCancel, +}); diff --git a/packages/ui/src/components/AlertDialog/index.tsx b/packages/ui/src/components/AlertDialog/index.tsx new file mode 100644 index 000000000..f5014f83e --- /dev/null +++ b/packages/ui/src/components/AlertDialog/index.tsx @@ -0,0 +1,20 @@ +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogRoot, + AlertDialogTitle, + AlertDialogTrigger, +} from './AlertDialog'; + +export type { + AlertDialogActionProps, + AlertDialogCancelProps, + AlertDialogContentProps, + AlertDialogDescriptionProps, + AlertDialogProps, + AlertDialogTitleProps, + AlertDialogTriggerProps, +} from './AlertDialog'; diff --git a/packages/ui/src/components/AspectRatio/AspectRatio.stories.tsx b/packages/ui/src/components/AspectRatio/AspectRatio.stories.tsx new file mode 100644 index 000000000..d1d3feb30 --- /dev/null +++ b/packages/ui/src/components/AspectRatio/AspectRatio.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Box } from '../Box'; + +import { AspectRatio } from './AspectRatio'; + +const meta: Meta = { + title: 'UI/AspectRatio', + component: AspectRatio, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + A house in a forest + + + ), +}; diff --git a/packages/ui/src/components/AspectRatio/AspectRatio.tsx b/packages/ui/src/components/AspectRatio/AspectRatio.tsx new file mode 100644 index 000000000..00e28dbe3 --- /dev/null +++ b/packages/ui/src/components/AspectRatio/AspectRatio.tsx @@ -0,0 +1,15 @@ +/// +import { AspectRatio as RadixAspectRatio } from '@radix-ui/themes'; + +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type AspectRatioProps = PropsOf; + +export const AspectRatio = createComponent< + AspectRatioProps, + typeof RadixAspectRatio +>({ + id: 'AspectRatio', + baseElement: RadixAspectRatio, +}); diff --git a/packages/ui/src/components/AspectRatio/index.tsx b/packages/ui/src/components/AspectRatio/index.tsx new file mode 100644 index 000000000..6d9841f82 --- /dev/null +++ b/packages/ui/src/components/AspectRatio/index.tsx @@ -0,0 +1,3 @@ +export { AspectRatio } from './AspectRatio'; + +export type { AspectRatioProps } from './AspectRatio'; diff --git a/packages/ui/src/components/Asset/Asset.stories.tsx b/packages/ui/src/components/Asset/Asset.stories.tsx new file mode 100644 index 000000000..3ff73d257 --- /dev/null +++ b/packages/ui/src/components/Asset/Asset.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconCurrencyEthereum } from '@tabler/icons-react'; + +import { HStack, VStack } from '../Box'; +import { Icon } from '../Icon/Icon'; + +import { Asset } from './Asset'; +import { MOCK_ASSETS } from './__mocks__/assets'; + +const meta: Meta = { + title: 'Web3/Asset', + component: Asset, +}; + +export default meta; +type Story = StoryObj; + +const DEFAULT_ARGS = { + asset: MOCK_ASSETS.eth, + amount: '1000000000', +}; + +export const Usage: Story = { + render: (args) => ( + + + + + + + + + ), +}; + +export const IconName: Story = { + name: 'Icon + Name', + render: (args) => ( + + + + + ), +}; + +export const CustomIcon: Story = { + render: (args) => ( + + } /> + + + ), +}; + +export const NoIcon: Story = { + render: (args) => ( + + + + + ), +}; + +export const AmountSymbol: Story = { + name: 'Amount + Symbol', + render: (args) => ( + + + + + ), +}; + +const AMOUNT_ARGS = { + asset: MOCK_ASSETS.eth, + amount: '1000000001', + precision: 9, +}; +export const AmountExamples: Story = { + render: (args) => ( + + + + + + + + + + + ), +}; + +export const Sizes: Story = { + render: (args) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Asset/Asset.tsx b/packages/ui/src/components/Asset/Asset.tsx new file mode 100644 index 000000000..be89b586b --- /dev/null +++ b/packages/ui/src/components/Asset/Asset.tsx @@ -0,0 +1,189 @@ +import { bn } from '@fuel-ts/math'; +import type { BNInput } from '@fuel-ts/math'; +import { IconArrowDown, IconArrowUp } from '@tabler/icons-react'; +import type { ReactNode } from 'react'; + +import { useStrictedChildren } from '../../hooks/useStrictedChildren'; +import { createComponent, withNamespace } from '../../utils/component'; +import { cx } from '../../utils/css'; +import type { PropsOf } from '../../utils/types'; +import { Badge } from '../Badge/Badge'; +import { Box, HStack } from '../Box'; +import type { HStackProps } from '../Box'; +import type { TextProps } from '../Text/Text'; +import { Text } from '../Text/Text'; + +import { AssetProvider, useAssetContext } from './useAssetContext'; + +export type AssetIconSize = 'xs' | 'sm' | 'md' | 'lg'; + +export type AssetObj = { + name: string; + symbol: string; + imageUrl?: string; +}; + +export type AssetProps = HStackProps & { + asset: AssetObj; + amount?: BNInput; + units?: number; + precision?: number; + iconSize?: AssetIconSize | number; + negative?: boolean; + hideIcon?: boolean; +}; + +export type AssetIconProps = Omit< + Omit, 'width' | 'height' | 'color'> & { icon?: ReactNode }, + 'children' +>; + +export type AssetNameProps = Omit, 'children'>; +export type AssetSymbolProps = PropsOf<'span'>; +export type AssetAmountProps = Omit< + TextProps, + 'leftIcon' | 'rightIcon' | 'iconColor' +>; + +const CHILD_ITEMS = [ + 'AssetIcon', + 'AssetSymbol', + 'AssetName', + 'AssetAmount', + 'HStack', + 'VStack', +]; + +export const AssetRoot = createComponent({ + id: 'AssetRoot', + render: ( + _, + { + asset, + amount, + units, + precision, + iconSize, + negative, + hideIcon, + children, + gap = '4', + ...props + }, + ) => { + { + const newChildren = useStrictedChildren('Asset', CHILD_ITEMS, children); + const amountStr = bn(amount).format({ units, precision }); + const isNegative = negative || bn(amount).lt(0); + return ( + + + {newChildren} + + + ); + } + }, +}); + +const SIZES_MAP = { + xs: 20, + sm: 24, + md: 32, + lg: 40, +}; + +export const AssetIcon = createComponent({ + id: 'AssetIcon', + render: (_, { icon, ...props }) => { + const { asset, iconSize = 'md' } = useAssetContext(); + const size = typeof iconSize === 'string' ? SIZES_MAP[iconSize] : iconSize; + + if (icon) { + return ( + + {icon} + + ); + } + + if (!asset.imageUrl) { + return ( + + {asset.symbol.slice(0, 2).toUpperCase()} + + ); + } + + return ( + {`${asset.name} + ); + }, +}); + +export const AssetSymbol = createComponent({ + id: 'AssetSymbol', + render: (_, props) => { + const assetProps = useAssetContext(); + return {assetProps.asset.symbol}; + }, +}); + +export const AssetName = createComponent({ + id: 'AssetName', + render: (_, props) => { + const assetProps = useAssetContext(); + return {assetProps.asset.name}; + }, +}); + +export const AssetAmount = createComponent({ + id: 'AssetAmount', + render: (_, props) => { + const { hideIcon, amountStr, isNegative } = useAssetContext(); + return ( + + {amountStr} + + ); + }, +}); + +export const Asset = withNamespace(AssetRoot, { + Amount: AssetAmount, + Icon: AssetIcon, + Name: AssetName, + Symbol: AssetSymbol, +}); diff --git a/packages/ui/src/components/Asset/__mocks__/assets.ts b/packages/ui/src/components/Asset/__mocks__/assets.ts new file mode 100644 index 000000000..337a5cef8 --- /dev/null +++ b/packages/ui/src/components/Asset/__mocks__/assets.ts @@ -0,0 +1,7 @@ +export const MOCK_ASSETS = { + eth: { + symbol: 'ETH', + name: 'Ethereum', + imageUrl: '/assets/eth.svg', + }, +}; diff --git a/packages/ui/src/components/Asset/index.tsx b/packages/ui/src/components/Asset/index.tsx new file mode 100644 index 000000000..2879a4076 --- /dev/null +++ b/packages/ui/src/components/Asset/index.tsx @@ -0,0 +1,22 @@ +'use client'; + +export { + Asset, + AssetAmount, + AssetIcon, + AssetName, + AssetRoot, + AssetSymbol, +} from './Asset'; + +export type { + AssetAmountProps, + AssetIconProps, + AssetIconSize, + AssetNameProps, + AssetObj, + AssetProps, + AssetSymbolProps, +} from './Asset'; + +export { AssetProvider, useAssetContext } from './useAssetContext'; diff --git a/packages/ui/src/components/Asset/useAssetContext.tsx b/packages/ui/src/components/Asset/useAssetContext.tsx new file mode 100644 index 000000000..76fd7ed53 --- /dev/null +++ b/packages/ui/src/components/Asset/useAssetContext.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { AssetProps } from './Asset'; + +type ContextProps = AssetProps & { + amountStr: string; + isNegative: boolean; +}; + +const ctx = createContext({} as ContextProps); +export function useAssetContext() { + return useContext(ctx); +} + +export const AssetProvider = ctx.Provider; diff --git a/packages/ui/src/components/Avatar/Avatar.stories.tsx b/packages/ui/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 000000000..3275edb32 --- /dev/null +++ b/packages/ui/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HStack } from '../Box'; + +import { Avatar } from './Avatar'; + +const meta: Meta = { + title: 'UI/Avatar', + component: Avatar, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + ), +}; diff --git a/packages/ui/src/components/Avatar/Avatar.tsx b/packages/ui/src/components/Avatar/Avatar.tsx new file mode 100644 index 000000000..eef8a5ca7 --- /dev/null +++ b/packages/ui/src/components/Avatar/Avatar.tsx @@ -0,0 +1,14 @@ +import { Avatar as RadixAvatar } from '@radix-ui/themes'; + +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type AvatarProps = PropsOf; + +export const Avatar = createComponent({ + id: 'Avatar', + baseElement: RadixAvatar, + defaultProps: { + radius: 'full', + }, +}); diff --git a/packages/ui/src/components/Avatar/index.tsx b/packages/ui/src/components/Avatar/index.tsx new file mode 100644 index 000000000..d3eb3b2b9 --- /dev/null +++ b/packages/ui/src/components/Avatar/index.tsx @@ -0,0 +1,3 @@ +export { Avatar } from './Avatar'; + +export type { AvatarProps } from './Avatar'; diff --git a/packages/ui/src/components/Badge/Badge.stories.tsx b/packages/ui/src/components/Badge/Badge.stories.tsx new file mode 100644 index 000000000..1b951d25b --- /dev/null +++ b/packages/ui/src/components/Badge/Badge.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HStack } from '../Box'; +import { ButtonClose } from '../ButtonClose/ButtonClose'; + +import { Badge } from './Badge'; + +const meta: Meta = { + title: 'UI/Badge', + component: Badge, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + In progress + In review + Complete + + ), +}; + +export const WithClose: Story = { + render: () => ( + + Selected + + ), +}; diff --git a/packages/ui/src/components/Badge/Badge.tsx b/packages/ui/src/components/Badge/Badge.tsx new file mode 100644 index 000000000..68f76b1b1 --- /dev/null +++ b/packages/ui/src/components/Badge/Badge.tsx @@ -0,0 +1,11 @@ +import { Badge as RadixBadge } from '@radix-ui/themes'; + +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type BadgeProps = PropsOf; + +export const Badge = createComponent({ + id: 'Badge', + baseElement: RadixBadge, +}); diff --git a/packages/ui/src/components/Badge/index.tsx b/packages/ui/src/components/Badge/index.tsx new file mode 100644 index 000000000..8451ab604 --- /dev/null +++ b/packages/ui/src/components/Badge/index.tsx @@ -0,0 +1,3 @@ +export { Badge } from './Badge'; + +export type { BadgeProps } from './Badge'; diff --git a/packages/ui/src/components/Box/Box.stories.tsx b/packages/ui/src/components/Box/Box.stories.tsx new file mode 100644 index 000000000..a7d56439e --- /dev/null +++ b/packages/ui/src/components/Box/Box.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Box } from './Box'; +import { Flex } from './Flex'; +import type { FlexProps } from './Flex'; + +const meta: Meta = { + title: 'Layout/Box', + component: Box, +}; + +export default meta; +type Story = StoryObj; + +const DecorativeBox = (props: FlexProps) => { + return ( + + ); +}; + +export const Usage: Story = { + render: () => ( + + + + ), +}; + +export const AsChild: Story = { + name: 'AsChild', + render: () => ( + + I'm a span + + ), +}; + +export const Polymorphic: Story = { + render: () => I'm a span, +}; diff --git a/packages/ui/src/components/Box/Box.tsx b/packages/ui/src/components/Box/Box.tsx new file mode 100644 index 000000000..4632bd6f3 --- /dev/null +++ b/packages/ui/src/components/Box/Box.tsx @@ -0,0 +1,11 @@ +import { Box as RadixBox } from '@radix-ui/themes'; + +import { createPolymorphicComponent } from '../../utils/component'; +import type { PropsOf, WithAsProps } from '../../utils/types'; + +export type BoxProps = WithAsProps & PropsOf; + +export const Box = createPolymorphicComponent({ + id: 'Box', + baseElement: RadixBox, +}); diff --git a/packages/ui/src/components/Box/Container.stories.tsx b/packages/ui/src/components/Box/Container.stories.tsx new file mode 100644 index 000000000..b69fa567a --- /dev/null +++ b/packages/ui/src/components/Box/Container.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Box } from './Box'; +import type { ContainerProps } from './Container'; +import { Container } from './Container'; + +const meta: Meta = { + title: 'Layout/Container', + component: Container, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +const DecorativeBox = (props: ContainerProps) => { + return ( + + ); +}; + +export const UsageContainer: Story = { + name: 'Container', + render: () => ( + + + + + + ), +}; diff --git a/packages/ui/src/components/Box/Container.tsx b/packages/ui/src/components/Box/Container.tsx new file mode 100644 index 000000000..6905e837b --- /dev/null +++ b/packages/ui/src/components/Box/Container.tsx @@ -0,0 +1,14 @@ +import { Container as RadixContainer } from '@radix-ui/themes'; + +import { createPolymorphicComponent } from '../../utils/component'; +import type { PropsOf, WithAsProps } from '../../utils/types'; + +export type ContainerProps = WithAsProps & PropsOf; + +export const Container = createPolymorphicComponent< + ContainerProps, + typeof RadixContainer +>({ + id: 'Container', + baseElement: RadixContainer, +}); diff --git a/packages/ui/src/components/Box/Flex.stories.tsx b/packages/ui/src/components/Box/Flex.stories.tsx new file mode 100644 index 000000000..a15d10601 --- /dev/null +++ b/packages/ui/src/components/Box/Flex.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import type { FlexProps } from './'; +import { Box, Flex } from './'; + +const meta: Meta = { + title: 'Layout/Flex', + component: Flex, +}; + +export default meta; +type Story = StoryObj; + +const DecorativeBox = (props: FlexProps) => { + return ( + + ); +}; + +export const Usage: Story = { + name: 'Flex', + render: () => ( + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Box/Flex.tsx b/packages/ui/src/components/Box/Flex.tsx new file mode 100644 index 000000000..3c0b3dd88 --- /dev/null +++ b/packages/ui/src/components/Box/Flex.tsx @@ -0,0 +1,11 @@ +import { Flex as RadixFlex } from '@radix-ui/themes'; + +import { createPolymorphicComponent } from '../../utils/component'; +import type { PropsOf, WithAsProps } from '../../utils/types'; + +export type FlexProps = WithAsProps & PropsOf; + +export const Flex = createPolymorphicComponent({ + id: 'Flex', + baseElement: RadixFlex, +}); diff --git a/packages/ui/src/components/Box/Grid.stories.tsx b/packages/ui/src/components/Box/Grid.stories.tsx new file mode 100644 index 000000000..7aa3db491 --- /dev/null +++ b/packages/ui/src/components/Box/Grid.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import type { GridProps } from './'; +import { Box, Grid } from './'; + +const meta: Meta = { + title: 'Layout/Grid', + component: Grid, +}; + +export default meta; +type Story = StoryObj; + +const DecorativeBox = (props: GridProps) => { + return ( + + ); +}; + +export const Usage: Story = { + name: 'Grid', + render: () => ( + + + + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Box/Grid.tsx b/packages/ui/src/components/Box/Grid.tsx new file mode 100644 index 000000000..9d1579e77 --- /dev/null +++ b/packages/ui/src/components/Box/Grid.tsx @@ -0,0 +1,11 @@ +import { Grid as RadixGrid } from '@radix-ui/themes'; + +import { createPolymorphicComponent } from '../../utils/component'; +import type { PropsOf, WithAsProps } from '../../utils/types'; + +export type GridProps = WithAsProps & PropsOf; + +export const Grid = createPolymorphicComponent({ + id: 'Grid', + baseElement: RadixGrid, +}); diff --git a/packages/ui/src/components/Box/HStack.stories.tsx b/packages/ui/src/components/Box/HStack.stories.tsx new file mode 100644 index 000000000..c4feeeb91 --- /dev/null +++ b/packages/ui/src/components/Box/HStack.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import type { HStackProps } from './'; +import { Box, HStack } from './'; + +const meta: Meta = { + title: 'Layout/HStack', + component: HStack, +}; + +export default meta; +type Story = StoryObj; + +const DecorativeBox = (props: HStackProps) => { + return ( + + ); +}; + +export const Usage: Story = { + name: 'HStack', + render: () => ( + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Box/HStack.tsx b/packages/ui/src/components/Box/HStack.tsx new file mode 100644 index 000000000..792677848 --- /dev/null +++ b/packages/ui/src/components/Box/HStack.tsx @@ -0,0 +1,17 @@ +import { Flex as RadixFlex } from '@radix-ui/themes'; + +import { createPolymorphicComponent } from '../../utils/component'; + +import type { FlexProps } from './Flex'; + +export type HStackProps = Omit; + +export const HStack = createPolymorphicComponent( + { + id: 'HStack', + baseElement: RadixFlex, + defaultProps: { + gap: '4', + } as FlexProps, + }, +); diff --git a/packages/ui/src/components/Box/VStack.stories.tsx b/packages/ui/src/components/Box/VStack.stories.tsx new file mode 100644 index 000000000..b3d69c3fc --- /dev/null +++ b/packages/ui/src/components/Box/VStack.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import type { VStackProps } from './'; +import { Box, VStack } from './'; + +const meta: Meta = { + title: 'Layout/VStack', + component: VStack, +}; + +export default meta; +type Story = StoryObj; + +const DecorativeBox = (props: VStackProps) => { + return ( + + ); +}; + +export const Usage: Story = { + name: 'VStack', + render: () => ( + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Box/VStack.tsx b/packages/ui/src/components/Box/VStack.tsx new file mode 100644 index 000000000..54d841549 --- /dev/null +++ b/packages/ui/src/components/Box/VStack.tsx @@ -0,0 +1,18 @@ +import { Flex as RadixFlex } from '@radix-ui/themes'; + +import { createPolymorphicComponent } from '../../utils/component'; + +import type { FlexProps } from './Flex'; + +export type VStackProps = Omit; + +export const VStack = createPolymorphicComponent( + { + id: 'VStack', + baseElement: RadixFlex, + defaultProps: { + direction: 'column', + gap: '4', + } as FlexProps, + }, +); diff --git a/packages/ui/src/components/Box/index.tsx b/packages/ui/src/components/Box/index.tsx new file mode 100644 index 000000000..d0dc5bdda --- /dev/null +++ b/packages/ui/src/components/Box/index.tsx @@ -0,0 +1,17 @@ +export { Box } from './Box'; +export type { BoxProps } from './Box'; + +export { Flex } from './Flex'; +export type { FlexProps } from './Flex'; + +export { Grid } from './Grid'; +export type { GridProps } from './Grid'; + +export { Container } from './Container'; +export type { ContainerProps } from './Container'; + +export { VStack } from './VStack'; +export type { VStackProps } from './VStack'; + +export { HStack } from './HStack'; +export type { HStackProps } from './HStack'; diff --git a/packages/ui/src/components/Breadcrumb/Breadcrumb.stories.tsx b/packages/ui/src/components/Breadcrumb/Breadcrumb.stories.tsx new file mode 100644 index 000000000..d4e664d2a --- /dev/null +++ b/packages/ui/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconHome } from '@tabler/icons-react'; + +import { Icon } from '../Icon/Icon'; + +import { Breadcrumb } from './Breadcrumb'; + +const meta: Meta = { + title: 'UI/Breadcrumb', + component: Breadcrumb, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: (args) => ( + + + + + Components + Dropdown + + ), +}; diff --git a/packages/ui/src/components/Breadcrumb/Breadcrumb.tsx b/packages/ui/src/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 000000000..e5d3424c0 --- /dev/null +++ b/packages/ui/src/components/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,63 @@ +import { IconChevronRight } from '@tabler/icons-react'; +import { Children } from 'react'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; +import { HStack } from '../Box'; +import type { HStackProps } from '../Box'; +import { Icon } from '../Icon/Icon'; +import { Link } from '../Link/Link'; +import type { LinkProps } from '../Link/Link'; + +export type BreadcrumbProps = PropsOf<'ul'> & Omit; +export type BreadcrumbItemProps = PropsOf<'li'>; +export type BreadcrumbLinkProps = LinkProps; + +export const BreadcrumbRoot = createComponent({ + id: 'Breadcrumb', + baseElement: 'ul', + render: (Comp, { children, ...props }) => { + const newChildren = Children.toArray(children).flatMap((child, index) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const id = (child as any)?.type?.id; + if (id !== 'BreadcrumbItem' && id !== 'BreadcrumbLink') { + throw new Error( + 'Breadcrumb only accepts Breadcrumb.Item or Breadcrumb.Link as children', + ); + } + + const count = Children.count(children); + if (index < count - 1) { + return [ + child, +
  • + +
  • , + ]; + } + return child; + }); + return ( + + {newChildren} + + ); + }, +}); + +export const BreadcrumbItem = createComponent({ + id: 'BreadcrumbItem', + baseElement: 'li', +}); + +export const BreadcrumbLink = createComponent( + { + id: 'BreadcrumbLink', + baseElement: Link, + }, +); + +export const Breadcrumb = withNamespace(BreadcrumbRoot, { + Item: BreadcrumbItem, + Link: BreadcrumbLink, +}); diff --git a/packages/ui/src/components/Breadcrumb/index.tsx b/packages/ui/src/components/Breadcrumb/index.tsx new file mode 100644 index 000000000..49cd69c78 --- /dev/null +++ b/packages/ui/src/components/Breadcrumb/index.tsx @@ -0,0 +1,12 @@ +export { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbRoot, +} from './Breadcrumb'; + +export type { + BreadcrumbItemProps, + BreadcrumbLinkProps, + BreadcrumbProps, +} from './Breadcrumb'; diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx new file mode 100644 index 000000000..255b27e2b --- /dev/null +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -0,0 +1,76 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { IconBookmark } from '@tabler/icons-react'; + +import { HStack } from '../Box'; + +import { Button } from './Button'; + +const meta: Meta = { + title: 'UI/Button', + component: Button, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => , +}; + +export const Variant: Story = { + render: () => ( + + + + + + + ), +}; + +export const Colors: Story = { + render: () => ( + + + + + + + ), +}; + +export const Sizes: Story = { + render: () => ( + + + + + + + ), +}; + +export const Loading: Story = { + render: () => , +}; + +export const onClick: Story = { + render: () => , +}; diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx new file mode 100644 index 000000000..fdd5faf10 --- /dev/null +++ b/packages/ui/src/components/Button/Button.tsx @@ -0,0 +1,24 @@ +import { Button as RadixButton } from '@radix-ui/themes'; + +import type { WithIconProps } from '../../hooks/useIconProps'; +import { useIconProps } from '../../hooks/useIconProps'; +import type { WithVariants } from '../../hooks/useVariants'; +import { useVariants } from '../../hooks/useVariants'; +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +type RadixButtonProps = PropsOf; +export type ButtonProps = WithVariants & WithIconProps; + +export const Button = createComponent({ + id: 'Button', + render: (_, props) => { + const itemProps = useIconProps(props); + const variantProps = useVariants(props); + return ; + }, + defaultProps: { + size: '2', + variant: 'solid', + }, +}); diff --git a/packages/ui/src/components/Button/index.tsx b/packages/ui/src/components/Button/index.tsx new file mode 100644 index 000000000..5daf68915 --- /dev/null +++ b/packages/ui/src/components/Button/index.tsx @@ -0,0 +1,3 @@ +export { Button } from './Button'; + +export type { ButtonProps } from './Button'; diff --git a/packages/ui/src/components/ButtonClose/ButtonClose.tsx b/packages/ui/src/components/ButtonClose/ButtonClose.tsx new file mode 100644 index 000000000..b3ae149d1 --- /dev/null +++ b/packages/ui/src/components/ButtonClose/ButtonClose.tsx @@ -0,0 +1,19 @@ +import { IconX } from '@tabler/icons-react'; + +import { createComponent } from '../../utils/component'; +import type { IconButtonProps } from '../IconButton/IconButton'; +import { IconButton } from '../IconButton/IconButton'; + +export type ButtonCloseProps = Partial; + +export const ButtonClose = createComponent( + { + id: 'ButtonClose', + baseElement: IconButton, + defaultProps: { + 'aria-label': 'Close', + icon: IconX, + variant: 'link', + }, + }, +); diff --git a/packages/ui/src/components/ButtonClose/index.tsx b/packages/ui/src/components/ButtonClose/index.tsx new file mode 100644 index 000000000..9232ba563 --- /dev/null +++ b/packages/ui/src/components/ButtonClose/index.tsx @@ -0,0 +1,3 @@ +export { ButtonClose } from './ButtonClose'; + +export type { ButtonCloseProps } from './ButtonClose'; diff --git a/packages/ui/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/ui/src/components/ButtonGroup/ButtonGroup.stories.tsx new file mode 100644 index 000000000..5a1b7dff1 --- /dev/null +++ b/packages/ui/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconCalendar } from '@tabler/icons-react'; + +import { Button } from '../Button/Button'; + +import { ButtonGroup } from './ButtonGroup'; + +const meta: Meta = { + title: 'UI/ButtonGroup', + component: ButtonGroup, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: (args) => ( + + + + + + ), +}; diff --git a/packages/ui/src/components/ButtonGroup/ButtonGroup.tsx b/packages/ui/src/components/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 000000000..1675be52a --- /dev/null +++ b/packages/ui/src/components/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,52 @@ +import type { ReactElement } from 'react'; +import { Children, cloneElement } from 'react'; +import { mergeProps } from 'react-aria'; + +import { createComponent } from '../../utils/component'; +import { pick } from '../../utils/helpers'; +import type { PropsOf } from '../../utils/types'; +import type { ButtonProps } from '../Button/Button'; +import { Focus } from '../Focus/Focus'; + +import { styles } from './styles'; + +type PropsToOmit = + | 'className' + | 'onClick' + | 'iconSize' + | 'leftIcon' + | 'leftIconAriaLabel' + | 'rightIcon' + | 'rightIconAriaLabel' + | 'isLoading' + | 'loadingText' + | 'isDisabled' + | 'justIcon' + | 'isLink' + | 'onClick'; + +export type ButtonGroupProps = Omit & PropsOf<'div'>; + +const BUTTON_BASE_PROPS = ['size', 'color', 'variant', 'isDisabled', 'intent']; + +export const ButtonGroup = createComponent({ + id: 'ButtonGroup', + baseElement: 'div', + className: () => styles().root(), + render: (Comp, { children, ...props }) => { + const buttons = (Children.toArray(children) as ReactElement[]).map( + (child: ReactElement) => + cloneElement( + child, + mergeProps(child.props, pick(BUTTON_BASE_PROPS, props), { + className: styles().button(), + }), + ), + ); + return ( + + {buttons} + + ); + }, +}); diff --git a/packages/ui/src/components/ButtonGroup/index.tsx b/packages/ui/src/components/ButtonGroup/index.tsx new file mode 100644 index 000000000..cb16e523f --- /dev/null +++ b/packages/ui/src/components/ButtonGroup/index.tsx @@ -0,0 +1,5 @@ +'use client'; + +export { ButtonGroup } from './ButtonGroup'; + +export type { ButtonGroupProps } from './ButtonGroup'; diff --git a/packages/ui/src/components/ButtonGroup/styles.ts b/packages/ui/src/components/ButtonGroup/styles.ts new file mode 100644 index 000000000..0a06fe8c5 --- /dev/null +++ b/packages/ui/src/components/ButtonGroup/styles.ts @@ -0,0 +1,14 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + root: ['flex items-center'], + button: [ + 'not-first:ml-px', + 'first-type:rounded-tr-none first-type:rounded-br-none', + 'last-type:rounded-tl-none last-type:rounded-bl-none', + 'not-first-last:rounded-none', + 'focus-visible:z-10 focus-visible:relative', + ], + }, +}); diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx new file mode 100644 index 000000000..fa64f3651 --- /dev/null +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconCalendar } from '@tabler/icons-react'; + +import { Button } from '../Button/Button'; +import { Icon } from '../Icon/Icon'; + +import { Card } from './Card'; + +const meta: Meta = { + title: 'UI/Card', + component: Card, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + Hello world from Fuel + + ), +}; + +export const FullVersion: Story = { + render: () => ( + + + + + Calendar + + This is a subtitle + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut vulputate + rutrum est non sollicitudin. Donec tortor ligula, bibendum ac luctus ac, + efficitur a sem. + + + + + + + ), +}; + +export const AsChild: Story = { + name: 'AsChild', + render: () => ( + +
    + Hello world from Fuel +
    +
    + ), +}; + +export const Polymorphic: Story = { + render: () => ( + + Hello world from Fuel + + ), +}; diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx new file mode 100644 index 000000000..0fa2b3c5c --- /dev/null +++ b/packages/ui/src/components/Card/Card.tsx @@ -0,0 +1,85 @@ +import { + createComponent, + createPolymorphicComponent, + withNamespace, +} from '../../utils/component'; +import type { BoxProps } from '../Box'; +import { Box } from '../Box'; +import type { HeadingProps } from '../Heading'; +import { Heading } from '../Heading'; +import { Text } from '../Text'; +import type { TextProps } from '../Text'; + +import { styles } from './styles'; + +export type CardProps = BoxProps; +export type CardHeaderProps = BoxProps; +export type CardTitleProps = HeadingProps; +export type CardBodyProps = BoxProps; +export type CardDescriptionProps = TextProps; +export type CardFooterProps = BoxProps; + +export const CardRoot = createPolymorphicComponent({ + id: 'Card', + baseElement: Box, + className: () => styles().root(), + defaultProps: { + as: 'article', + }, +}); + +export const CardHeader = createPolymorphicComponent< + CardHeaderProps, + typeof Box +>({ + id: 'CardHeader', + baseElement: Box, + className: () => styles().header(), + defaultProps: { + as: 'header', + }, +}); + +export const CardTitle = createComponent({ + id: 'CardTitle', + baseElement: Heading, + className: () => styles().title(), + defaultProps: { + size: '6', + }, +}); + +export const CardBody = createPolymorphicComponent({ + id: 'CardBody', + baseElement: Box, + className: () => styles().body(), +}); + +export const CardDescription = createComponent< + CardDescriptionProps, + typeof Text +>({ + id: 'CardDescription', + baseElement: Text, + className: () => styles().description(), +}); + +export const CardFooter = createPolymorphicComponent< + CardFooterProps, + typeof Box +>({ + id: 'CardFooter', + baseElement: Box, + className: () => styles().footer(), + defaultProps: { + as: 'footer', + }, +}); + +export const Card = withNamespace(CardRoot, { + Header: CardHeader, + Title: CardTitle, + Body: CardBody, + Description: CardDescription, + Footer: CardFooter, +}); diff --git a/packages/ui/src/components/Card/index.tsx b/packages/ui/src/components/Card/index.tsx new file mode 100644 index 000000000..d3116c391 --- /dev/null +++ b/packages/ui/src/components/Card/index.tsx @@ -0,0 +1,18 @@ +export { + Card, + CardBody, + CardDescription, + CardFooter, + CardHeader, + CardRoot, + CardTitle, +} from './Card'; + +export type { + CardBodyProps, + CardDescriptionProps, + CardFooterProps, + CardHeaderProps, + CardProps, + CardTitleProps, +} from './Card'; diff --git a/packages/ui/src/components/Card/styles.ts b/packages/ui/src/components/Card/styles.ts new file mode 100644 index 000000000..4da315783 --- /dev/null +++ b/packages/ui/src/components/Card/styles.ts @@ -0,0 +1,17 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + root: [ + 'flex flex-col py-4 gap-4 rounded-md bg-card-bg text-color overflow-clip', + 'border border-solid border-card-border', + ], + header: ['flex flex-col gap-1.5 px-4 text-heading'], + title: [ + 'm-0 font-semibold leading-none tracking-tight text-xl flex items-center gap-2', + ], + description: ['m-0 text-sm text-secondary'], + body: ['px-4 text-base'], + footer: ['px-4 pt-0 self-end flex gap-2'], + }, +}); diff --git a/packages/ui/src/components/CardList/CardList.stories.tsx b/packages/ui/src/components/CardList/CardList.stories.tsx new file mode 100644 index 000000000..562fc33b6 --- /dev/null +++ b/packages/ui/src/components/CardList/CardList.stories.tsx @@ -0,0 +1,51 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { IconArrowRight } from '@tabler/icons-react'; + +import { Avatar } from '../Avatar/Avatar'; +import { Heading } from '../Heading/Heading'; +import { IconButton } from '../IconButton/IconButton'; + +import { CardList } from './CardList'; + +const meta: Meta = { + title: 'UI/CardList', + component: CardList, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + } + onClick={action('onClick')} + > + + Colm Tuite + + + + Colm Tuite + + + ), +}; diff --git a/packages/ui/src/components/CardList/CardList.tsx b/packages/ui/src/components/CardList/CardList.tsx new file mode 100644 index 000000000..fd92a3858 --- /dev/null +++ b/packages/ui/src/components/CardList/CardList.tsx @@ -0,0 +1,75 @@ +import { createComponent, withNamespace } from '../../utils/component'; +import { HStack, VStack } from '../Box'; +import type { VStackProps } from '../Box'; +import { Card } from '../Card/Card'; +import type { CardProps } from '../Card/Card'; +import { Focus } from '../Focus/Focus'; + +import { styles } from './styles'; +import { CardListContext, useCardListContext } from './useCardListContext'; + +export type CardListProps = VStackProps & + Omit; + +export type CardListItemProps = CardProps & { + isActive?: boolean; + rightEl?: React.ReactNode; +}; + +export const CardListRoot = createComponent({ + id: 'CardList', + baseElement: VStack, + render: (Comp, { children, gap = '3', isClickable, autoFocus, ...props }) => { + return ( + + + {isClickable ? ( + + {children} + + ) : ( + children + )} + + + ); + }, +}); + +export const CardListItem = createComponent({ + id: 'CardListItem', + baseElement: Card, + render: ( + Comp, + { + className, + children, + isActive, + rightEl, + autoFocus: initAutoFocus, + ...props + }, + ) => { + const ctx = useCardListContext(); + const isClickable = Boolean(props.onClick || ctx.isClickable); + const classes = styles({ clickable: isClickable }); + return ( + + {isActive && } + + {children} + + {rightEl} + + ); + }, +}); + +export const CardList = withNamespace(CardListRoot, { + Item: CardListItem, +}); diff --git a/packages/ui/src/components/CardList/index.tsx b/packages/ui/src/components/CardList/index.tsx new file mode 100644 index 000000000..c80a2daf0 --- /dev/null +++ b/packages/ui/src/components/CardList/index.tsx @@ -0,0 +1,9 @@ +'use client'; + +export { CardList, CardListItem, CardListRoot } from './CardList'; + +export type { CardListItemProps, CardListProps } from './CardList'; + +export { CardListProvider, useCardListContext } from './useCardListContext'; + +export type { CardListContext } from './useCardListContext'; diff --git a/packages/ui/src/components/CardList/styles.ts b/packages/ui/src/components/CardList/styles.ts new file mode 100644 index 000000000..79faf626e --- /dev/null +++ b/packages/ui/src/components/CardList/styles.ts @@ -0,0 +1,21 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + root: [ + 'relative transition-colors duration-150 flex flex-row', + 'items-center px-5 focus:outline-none', + ], + activeMark: ['block absolute inset-0 bg-brand w-1 h-full'], + }, + variants: { + clickable: { + true: { + root: 'cursor-pointer focus:ring-2 focus:ring-gray-3 hover:border-border-hover', + }, + }, + }, + defaultVariants: { + clickable: false, + }, +}); diff --git a/packages/ui/src/components/CardList/useCardListContext.tsx b/packages/ui/src/components/CardList/useCardListContext.tsx new file mode 100644 index 000000000..3c40bd56e --- /dev/null +++ b/packages/ui/src/components/CardList/useCardListContext.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export type CardListContext = { + isClickable?: boolean; + autoFocus?: boolean; + isFocused?: boolean; +}; + +export const CardListContext = createContext( + {} as CardListContext, +); + +export function useCardListContext() { + return useContext(CardListContext); +} + +export const CardListProvider = CardListContext.Provider; diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx new file mode 100644 index 000000000..2e92fb447 --- /dev/null +++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,24 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '../Text/Text'; + +import { Checkbox } from './Checkbox'; + +const meta: Meta = { + title: 'Form/Checkbox', + component: Checkbox, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + ), +}; diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 000000000..b49a5b510 --- /dev/null +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,11 @@ +import { Checkbox as RadixCheckbox } from '@radix-ui/themes'; + +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type CheckboxProps = PropsOf; + +export const Checkbox = createComponent({ + id: 'Checkbox', + baseElement: RadixCheckbox, +}); diff --git a/packages/ui/src/components/Checkbox/index.tsx b/packages/ui/src/components/Checkbox/index.tsx new file mode 100644 index 000000000..482670bde --- /dev/null +++ b/packages/ui/src/components/Checkbox/index.tsx @@ -0,0 +1,3 @@ +export { Checkbox } from './Checkbox'; + +export type { CheckboxProps } from './Checkbox'; diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx new file mode 100644 index 000000000..36fa7c1f2 --- /dev/null +++ b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import type { FlexProps } from '../Box'; +import { Flex } from '../Box'; + +import { ContextMenu } from './ContextMenu'; + +const meta: Meta = { + title: 'Overlay/ContextMenu', + component: ContextMenu, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +const RightClickZone = (props: FlexProps) => { + return ( + + ); +}; + +export const Usage: Story = { + render: () => ( + + + Click here + + + Edit + Duplicate + + Archive + + + More + + Move to project… + Move to folder… + + Advanced options… + + + + + Share + Add to favorites + + + Delete + + + + ), +}; diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 000000000..550c96096 --- /dev/null +++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,127 @@ +import { ContextMenu as RC } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type ContextMenuProps = PropsOf; +export type ContextMenuTriggerProps = PropsOf; +export type ContextMenuContentProps = PropsOf; +export type ContextMenuLabelProps = PropsOf; +export type ContextMenuItemProps = PropsOf; +export type ContextMenuGroupProps = PropsOf; +export type ContextMenuRadioGroupProps = PropsOf; +export type ContextMenuCheckboxItemProps = PropsOf; +export type ContextMenuSubProps = PropsOf; +export type ContextMenuSubTriggerProps = PropsOf; +export type ContextMenuSubContentProps = PropsOf; +export type ContextMenuSeparatorProps = PropsOf; + +export const ContextMenuRoot = createComponent< + ContextMenuProps, + typeof RC.Root +>({ + id: 'ContextMenu', + baseElement: RC.Root, +}); + +export const ContextMenuTrigger = createComponent< + ContextMenuTriggerProps, + typeof RC.Trigger +>({ + id: 'ContextMenuTrigger', + baseElement: RC.Trigger, +}); + +export const ContextMenuContent = createComponent< + ContextMenuContentProps, + typeof RC.Content +>({ + id: 'ContextMenuContent', + baseElement: RC.Content, +}); + +export const ContextMenuLabel = createComponent< + ContextMenuLabelProps, + typeof RC.Label +>({ + id: 'ContextMenuLabel', + baseElement: RC.Label, +}); + +export const ContextMenuItem = createComponent< + ContextMenuItemProps, + typeof RC.Item +>({ + id: 'ContextMenuItem', + baseElement: RC.Item, +}); + +export const ContextMenuGroup = createComponent< + ContextMenuGroupProps, + typeof RC.Group +>({ + id: 'ContextMenuGroup', + baseElement: RC.Group, +}); + +export const ContextMenuRadioGroup = createComponent< + ContextMenuRadioGroupProps, + typeof RC.RadioGroup +>({ + id: 'ContextMenuRadioGroup', + baseElement: RC.RadioGroup, +}); + +export const ContextMenuCheckboxItem = createComponent< + ContextMenuCheckboxItemProps, + typeof RC.CheckboxItem +>({ + id: 'ContextMenuCheckboxItem', + baseElement: RC.CheckboxItem, +}); + +export const ContextMenuSub = createComponent< + ContextMenuSubProps, + typeof RC.Sub +>({ + id: 'ContextMenuSub', + baseElement: RC.Sub, +}); + +export const ContextMenuSubContent = createComponent< + ContextMenuSubContentProps, + typeof RC.SubContent +>({ + id: 'ContextMenuSubContent', + baseElement: RC.SubContent, +}); + +export const ContextMenuSubTrigger = createComponent< + ContextMenuSubTriggerProps, + typeof RC.SubTrigger +>({ + id: 'ContextMenuSubTrigger', + baseElement: RC.SubTrigger, +}); + +export const ContextMenuSeparator = createComponent< + ContextMenuSeparatorProps, + typeof RC.Separator +>({ + id: 'ContextSeparator', + baseElement: RC.Separator, +}); + +export const ContextMenu = withNamespace(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Content: ContextMenuContent, + Label: ContextMenuLabel, + Item: ContextMenuItem, + Group: ContextMenuGroup, + RadioGroup: ContextMenuRadioGroup, + CheckboxItem: ContextMenuCheckboxItem, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, + Separator: ContextMenuSeparator, +}); diff --git a/packages/ui/src/components/ContextMenu/index.tsx b/packages/ui/src/components/ContextMenu/index.tsx new file mode 100644 index 000000000..412f1acef --- /dev/null +++ b/packages/ui/src/components/ContextMenu/index.tsx @@ -0,0 +1,30 @@ +export { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRoot, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from './ContextMenu'; + +export type { + ContextMenuCheckboxItemProps, + ContextMenuContentProps, + ContextMenuGroupProps, + ContextMenuItemProps, + ContextMenuLabelProps, + ContextMenuProps, + ContextMenuRadioGroupProps, + ContextMenuSeparatorProps, + ContextMenuSubContentProps, + ContextMenuSubProps, + ContextMenuSubTriggerProps, + ContextMenuTriggerProps, +} from './ContextMenu'; diff --git a/packages/ui/src/components/Copyable/Copyable.stories.tsx b/packages/ui/src/components/Copyable/Copyable.stories.tsx new file mode 100644 index 000000000..b1f18a938 --- /dev/null +++ b/packages/ui/src/components/Copyable/Copyable.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconClipboardCopy } from '@tabler/icons-react'; + +import { Copyable } from './Copyable'; + +const meta: Meta = { + title: 'Helpers/Copyable', + component: Copyable, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => Standard Icon, +}; + +export const CustomIcon: Story = { + render: () => ( + + Different Icon + + ), +}; + +export const Polymorphic: Story = { + render: () => ( + + Standard Icon + + ), +}; diff --git a/packages/ui/src/components/Copyable/Copyable.tsx b/packages/ui/src/components/Copyable/Copyable.tsx new file mode 100644 index 000000000..9c6026a4a --- /dev/null +++ b/packages/ui/src/components/Copyable/Copyable.tsx @@ -0,0 +1,71 @@ +import { Tooltip } from '@radix-ui/themes'; +import { IconCopy } from '@tabler/icons-react'; + +import { createPolymorphicComponent } from '../../utils/component'; +import type { Colors } from '../../utils/types'; +import { Box } from '../Box'; +import type { BoxProps } from '../Box'; +import type { IconContext } from '../Icon/useIconContext'; +import { IconButton } from '../IconButton/IconButton'; +import { toast } from '../Toast/useToast'; + +export type CopyableBaseProps = { + value: string; + tooltipMessage?: string; + icon?: React.ComponentType>; + iconSize?: number; + iconStroke?: number; + iconClassName?: string; + iconColor?: Colors; + iconAriaLabel?: string; +}; + +export type CopyableProps = BoxProps & CopyableBaseProps; + +export const Copyable = createPolymorphicComponent({ + id: 'Copyable', + className: 'inline-flex items-center gap-2', + baseElement: Box, + render: ( + Comp, + { + children, + value, + tooltipMessage = 'Click here to copy to clipboard', + icon: CopyIcon = IconCopy, + iconSize, + iconStroke, + iconClassName, + iconColor = 'text-icon', + iconAriaLabel: ariaLabel = 'Copy to clipboard', + ...props + }, + ) => { + async function handleCopy() { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } + + return ( + + {children} + + + + + ); + }, + defaultProps: { + as: 'span', + }, +}); diff --git a/packages/ui/src/components/Copyable/index.tsx b/packages/ui/src/components/Copyable/index.tsx new file mode 100644 index 000000000..f3cec0400 --- /dev/null +++ b/packages/ui/src/components/Copyable/index.tsx @@ -0,0 +1,3 @@ +export { Copyable } from './Copyable'; + +export type { CopyableBaseProps, CopyableProps } from './Copyable'; diff --git a/packages/ui/src/components/Dialog/Dialog.stories.tsx b/packages/ui/src/components/Dialog/Dialog.stories.tsx new file mode 100644 index 000000000..31fad1e4f --- /dev/null +++ b/packages/ui/src/components/Dialog/Dialog.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HStack, VStack } from '../Box'; +import { Button } from '../Button/Button'; +import { Input } from '../Input/Input'; +import { Text } from '../Text/Text'; + +import { Dialog } from './Dialog'; + +const meta: Meta = { + title: 'Overlay/Dialog', + component: Dialog, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + Edit profile + + Make changes to your profile. + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx new file mode 100644 index 000000000..fb4e06ce3 --- /dev/null +++ b/packages/ui/src/components/Dialog/Dialog.tsx @@ -0,0 +1,53 @@ +import { DialogTitle, Dialog as RD } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type DialogProps = PropsOf; +export type DialogTriggerProps = PropsOf; +export type DialogTitleProps = PropsOf; +export type DialogContentProps = PropsOf; +export type DialogCloseProps = PropsOf; +export type DialogDescriptionProps = PropsOf; + +export const DialogRoot = createComponent({ + id: 'Dialog', + baseElement: RD.Root, +}); + +export const DialogTrigger = createComponent< + DialogTriggerProps, + typeof RD.Trigger +>({ + id: 'DialogTrigger', + baseElement: RD.Trigger, +}); + +export const DialogContent = createComponent< + DialogContentProps, + typeof RD.Content +>({ + id: 'DialogContent', + baseElement: RD.Content, +}); + +export const DialogClose = createComponent({ + id: 'DialogClose', + baseElement: RD.Close, +}); + +export const DialogDescription = createComponent< + DialogDescriptionProps, + typeof RD.Description +>({ + id: 'DialogDescription', + baseElement: RD.Description, +}); + +export const Dialog = withNamespace(DialogRoot, { + Trigger: DialogTrigger, + Content: DialogContent, + Close: DialogClose, + Description: DialogDescription, + Title: DialogTitle, +}); diff --git a/packages/ui/src/components/Dialog/index.tsx b/packages/ui/src/components/Dialog/index.tsx new file mode 100644 index 000000000..6c79eb1e3 --- /dev/null +++ b/packages/ui/src/components/Dialog/index.tsx @@ -0,0 +1,17 @@ +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogRoot, + DialogTrigger, +} from './Dialog'; + +export type { + DialogCloseProps, + DialogContentProps, + DialogDescriptionProps, + DialogProps, + DialogTitleProps, + DialogTriggerProps, +} from './Dialog'; diff --git a/packages/ui/src/components/Drawer/Drawer.stories.tsx b/packages/ui/src/components/Drawer/Drawer.stories.tsx new file mode 100644 index 000000000..275eb0d43 --- /dev/null +++ b/packages/ui/src/components/Drawer/Drawer.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HStack, VStack } from '../Box'; +import { Button } from '../Button/Button'; +import { Input } from '../Input/Input'; + +import type { DrawerContentProps } from './Drawer'; +import { Drawer } from './Drawer'; + +const meta: Meta = { + title: 'Overlay/Drawer', + component: Drawer, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +function Content(props?: DrawerContentProps) { + return ( + + + Edit profile + + Make changes to your profile here. Click save when you're done. + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const Usage: Story = { + render: () => ( + + + + + + + ), +}; + +export const Position: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx new file mode 100644 index 000000000..932bc3869 --- /dev/null +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -0,0 +1,175 @@ +import * as RD from '@radix-ui/react-dialog'; + +import { + createComponent, + createPolymorphicComponent, + withNamespace, +} from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; +import { Box } from '../Box'; +import type { BoxProps } from '../Box'; +import type { ButtonCloseProps } from '../ButtonClose/ButtonClose'; +import { ButtonClose } from '../ButtonClose/ButtonClose'; +import type { IconButtonProps } from '../IconButton'; +import { Theme } from '../Theme'; + +import { styles } from './styles'; + +export type DrawerProps = PropsOf; +export type DrawerPortalProps = PropsOf; +export type DrawerTriggerProps = PropsOf; +export type DrawerOverlayProps = PropsOf; +export type DrawerContentProps = PropsOf & { + side?: 'left' | 'right' | 'top' | 'bottom'; +}; +export type DrawerCloseProps = PropsOf; +export type DrawerCloseIconProps = ButtonCloseProps; +export type DrawerTitleProps = PropsOf; +export type DrawerDescriptionProps = PropsOf; + +export type DrawerHeaderProps = BoxProps; +export type DrawerBodyProps = BoxProps; +export type DrawerFooterProps = BoxProps; + +export const DrawerRoot = createComponent({ + id: 'Drawer', + baseElement: RD.Root, +}); + +export const DrawerTrigger = createComponent< + DrawerTriggerProps, + typeof RD.Trigger +>({ + id: 'DrawerTrigger', + baseElement: RD.Trigger, + defaultProps: { + asChild: true, + }, +}); + +export const DrawerPortal = createComponent< + DrawerPortalProps, + typeof RD.Portal +>({ + id: 'DrawerPortal', + baseElement: RD.Portal, +}); + +export const DrawerOverlay = createComponent< + DrawerOverlayProps, + typeof RD.Overlay +>({ + id: 'DrawerOverlay', + baseElement: RD.Overlay, + className: () => styles().overlay(), +}); + +export const DrawerClose = createComponent({ + id: 'DrawerClose', + baseElement: RD.Close, + defaultProps: { + asChild: true, + }, +}); + +export const DrawerCloseIcon = createComponent< + DrawerCloseIconProps, + typeof ButtonClose +>({ + id: 'DrawerCloseIcon', + className: () => styles().closeIcon(), + baseElement: ButtonClose, + render: (Comp, props) => { + return ( + + + + ); + }, + defaultProps: { + variant: 'link', + color: 'gray', + }, +}); + +export const DrawerContent = createComponent< + DrawerContentProps, + typeof RD.Content +>({ + id: 'DrawerContent', + baseElement: RD.Content, + render: (Comp, { className, children, side = 'right', ...props }) => { + const classes = styles({ side }); + return ( + + + + + {children} + + + + + ); + }, +}); + +export const DrawerDescription = createComponent< + DrawerDescriptionProps, + typeof RD.Description +>({ + id: 'DrawerDescription', + baseElement: RD.Description, + className: () => styles().description(), +}); + +export const DrawerTitle = createComponent({ + id: 'DrawerTitle', + baseElement: RD.Title, + className: () => styles().title(), +}); + +export const DrawerHeader = createPolymorphicComponent< + DrawerHeaderProps, + typeof Box +>({ + id: 'DrawerHeader', + baseElement: Box, + className: () => styles().header(), + defaultProps: { + as: 'header', + }, +}); + +export const DrawerBody = createPolymorphicComponent< + DrawerBodyProps, + typeof Box +>({ + id: 'DrawerBody', + baseElement: Box, + className: () => styles().body(), +}); + +export const DrawerFooter = createPolymorphicComponent< + DrawerFooterProps, + typeof Box +>({ + id: 'DrawerFooter', + baseElement: Box, + className: () => styles().footer(), + defaultProps: { + as: 'header', + }, +}); + +export const Drawer = withNamespace(DrawerRoot, { + Trigger: DrawerTrigger, + Close: DrawerClose, + CloseIcon: DrawerCloseIcon, + Content: DrawerContent, + Header: DrawerHeader, + Description: DrawerDescription, + Title: DrawerTitle, + Body: DrawerBody, + Footer: DrawerFooter, +}); diff --git a/packages/ui/src/components/Drawer/index.tsx b/packages/ui/src/components/Drawer/index.tsx new file mode 100644 index 000000000..c6bf97e3b --- /dev/null +++ b/packages/ui/src/components/Drawer/index.tsx @@ -0,0 +1,30 @@ +'use client'; + +export { + Drawer, + DrawerBody, + DrawerClose, + DrawerCloseIcon, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerRoot, + DrawerTitle, + DrawerTrigger, +} from './Drawer'; + +export type { + DrawerBodyProps, + DrawerCloseIconProps, + DrawerCloseProps, + DrawerContentProps, + DrawerDescriptionProps, + DrawerFooterProps, + DrawerHeaderProps, + DrawerOverlayProps, + DrawerProps, + DrawerTitleProps, + DrawerTriggerProps, +} from './Drawer'; diff --git a/packages/ui/src/components/Drawer/styles.ts b/packages/ui/src/components/Drawer/styles.ts new file mode 100644 index 000000000..a3fd6c0ff --- /dev/null +++ b/packages/ui/src/components/Drawer/styles.ts @@ -0,0 +1,64 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + overlay: [ + 'fixed inset-0 z-50 backdrop-blur-sm', + 'state-open:animate-in state-closed:animate-out', + 'state-closed:fade-out-0 state-open:fade-in-0', + ], + closeIcon: [ + 'absolute right-4 top-4 rounded-sm opacity-70', + 'transition-opacity hover:opacity-100 focus:outline-none', + 'focus:ring-2 focus:ring-gray-4 focus:ring-offset-2', + 'disabled:pointer-events-none state-open:bg-secondary', + ], + content: [ + 'flex flex-col fixed z-50 gap-4 bg-card-bg p-6', + 'text-color border-border shadow-lg transition ease-in-out', + 'state-open:animate-in state-closed:animate-out', + 'state-closed:duration-500 state-open:duration-700', + ], + header: ['flex flex-col space-y-2'], + description: ['text-sm text-muted'], + footer: [ + 'flex flex-col-reverse', + 'sm:flex-row sm:justify-end sm:space-x-2', + ], + title: ['text-lg font-semibold text-heading'], + body: ['py-4 flex-1'], + }, + variants: { + side: { + top: { + content: [ + 'inset-x-0 top-0 border-b', + 'state-closed:slide-out-to-top state-open:slide-in-from-top', + ], + }, + bottom: { + content: [ + 'inset-x-0 bottom-0 border-t', + 'state-closed:slide-out-to-bottom state-open:slide-in-from-bottom', + ], + }, + left: { + content: [ + 'inset-y-0 left-0 h-full w-3/4 border-r', + 'state-closed:slide-out-to-left state-open:slide-in-from-left', + 'sm:max-w-sm', + ], + }, + right: { + content: [ + 'inset-y-0 right-0 h-full w-3/4 border-l', + 'state-closed:slide-out-to-right state-open:slide-in-from-right', + 'sm:max-w-sm', + ], + }, + }, + }, + defaultVariants: { + side: 'right', + }, +}); diff --git a/packages/ui/src/components/Dropdown/Dropdown.stories.tsx b/packages/ui/src/components/Dropdown/Dropdown.stories.tsx new file mode 100644 index 000000000..7b065e408 --- /dev/null +++ b/packages/ui/src/components/Dropdown/Dropdown.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconChevronDown } from '@tabler/icons-react'; + +import { Button } from '../Button/Button'; + +import { Dropdown } from './Dropdown'; + +const meta: Meta = { + title: 'Overlay/Dropdown', + component: Dropdown, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + Edit + Duplicate + + Archive + + More + + Move to project… + Move to folder… + + Advanced options… + + + + Share + Add to favorites + + + Delete + + + + ), +}; diff --git a/packages/ui/src/components/Dropdown/Dropdown.tsx b/packages/ui/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 000000000..414550e22 --- /dev/null +++ b/packages/ui/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,118 @@ +import { DropdownMenu as RD } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type DropdownProps = PropsOf; +export type DropdownTriggerProps = PropsOf; +export type DropdownContentProps = PropsOf; +export type DropdownLabelProps = PropsOf; +export type DropdownItemProps = PropsOf; +export type DropdownGroupProps = PropsOf; +export type DropdownRadioGroupProps = PropsOf; +export type DropdownCheckboxItemProps = PropsOf; +export type DropdownSubProps = PropsOf; +export type DropdownSubTriggerProps = PropsOf; +export type DropdownSubContentProps = PropsOf; +export type DropdownSeparatorProps = PropsOf; + +export const DropdownRoot = createComponent({ + id: 'Dropdown', + baseElement: RD.Root, +}); + +export const DropdownTrigger = createComponent< + DropdownTriggerProps, + typeof RD.Trigger +>({ + id: 'DropdownTrigger', + baseElement: RD.Trigger, +}); + +export const DropdownContent = createComponent< + DropdownContentProps, + typeof RD.Content +>({ + id: 'DropdownContent', + baseElement: RD.Content, +}); + +export const DropdownLabel = createComponent< + DropdownLabelProps, + typeof RD.Label +>({ + id: 'DropdownLabel', + baseElement: RD.Label, +}); + +export const DropdownItem = createComponent({ + id: 'DropdownItem', + baseElement: RD.Item, +}); + +export const DropdownGroup = createComponent< + DropdownGroupProps, + typeof RD.Group +>({ + id: 'DropdownGroup', + baseElement: RD.Group, +}); + +export const DropdownRadioGroup = createComponent< + DropdownRadioGroupProps, + typeof RD.RadioGroup +>({ + id: 'DropdownRadioGroup', + baseElement: RD.RadioGroup, +}); + +export const DropdownCheckboxItem = createComponent< + DropdownCheckboxItemProps, + typeof RD.CheckboxItem +>({ + id: 'DropdownCheckboxItem', + baseElement: RD.CheckboxItem, +}); + +export const DropdownSub = createComponent({ + id: 'DropdownSub', + baseElement: RD.Sub, +}); + +export const DropdownSubContent = createComponent< + DropdownSubContentProps, + typeof RD.SubContent +>({ + id: 'DropdownSubContent', + baseElement: RD.SubContent, +}); + +export const DropdownSubTrigger = createComponent< + DropdownSubTriggerProps, + typeof RD.SubTrigger +>({ + id: 'DropdownSubTrigger', + baseElement: RD.SubTrigger, +}); + +export const DropdownSeparator = createComponent< + DropdownSeparatorProps, + typeof RD.Separator +>({ + id: 'DropdownSeparator', + baseElement: RD.Separator, +}); + +export const Dropdown = withNamespace(DropdownRoot, { + Trigger: DropdownTrigger, + Content: DropdownContent, + Label: DropdownLabel, + Item: DropdownItem, + Group: DropdownGroup, + RadioGroup: DropdownRadioGroup, + CheckboxItem: DropdownCheckboxItem, + Sub: DropdownSub, + SubTrigger: DropdownSubTrigger, + SubContent: DropdownSubContent, + Separator: DropdownSeparator, +}); diff --git a/packages/ui/src/components/Dropdown/index.tsx b/packages/ui/src/components/Dropdown/index.tsx new file mode 100644 index 000000000..2a541e920 --- /dev/null +++ b/packages/ui/src/components/Dropdown/index.tsx @@ -0,0 +1,30 @@ +export { + Dropdown, + DropdownCheckboxItem, + DropdownContent, + DropdownGroup, + DropdownItem, + DropdownLabel, + DropdownRadioGroup, + DropdownRoot, + DropdownSeparator, + DropdownSub, + DropdownSubContent, + DropdownSubTrigger, + DropdownTrigger, +} from './Dropdown'; + +export type { + DropdownCheckboxItemProps, + DropdownContentProps, + DropdownGroupProps, + DropdownItemProps, + DropdownLabelProps, + DropdownProps, + DropdownRadioGroupProps, + DropdownSeparatorProps, + DropdownSubContentProps, + DropdownSubProps, + DropdownSubTriggerProps, + DropdownTriggerProps, +} from './Dropdown'; diff --git a/packages/ui/src/components/EntityItem/EntityItem.stories.tsx b/packages/ui/src/components/EntityItem/EntityItem.stories.tsx new file mode 100644 index 000000000..334bd6c7e --- /dev/null +++ b/packages/ui/src/components/EntityItem/EntityItem.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconCode } from '@tabler/icons-react'; + +import { VStack } from '../Box'; +import { Icon } from '../Icon'; + +import { EntityItem } from './EntityItem'; + +const meta: Meta = { + title: 'Web3/EntityItem', + component: EntityItem, +}; + +export default meta; +type Story = StoryObj; + +const DEFAULT_ARGS = { + children: ( + <> + + Ethereum Logo + + + + ), +}; + +export const Default: Story = { + args: DEFAULT_ARGS, +}; + +export const Variations: Story = { + render: () => ( + + + + + + + + {DEFAULT_ARGS.children} + + ), +}; diff --git a/packages/ui/src/components/EntityItem/EntityItem.tsx b/packages/ui/src/components/EntityItem/EntityItem.tsx new file mode 100644 index 000000000..c7ef96a42 --- /dev/null +++ b/packages/ui/src/components/EntityItem/EntityItem.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react'; +import type { VariantProps } from 'tailwind-variants'; +import { tv } from 'tailwind-variants'; + +import { createComponent, withNamespace } from '../../utils/component'; +import { shortAddress } from '../../utils/helpers'; +import type { BoxProps, HStackProps } from '../Box'; +import { Box, HStack } from '../Box'; +import { Copyable } from '../Copyable'; +import { Text } from '../Text'; + +export type EntityItemVariantProps = VariantProps; +export type EntityItemProps = Omit & + EntityItemVariantProps; + +export type EntityItemSlotProps = BoxProps; +export type EntityItemInfo = BoxProps & { + title: ReactNode; + id: string; + shortId?: boolean; +}; + +export const EntityItemRoot = createComponent({ + id: 'EntityItem', + baseElement: HStack, + render: (Comp, { gap = '2', className, ...props }) => { + const classes = styles(); + return ( + + ); + }, +}); + +export const EntityItemSlot = createComponent({ + id: 'EntityItemSlot', + baseElement: Box, +}); + +export const EntityItemInfo = createComponent({ + id: 'EntityItemInfo', + baseElement: Box, + render: ( + Comp, + { title, id, children, className, shortId = true, ...props }, + ) => { + const classes = styles(); + return ( + + + {title} + + + {shortId ? shortAddress(id) : id} + + {children} + + ); + }, +}); + +export const EntityItem = withNamespace(EntityItemRoot, { + Slot: EntityItemSlot, + Info: EntityItemInfo, +}); + +const styles = tv({ + slots: { + root: 'gap-4 items-center', + name: 'mt-0 font-medium', + info: 'flex flex-col justify-center gap-1', + tag: 'mt-0', + copyable: 'text-sm text-muted', + assetId: 'text-sm leading-tight', + }, +}); diff --git a/packages/ui/src/components/EntityItem/index.tsx b/packages/ui/src/components/EntityItem/index.tsx new file mode 100644 index 000000000..295e731b8 --- /dev/null +++ b/packages/ui/src/components/EntityItem/index.tsx @@ -0,0 +1,12 @@ +export type { + EntityItemProps, + EntityItemSlotProps, + EntityItemVariantProps, +} from './EntityItem'; + +export { + EntityItem, + EntityItemRoot, + EntityItemSlot, + EntityItemInfo, +} from './EntityItem'; diff --git a/packages/ui/src/components/Focus/Focus.stories.tsx b/packages/ui/src/components/Focus/Focus.stories.tsx new file mode 100644 index 000000000..e42c2f326 --- /dev/null +++ b/packages/ui/src/components/Focus/Focus.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HStack, VStack } from '../Box'; +import { Button } from '../Button/Button'; +import { Text } from '../Text/Text'; + +import { Focus } from './Focus'; + +const meta: Meta = { + title: 'Helpers/Focus', +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + + + + Try to navigate between buttons using arrow keys + + ), +}; diff --git a/packages/ui/src/components/Focus/Focus.tsx b/packages/ui/src/components/Focus/Focus.tsx new file mode 100644 index 000000000..282458279 --- /dev/null +++ b/packages/ui/src/components/Focus/Focus.tsx @@ -0,0 +1,41 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import type { FocusScopeProps } from '@react-aria/focus'; +import { FocusScope } from '@react-aria/focus'; +import { Children, cloneElement } from 'react'; +import type { ReactElement } from 'react'; +import { mergeProps } from 'react-aria'; + +import { createComponent } from '../../utils/component'; + +import { useFocusNavigator, isRightChildrenType } from './useFocusNavigator'; + +export type FocusArrowNavigatorProps = FocusScopeProps; + +export const FocusArrowNavigator = createComponent< + FocusArrowNavigatorProps, + typeof FocusScope +>({ + id: 'FocusArrowNavigator', + render: (_, { children, ...props }) => { + const { onKeyDown } = useFocusNavigator(); + + if (isRightChildrenType(children)) { + const child = Children.map( + children as ReactElement[], + (child: ReactElement) => { + return cloneElement(child, mergeProps(child.props, { onKeyDown })); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + return {child}; + } + + throw new Error('Children type not accepted'); + }, +}); + +export const Focus = { + ArrowNavigator: FocusArrowNavigator, + Scope: FocusScope, +}; diff --git a/packages/ui/src/components/Focus/index.tsx b/packages/ui/src/components/Focus/index.tsx new file mode 100644 index 000000000..08511f104 --- /dev/null +++ b/packages/ui/src/components/Focus/index.tsx @@ -0,0 +1,7 @@ +'use client'; + +export { Focus, FocusArrowNavigator } from './Focus'; + +export type { FocusArrowNavigatorProps } from './Focus'; + +export { isRightChildrenType, useFocusNavigator } from './useFocusNavigator'; diff --git a/packages/ui/src/components/Focus/useFocusNavigator.tsx b/packages/ui/src/components/Focus/useFocusNavigator.tsx new file mode 100644 index 000000000..df828e06c --- /dev/null +++ b/packages/ui/src/components/Focus/useFocusNavigator.tsx @@ -0,0 +1,36 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useFocusManager } from 'react-aria'; + +export function useFocusNavigator() { + const focusManager = useFocusManager(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + focusManager.focusNext(); + } + if (e.key === 'ArrowLeft') { + focusManager.focusPrevious(); + } + if (e.key === 'ArrowUp') { + focusManager.focusNext(); + } + if (e.key === 'ArrowDown') { + focusManager.focusPrevious(); + } + }; + + return { + onKeyDown, + }; +} + +export function isRightChildrenType(children: ReactNode) { + return ( + typeof children !== 'boolean' && + typeof children !== 'string' && + typeof children !== 'undefined' && + typeof children !== 'number' + ); +} diff --git a/packages/ui/src/components/FuelLogo/FuelLogo.stories.tsx b/packages/ui/src/components/FuelLogo/FuelLogo.stories.tsx new file mode 100644 index 000000000..118bb35a0 --- /dev/null +++ b/packages/ui/src/components/FuelLogo/FuelLogo.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { FuelLogo } from './FuelLogo'; + +const meta: Meta = { + title: 'UI/FuelLogo', + component: FuelLogo, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + args: { + showLettering: true, + showSymbol: true, + }, +}; + +export const NoLettering: Story = { + args: { + showLettering: false, + }, +}; + +export const NoSymbol: Story = { + args: { + showLettering: true, + showSymbol: false, + }, +}; diff --git a/packages/ui/src/components/FuelLogo/FuelLogo.tsx b/packages/ui/src/components/FuelLogo/FuelLogo.tsx new file mode 100644 index 000000000..fa6b7ae23 --- /dev/null +++ b/packages/ui/src/components/FuelLogo/FuelLogo.tsx @@ -0,0 +1,42 @@ +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type FuelLogoProps = PropsOf<'span'> & { + size?: number; + showLettering?: boolean; + showSymbol?: boolean; +}; + +export const FuelLogo = createComponent({ + id: 'FuelLogo', + className: 'inline-flex items-center gap-3', + render: (_, { size = 40, showLettering, showSymbol = true, ...props }) => { + return ( + + {showSymbol && ( + + + + )} + {showLettering && ( + + + + )} + + ); + }, +}); diff --git a/packages/ui/src/components/FuelLogo/index.tsx b/packages/ui/src/components/FuelLogo/index.tsx new file mode 100644 index 000000000..bdbefda8a --- /dev/null +++ b/packages/ui/src/components/FuelLogo/index.tsx @@ -0,0 +1,3 @@ +export { FuelLogo } from './FuelLogo'; + +export type { FuelLogoProps } from './FuelLogo'; diff --git a/packages/ui/src/components/Heading/Heading.stories.tsx b/packages/ui/src/components/Heading/Heading.stories.tsx new file mode 100644 index 000000000..18fda7db0 --- /dev/null +++ b/packages/ui/src/components/Heading/Heading.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconCalendar } from '@tabler/icons-react'; + +import { VStack } from '../Box'; + +import { Heading } from './Heading'; + +const meta: Meta = { + title: 'Base/Heading', + component: Heading, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + The quick brown fox jumps over the lazy dog + + + The quick brown fox jumps over the lazy dog + + + The quick brown fox jumps over the lazy dog + + + The quick brown fox jumps over the lazy dog + + + The quick brown fox jumps over the lazy dog + + + The quick brown fox jumps over the lazy dog + + + ), +}; + +export const WithIcon: Story = { + render: () => ( + + Calendar + + ), +}; diff --git a/packages/ui/src/components/Heading/Heading.tsx b/packages/ui/src/components/Heading/Heading.tsx new file mode 100644 index 000000000..f0dec5aa3 --- /dev/null +++ b/packages/ui/src/components/Heading/Heading.tsx @@ -0,0 +1,38 @@ +import { useIconProps } from '../../hooks/useIconProps'; +import type { WithIconProps } from '../../hooks/useIconProps'; +import { createComponent } from '../../utils/component'; +import type { PropsOf, WithAsProps } from '../../utils/types'; + +import { styles } from './styles'; + +export type HeadingProps = Omit & + PropsOf<'h2'> & + WithAsProps & { + size?: '1' | '2' | '3' | '4' | '5' | '6'; + }; + +export const Heading = createComponent({ + id: 'Heading', + baseElement: 'h2', + render( + _, + { + as: Root = 'h2', + size = '2', + className, + iconColor = 'text-icon', + ...props + }, + ) { + const { size: __, ...itemProps } = useIconProps({ + iconColor, + ...props, + } as WithIconProps); + + const classes = styles({ + className, + withIcon: !!itemProps['data-icon'], + }); + return ; + }, +}); diff --git a/packages/ui/src/components/Heading/index.tsx b/packages/ui/src/components/Heading/index.tsx new file mode 100644 index 000000000..832a471e4 --- /dev/null +++ b/packages/ui/src/components/Heading/index.tsx @@ -0,0 +1,3 @@ +export { Heading } from './Heading'; + +export type { HeadingProps } from './Heading'; diff --git a/packages/ui/src/components/Heading/styles.ts b/packages/ui/src/components/Heading/styles.ts new file mode 100644 index 000000000..fe60b8539 --- /dev/null +++ b/packages/ui/src/components/Heading/styles.ts @@ -0,0 +1,21 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + base: [ + 'm-0 font-medium tracking-tight text-heading', + 'data-[size="1"]:text-h1', + 'data-[size="2"]:text-h2', + 'data-[size="3"]:text-h3', + 'data-[size="4"]:text-h4', + 'data-[size="5"]:text-h5', + 'data-[size="6"]:text-h6', + ], + variants: { + withIcon: { + true: 'flex items-center gap-2', + }, + }, + defaultVariants: { + withIcon: false, + }, +}); diff --git a/packages/ui/src/components/HelperIcon/HelperIcon.stories.tsx b/packages/ui/src/components/HelperIcon/HelperIcon.stories.tsx new file mode 100644 index 000000000..8ef938624 --- /dev/null +++ b/packages/ui/src/components/HelperIcon/HelperIcon.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { HelperIcon } from './HelperIcon'; + +const meta: Meta = { + title: 'Helpers/HelperIcon', + component: HelperIcon, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + Some information + ), +}; diff --git a/packages/ui/src/components/HelperIcon/HelperIcon.tsx b/packages/ui/src/components/HelperIcon/HelperIcon.tsx new file mode 100644 index 000000000..e3f32e266 --- /dev/null +++ b/packages/ui/src/components/HelperIcon/HelperIcon.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from '@radix-ui/themes'; +import { IconHelpCircle } from '@tabler/icons-react'; + +import { createComponent } from '../../utils/component'; +import type { Colors, PropsOf } from '../../utils/types'; +import { Icon } from '../Icon/Icon'; +import type { IconContext } from '../Icon/useIconContext'; + +export type HelperIconBaseProps = { + message: string; + icon?: React.ComponentType>; + iconSize?: number; + iconStroke?: number; + iconClassName?: string; + iconColor?: Colors; + iconAriaLabel?: string; +}; +export type HelperIconProps = PropsOf<'span'> & HelperIconBaseProps; + +export const HelperIcon = createComponent({ + id: 'HelperIcon', + baseElement: 'span', + className: 'inline-flex items-center gap-2', + render: ( + Comp, + { + children, + message, + icon: HelperIcon = IconHelpCircle, + iconSize, + iconStroke, + iconClassName, + iconColor = 'text-icon', + iconAriaLabel: ariaLabel = 'Helper Icon', + ...props + }, + ) => { + return ( + + {children} + + + + + ); + }, +}); diff --git a/packages/ui/src/components/HelperIcon/index.tsx b/packages/ui/src/components/HelperIcon/index.tsx new file mode 100644 index 000000000..927a2f23d --- /dev/null +++ b/packages/ui/src/components/HelperIcon/index.tsx @@ -0,0 +1,3 @@ +export { HelperIcon } from './HelperIcon'; + +export type { HelperIconBaseProps, HelperIconProps } from './HelperIcon'; diff --git a/packages/ui/src/components/HoverCard/HoverCard.stories.tsx b/packages/ui/src/components/HoverCard/HoverCard.stories.tsx new file mode 100644 index 000000000..70e8f28b5 --- /dev/null +++ b/packages/ui/src/components/HoverCard/HoverCard.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Avatar } from '../Avatar/Avatar'; +import { Box, HStack } from '../Box'; +import { Heading } from '../Heading/Heading'; +import { Link } from '../Link/Link'; +import { Text } from '../Text/Text'; + +import { HoverCard } from './HoverCard'; + +const meta: Meta = { + title: 'Overlay/HoverCard', + component: HoverCard, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + Follow{' '} + + + + @fuel_network + + + + + + + + Fuel + + + @fuel_network + + + + The fastest execution layer. + + + + + {' '} + for updates. + + ), +}; diff --git a/packages/ui/src/components/HoverCard/HoverCard.tsx b/packages/ui/src/components/HoverCard/HoverCard.tsx new file mode 100644 index 000000000..15222d605 --- /dev/null +++ b/packages/ui/src/components/HoverCard/HoverCard.tsx @@ -0,0 +1,34 @@ +import { HoverCard as RH } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type HoverCardProps = PropsOf; +export type HoverCardTriggerProps = PropsOf; +export type HoverCardContentProps = PropsOf; + +export const HoverCardRoot = createComponent({ + id: 'HoverCard', + baseElement: RH.Root, +}); + +export const HoverCardTrigger = createComponent< + HoverCardTriggerProps, + typeof RH.Trigger +>({ + id: 'HoverCardTrigger', + baseElement: RH.Trigger, +}); + +export const HoverCardContent = createComponent< + HoverCardContentProps, + typeof RH.Content +>({ + id: 'HoverCardContent', + baseElement: RH.Content, +}); + +export const HoverCard = withNamespace(HoverCardRoot, { + Trigger: HoverCardTrigger, + Content: HoverCardContent, +}); diff --git a/packages/ui/src/components/HoverCard/index.tsx b/packages/ui/src/components/HoverCard/index.tsx new file mode 100644 index 000000000..679aa17d8 --- /dev/null +++ b/packages/ui/src/components/HoverCard/index.tsx @@ -0,0 +1,12 @@ +export { + HoverCard, + HoverCardContent, + HoverCardRoot, + HoverCardTrigger, +} from './HoverCard'; + +export type { + HoverCardContentProps, + HoverCardProps, + HoverCardTriggerProps, +} from './HoverCard'; diff --git a/packages/ui/src/components/Icon/Icon.stories.tsx b/packages/ui/src/components/Icon/Icon.stories.tsx new file mode 100644 index 000000000..6188c0608 --- /dev/null +++ b/packages/ui/src/components/Icon/Icon.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconHome } from '@tabler/icons-react'; + +import { Theme } from '../Theme/Theme'; + +import { Icon } from './Icon'; + +const meta: Meta = { + title: 'UI/Icon', + component: Icon, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => , +}; + +export const WithTheme: Story = { + render: () => ( + + + + ), +}; diff --git a/packages/ui/src/components/Icon/Icon.tsx b/packages/ui/src/components/Icon/Icon.tsx new file mode 100644 index 000000000..6fdbded34 --- /dev/null +++ b/packages/ui/src/components/Icon/Icon.tsx @@ -0,0 +1,34 @@ +import { createComponent } from '../../utils/component'; +import { cx } from '../../utils/css'; +import type { PropsOf } from '../../utils/types'; + +import type { IconComponent, IconContext } from './useIconContext'; +import { useIconContext } from './useIconContext'; + +type SvgIconProps = Omit, 'size' | 'stroke'>; +export type IconBaseProps = Partial & { icon: IconComponent }; +export type IconProps = IconBaseProps & SvgIconProps; + +export const Icon = createComponent({ + id: 'Icon', + baseElement: 'svg', + render: ( + _, + { + className, + color: initColor, + size: initSize, + stroke: initStroke, + icon: IconComponent, + ...props + }, + ) => { + const iconContext = useIconContext(); + const size = initSize || iconContext.size; + const stroke = initStroke || iconContext.stroke; + const color = initColor || iconContext.color; + const itemProps = { ...props, size, stroke }; + const classes = cx(color, 'inline-flex items-center', className); + return ; + }, +}); diff --git a/packages/ui/src/components/Icon/index.tsx b/packages/ui/src/components/Icon/index.tsx new file mode 100644 index 000000000..bf8be93b8 --- /dev/null +++ b/packages/ui/src/components/Icon/index.tsx @@ -0,0 +1,9 @@ +'use client'; + +export { Icon } from './Icon'; + +export type { IconBaseProps, IconProps } from './Icon'; + +export { IconProvider, useIconContext } from './useIconContext'; + +export type { IconComponent, IconContext } from './useIconContext'; diff --git a/packages/ui/src/components/Icon/useIconContext.tsx b/packages/ui/src/components/Icon/useIconContext.tsx new file mode 100644 index 000000000..e29ff1d68 --- /dev/null +++ b/packages/ui/src/components/Icon/useIconContext.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { ComponentType } from 'react'; +import { createContext, useContext } from 'react'; + +import type { Colors } from '../../utils/types'; + +export type IconContext = { + stroke?: number; + size?: number; + color?: Colors; +}; + +export type IconComponent = ComponentType< + Partial & { className?: string } +>; + +const context = createContext({ + size: 18, + stroke: 1.2, +} as IconContext); + +export function useIconContext() { + return useContext(context); +} + +export const IconProvider = context.Provider; diff --git a/packages/ui/src/components/IconButton/IconButton.stories.tsx b/packages/ui/src/components/IconButton/IconButton.stories.tsx new file mode 100644 index 000000000..b7883cb1d --- /dev/null +++ b/packages/ui/src/components/IconButton/IconButton.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconSettings } from '@tabler/icons-react'; + +import { IconButton } from './IconButton'; + +const meta: Meta = { + title: 'UI/IconButton', + component: IconButton, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => , +}; + +export const Loading: Story = { + render: () => ( + + ), +}; diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx new file mode 100644 index 000000000..c1bb452d2 --- /dev/null +++ b/packages/ui/src/components/IconButton/IconButton.tsx @@ -0,0 +1,66 @@ +import { IconButton as RadixIconButton } from '@radix-ui/themes'; + +import { getIconSize } from '../../hooks/useIconProps'; +import { useVariants } from '../../hooks/useVariants'; +import type { WithVariants } from '../../hooks/useVariants'; +import { createComponent } from '../../utils/component'; +import type { Colors, PropsOf } from '../../utils/types'; +import { Icon } from '../Icon/Icon'; +import type { IconContext } from '../Icon/useIconContext'; +import { Spinner } from '../Spinner/Spinner'; + +type RadixIconButtonProps = Omit, 'children'>; + +export type IconButtonProps = WithVariants< + RadixIconButtonProps & { + disabled?: boolean; + isLoading?: boolean; + icon: React.ComponentType>; + iconSize?: number; + iconStroke?: number; + iconClassName?: string; + iconColor?: Colors; + 'aria-label'?: string; + } +>; + +export const IconButton = createComponent({ + id: 'IconButton', + render: ( + _, + { + size, + disabled, + isLoading, + icon, + iconSize, + iconStroke, + iconClassName, + iconColor, + ...props + }, + ) => { + const variantProps = useVariants(props); + const isDisabled = Boolean(disabled || isLoading); + return ( + + {isLoading ? ( + + ) : ( + + )} + + ); + }, +}); diff --git a/packages/ui/src/components/IconButton/index.tsx b/packages/ui/src/components/IconButton/index.tsx new file mode 100644 index 000000000..5656460ea --- /dev/null +++ b/packages/ui/src/components/IconButton/index.tsx @@ -0,0 +1,3 @@ +export { IconButton } from './IconButton'; + +export type { IconButtonProps } from './IconButton'; diff --git a/packages/ui/src/components/Input/Input.stories.tsx b/packages/ui/src/components/Input/Input.stories.tsx new file mode 100644 index 000000000..6ff526968 --- /dev/null +++ b/packages/ui/src/components/Input/Input.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconSearch } from '@tabler/icons-react'; + +import { Box, VStack } from '../Box'; +import { Icon } from '../Icon/Icon'; + +import { Input } from './Input'; + +const meta: Meta = { + title: 'Form/Input', + component: Input, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + + + + ), +}; + +export const Sizes: Story = { + render: () => ( + + + + + + ), +}; diff --git a/packages/ui/src/components/Input/Input.tsx b/packages/ui/src/components/Input/Input.tsx new file mode 100644 index 000000000..c8fa17b01 --- /dev/null +++ b/packages/ui/src/components/Input/Input.tsx @@ -0,0 +1,28 @@ +import { TextField as RT } from '@radix-ui/themes'; + +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type InputProps = PropsOf; +export type InputSlotProps = PropsOf; +export type InputFieldProps = PropsOf; + +export const InputRoot = createComponent({ + id: 'Input', + baseElement: RT.Root, +}); + +export const InputSlot = createComponent({ + id: 'InputSlot', + baseElement: RT.Slot, +}); + +export const InputField = createComponent({ + id: 'InputField', + baseElement: RT.Input, +}); + +export const Input = withNamespace(InputRoot, { + Slot: InputSlot, + Field: InputField, +}); diff --git a/packages/ui/src/components/Input/index.tsx b/packages/ui/src/components/Input/index.tsx new file mode 100644 index 000000000..4e9f9d83f --- /dev/null +++ b/packages/ui/src/components/Input/index.tsx @@ -0,0 +1,3 @@ +export { Input, InputField, InputRoot, InputSlot } from './Input'; + +export type { InputFieldProps, InputProps, InputSlotProps } from './Input'; diff --git a/packages/ui/src/components/InputPassword/InputPassword.stories.tsx b/packages/ui/src/components/InputPassword/InputPassword.stories.tsx new file mode 100644 index 000000000..76a52de79 --- /dev/null +++ b/packages/ui/src/components/InputPassword/InputPassword.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { InputPassword } from './InputPassword'; + +const meta: Meta = { + title: 'Form/InputPassword', + component: InputPassword, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + ), +}; diff --git a/packages/ui/src/components/InputPassword/InputPassword.tsx b/packages/ui/src/components/InputPassword/InputPassword.tsx new file mode 100644 index 000000000..6471f8db4 --- /dev/null +++ b/packages/ui/src/components/InputPassword/InputPassword.tsx @@ -0,0 +1,37 @@ +import { IconEye, IconEyeOff, IconLock } from '@tabler/icons-react'; +import { useState } from 'react'; + +import { createComponent } from '../../utils/component'; +import { Icon } from '../Icon/Icon'; +import { IconButton } from '../IconButton/IconButton'; +import type { InputFieldProps, InputProps } from '../Input/Input'; +import { Input } from '../Input/Input'; + +export type InputPasswordProps = InputProps & Omit; + +export const InputPassword = createComponent({ + id: 'InputPassword', + render: (_, { size, className, variant, color, ...props }) => { + const [opened, setOpened] = useState(false); + const type = opened ? 'text' : 'password'; + return ( + + + + + + + setOpened(!opened)} + /> + + + ); + }, +}); diff --git a/packages/ui/src/components/InputPassword/index.tsx b/packages/ui/src/components/InputPassword/index.tsx new file mode 100644 index 000000000..65b0004c3 --- /dev/null +++ b/packages/ui/src/components/InputPassword/index.tsx @@ -0,0 +1,5 @@ +'use client'; + +export { InputPassword } from './InputPassword'; + +export type { InputPasswordProps } from './InputPassword'; diff --git a/packages/ui/src/components/Inset/Inset.stories.tsx b/packages/ui/src/components/Inset/Inset.stories.tsx new file mode 100644 index 000000000..a938e1a1c --- /dev/null +++ b/packages/ui/src/components/Inset/Inset.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconBrandGithub } from '@tabler/icons-react'; + +import { Flex, HStack, VStack } from '../Box'; +import { Card } from '../Card/Card'; +import { Icon } from '../Icon/Icon'; +import { Text } from '../Text/Text'; + +import { Inset } from './Inset'; + +const meta: Meta = { + title: 'UI/Inset', + component: Inset, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + + + + + + github.com + + + Official Node.js SDK for interacting with the AcmeCorp API. + + + + + ), +}; diff --git a/packages/ui/src/components/Inset/Inset.tsx b/packages/ui/src/components/Inset/Inset.tsx new file mode 100644 index 000000000..d3ef7aa6c --- /dev/null +++ b/packages/ui/src/components/Inset/Inset.tsx @@ -0,0 +1,11 @@ +import { Inset as RadixInset } from '@radix-ui/themes'; + +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; + +export type InsetProps = PropsOf; + +export const Inset = createComponent({ + id: 'Inset', + baseElement: RadixInset, +}); diff --git a/packages/ui/src/components/Inset/index.tsx b/packages/ui/src/components/Inset/index.tsx new file mode 100644 index 000000000..6e68ee13d --- /dev/null +++ b/packages/ui/src/components/Inset/index.tsx @@ -0,0 +1,3 @@ +export { Inset } from './Inset'; + +export type { InsetProps } from './Inset'; diff --git a/packages/ui/src/components/Link/Link.stories.tsx b/packages/ui/src/components/Link/Link.stories.tsx new file mode 100644 index 000000000..eeca5728c --- /dev/null +++ b/packages/ui/src/components/Link/Link.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Link } from './Link'; + +const meta: Meta = { + title: 'Base/Link', + component: Link, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => Link, +}; + +export const External: Story = { + render: () => ( + + Visit our website + + ), +}; + +export const Polymorphic: Story = { + render: () => ( + + + Visit our website + + + ), +}; diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx new file mode 100644 index 000000000..16e9f1bdb --- /dev/null +++ b/packages/ui/src/components/Link/Link.tsx @@ -0,0 +1,66 @@ +import { Link as RadixLink } from '@radix-ui/themes'; +import { IconLink } from '@tabler/icons-react'; +import { tv } from 'tailwind-variants'; +import type { VariantProps } from 'tailwind-variants'; + +import { createComponent } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; +import { Icon } from '../Icon/Icon'; + +const link = tv({ + variants: { + isExternal: { + true: 'inline-flex items-center gap-2', + }, + }, + defaultVariants: { + isExternal: false, + }, +}); + +export type LinkVariantProps = VariantProps; +export type LinkProps = PropsOf & + LinkVariantProps & { + externalIcon?: React.ComponentType | null; + iconSize?: number; + }; + +export const Link = createComponent({ + id: 'Link', + baseElement: RadixLink, + render: ( + Comp, + { + children, + className, + isExternal: initIsExternal, + externalIcon: ExternalIcon = IconLink, + iconSize = 18, + ...props + }, + ) => { + const isExternal = + initIsExternal || + props.href?.startsWith('http') || + props.target === '_blank'; + + const classes = link({ isExternal, className }); + if (isExternal) { + return ( + + + {children} + {ExternalIcon && ( + + )} + + + ); + } + return ( + + {children} + + ); + }, +}); diff --git a/packages/ui/src/components/Link/index.tsx b/packages/ui/src/components/Link/index.tsx new file mode 100644 index 000000000..71a51f5a2 --- /dev/null +++ b/packages/ui/src/components/Link/index.tsx @@ -0,0 +1,3 @@ +export { Link } from './Link'; + +export type { LinkProps, LinkVariantProps } from './Link'; diff --git a/packages/ui/src/components/List/List.stories.tsx b/packages/ui/src/components/List/List.stories.tsx new file mode 100644 index 000000000..31e1f87c9 --- /dev/null +++ b/packages/ui/src/components/List/List.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconCheck } from '@tabler/icons-react'; + +import { List } from './List'; + +const meta: Meta = { + title: 'Base/List', + component: List, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + First item + Second item + Third item + + ), +}; + +export const Unordered: Story = { + render: () => ( + + First item + Second item + Third item + + ), +}; + +export const Ordered: Story = { + render: () => ( + + First item + Second item + Third item + + ), +}; + +export const WithIcon: Story = { + render: () => ( + + First item + Second item + Third item + + ), +}; diff --git a/packages/ui/src/components/List/List.tsx b/packages/ui/src/components/List/List.tsx new file mode 100644 index 000000000..3f0935af4 --- /dev/null +++ b/packages/ui/src/components/List/List.tsx @@ -0,0 +1,76 @@ +import { createComponent, withNamespace } from '../../utils/component'; +import type { PropsOf } from '../../utils/types'; +import { Icon } from '../Icon/Icon'; +import type { TextProps } from '../Text/Text'; +import { Text } from '../Text/Text'; + +import { styles } from './styles'; +import type { ListContext } from './useListContext'; +import { ListProvider, useListContext } from './useListContext'; + +export type ListBaseProps = PropsOf<'ul'> & { type?: 'none' | 'ol' | 'ul' }; +export type ListProps = ListContext & ListBaseProps; +export type ListULProps = Omit; +export type ListOLProps = Omit; +export type ListItemProps = PropsOf<'li'>; + +export const ListRoot = createComponent({ + id: 'List', + className: ({ className, icon, type = 'none' }) => { + return styles({ type, withIcon: Boolean(icon) }).root({ className }); + }, + render: ( + _, + { type = 'none', icon, iconColor, iconSize, iconAriaLabel, ...props }, + ) => { + const El = type === 'ol' ? 'ol' : 'ul'; + return ( + + + + ); + }, +}); + +export const ListItem = createComponent({ + id: 'ListItem', + render: (_, { children, className, ...props }) => { + const { icon, iconColor, iconSize, iconAriaLabel } = useListContext(); + const classes = styles({ withIcon: Boolean(icon) }).item({ className }); + const iconEl = icon && ( + + ); + return ( + + {iconEl} {children} + + ); + }, +}); + +export const ListUL = createComponent({ + id: 'ListUL', + baseElement: ListRoot, + defaultProps: { + type: 'ul', + } as ListProps, +}); + +export const ListOL = createComponent({ + id: 'ListOL', + baseElement: ListRoot, + defaultProps: { + type: 'ol', + } as ListProps, +}); + +export const List = withNamespace(ListRoot, { + UL: ListUL, + OL: ListOL, + Item: ListItem, +}); diff --git a/packages/ui/src/components/List/index.tsx b/packages/ui/src/components/List/index.tsx new file mode 100644 index 000000000..493a41328 --- /dev/null +++ b/packages/ui/src/components/List/index.tsx @@ -0,0 +1,15 @@ +'use client'; + +export { List, ListItem, ListOL, ListRoot, ListUL } from './List'; + +export type { + ListBaseProps, + ListItemProps, + ListOLProps, + ListProps, + ListULProps, +} from './List'; + +export { ListProvider, useListContext } from './useListContext'; + +export type { ListContext } from './useListContext'; diff --git a/packages/ui/src/components/List/styles.ts b/packages/ui/src/components/List/styles.ts new file mode 100644 index 000000000..7e2b9718d --- /dev/null +++ b/packages/ui/src/components/List/styles.ts @@ -0,0 +1,31 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + root: 'm-0 p-0 fuel-[Text]:leading-relaxed', + item: 'marker:text-icon', + }, + variants: { + type: { + ol: { + root: 'list-decimal pl-4', + }, + ul: { + root: 'list-disc pl-4', + }, + none: { + root: 'list-none', + }, + }, + withIcon: { + true: { + root: 'list-none', + item: 'flex items-center gap-2', + }, + }, + }, + defaultVariants: { + type: 'ul', + withIcon: false, + }, +}); diff --git a/packages/ui/src/components/List/useListContext.tsx b/packages/ui/src/components/List/useListContext.tsx new file mode 100644 index 000000000..7bc5148ee --- /dev/null +++ b/packages/ui/src/components/List/useListContext.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { Colors } from '../../utils/types'; +import type { IconProps } from '../Icon/Icon'; + +export type ListContext = { + icon?: IconProps['icon']; + iconColor?: Colors; + iconSize?: number; + iconAriaLabel?: string; +}; + +const ctx = createContext({} as ListContext); + +export function useListContext() { + return useContext(ctx); +} + +export const ListProvider = ctx.Provider; diff --git a/packages/ui/src/components/Nav/Nav.stories.tsx b/packages/ui/src/components/Nav/Nav.stories.tsx new file mode 100644 index 000000000..9704fa977 --- /dev/null +++ b/packages/ui/src/components/Nav/Nav.stories.tsx @@ -0,0 +1,109 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Nav } from './Nav'; + +const meta: Meta = { + title: 'Layout/Nav', + component: Nav, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +const ACCOUNT = + 'fuel10va6297tkerdcn5u8mxjm9emudsmkj85pq5x7t7stkmzmc4nvs3qvn99qz'; + +const NETWORK = { + id: '1', + name: 'Mainnet', + url: 'https://mainnet.fuel.sh', +}; + +export const Usage: Story = { + render: () => ( + + ), +}; + +export const NoConnection: Story = { + render: () => ( + + ), +}; + +export const Mobile: Story = { + render: () => ( + + ), + parameters: { + viewport: { + defaultViewport: 'iphonex', + }, + }, +}; diff --git a/packages/ui/src/components/Nav/Nav.tsx b/packages/ui/src/components/Nav/Nav.tsx new file mode 100644 index 000000000..1bbf0b831 --- /dev/null +++ b/packages/ui/src/components/Nav/Nav.tsx @@ -0,0 +1,447 @@ +import { + IconMenu2, + IconMoonFilled, + IconSunFilled, + IconWallet, + IconX, +} from '@tabler/icons-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Children, cloneElement, useEffect, useState } from 'react'; +import { useWindowSize } from 'react-use'; + +import { useStrictedChildren } from '../../hooks/useStrictedChildren'; +import { createComponent, withNamespace } from '../../utils/component'; +import type { AsChildProp, PropsOf, WithAsProps } from '../../utils/types'; +import { Badge } from '../Badge/Badge'; +import { Box, HStack } from '../Box'; +import type { BoxProps, HStackProps } from '../Box'; +import { Button } from '../Button/Button'; +import { FuelLogo } from '../FuelLogo/FuelLogo'; +import type { FuelLogoProps } from '../FuelLogo/FuelLogo'; +import { Icon } from '../Icon/Icon'; +import { IconButton } from '../IconButton'; +import type { LinkProps } from '../Link/Link'; +import { Link } from '../Link/Link'; +import { useTheme } from '../Theme/useTheme'; + +import { styles } from './styles'; +import { NavProvider, useNavContext } from './useNavContext'; +import { NavMobileProvider, useNavMobileContext } from './useNavMobileContext'; + +/** + * Types + */ + +export type NetworkObj = { + id?: string; + name: string; + url: string; +}; + +export type NavProps = { + network?: NetworkObj; + account?: string; + onConnect?: () => void; + children?: React.ReactNode; +}; + +export type NavLogoProps = FuelLogoProps; +export type NavMenuProps = HStackProps; +export type NavMenuItemProps = LinkProps & { isActive?: boolean }; +export type NavDesktopProps = PropsOf<'nav'>; + +export type NavConnectionProps = HStackProps & { + whenOpened?: 'hide' | 'show' | 'no-effect'; +}; + +export type NavThemeToggleProps = AsChildProp & + PropsOf<'span'> & { whenOpened?: 'hide' | 'show' | 'no-effect' }; + +export type NavMobileProps = WithAsProps & + PropsOf<'nav'> & { + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + }; + +export type NavMobileContentProps = WithAsProps & BoxProps; + +/** + * NavRoot + */ + +const ROOT_CHILD_ITEMS = ['NavDesktop', 'NavMobile']; + +export const NavRoot = createComponent({ + id: 'Nav', + render: (_, { network, account, onConnect, children }) => { + const newChildren = useStrictedChildren('Nav', ROOT_CHILD_ITEMS, children); + return ( + + {newChildren} + + ); + }, +}); + +/** + * NavDesktop + */ + +const DESKTOP_CHILD_ITEMS = [ + 'NavLogo', + 'NavMenu', + 'NavSpacer', + 'NavConnection', + 'NavThemeToggle', +]; + +export const NavDesktop = createComponent({ + id: 'NavDesktop', + baseElement: 'nav', + render: (Root, { className, ...props }) => { + const classes = styles(); + const { width } = useWindowSize(); + const children = useStrictedChildren( + 'NavDesktop', + DESKTOP_CHILD_ITEMS, + props.children, + ); + + if (width < 1024) return null; + return ( +
    + + {children} + +
    + ); + }, +}); + +/** + * NavMobile + */ + +const MOBILE_CHILD_ITEMS = [ + 'NavLogo', + 'NavMenu', + 'NavSpacer', + 'NavConnection', + 'NavThemeToggle', + 'NavMobileContent', +]; + +export const NavMobile = createComponent({ + id: 'NavMobile', + baseElement: 'nav', + className: () => styles().mobile(), + render: (Root, { isOpen, onOpenChange, ...props }) => { + const { width } = useWindowSize(); + const [open, setOpen] = useState(() => Boolean(isOpen)); + const children = useStrictedChildren( + 'NavMobile', + MOBILE_CHILD_ITEMS, + props.children, + ); + + useEffect(() => { + onOpenChange?.(Boolean(open)); + }, [open]); + + if (width >= 1024) return null; + return ( + + + {children} + + + ); + }, +}); + +export const NavMobileContent = createComponent< + NavMobileContentProps, + 'header' +>({ + id: 'NavMobileContent', + baseElement: 'header', + className: () => styles().mobileContent(), + render: (Root, { children, ...props }) => { + const { isOpen, onOpenChange } = useNavMobileContext(); + + return ( + + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {Children.toArray(children).map((child: any) => { + return cloneElement(child, { key: child.type.id }); + })} + + onOpenChange((s) => !s)} + /> + + ); + }, +}); + +/** + * NavSpacer + */ + +// eslint-disable-next-line @typescript-eslint/ban-types +export const NavSpacer = createComponent<{}, 'hr'>({ + id: 'NavSpacer', + baseElement: 'hr', + className: 'flex-1 opacity-0', +}); + +/** + * NavLogo + */ + +export const NavLogo = createComponent({ + id: 'NavLogo', + className: () => styles().logo(), + render: (_, { size, ...props }) => { + const { width } = useWindowSize(); + const defaultSize = width < 1024 ? 28 : 32; + return ; + }, +}); + +/** + * NavMenu + */ + +export const NavMenu = createComponent({ + id: 'NavMenu', + className: () => styles().menu(), + render: (Root, props) => { + const mobileProps = useNavMobileContext(); + const content = ; + + if (!mobileProps?.onOpenChange) { + return content; + } + + return ( + + {mobileProps.isOpen && ( + + {content} + + )} + + ); + }, +}); + +/** + * NavMenuItem + */ + +export const NavMenuItem = createComponent({ + id: 'NavMenuItem', + baseElement: Link, + className: () => styles().menuItem(), + render: (Comp, { isActive, ...props }) => { + return ; + }, +}); + +/** + * NavConnection + */ +const MotionHStack = motion(HStack); + +export const NavConnection = createComponent( + { + id: 'NavConnection', + className: () => styles().navConnection(), + render: (_, { whenOpened = 'show', ...props }) => { + const navProps = useNavContext(); + const mobileProps = useNavMobileContext(); + const hasProps = navProps.network || navProps.account; + const connectButton = ( + + ); + + const content = ( + <> + {navProps.network && ( + + + {navProps.network.name} + + )} + {/* {navProps.account && ( */} + {/* */} + {/* )} */} + + ); + + if (!mobileProps?.onOpenChange && !hasProps) { + return connectButton; + } + if (!mobileProps?.onOpenChange || whenOpened === 'no-effect') { + return {content}; + } + + const animContent = ( + + {content} + + ); + + return ( + <> + {!mobileProps.isOpen && whenOpened === 'hide' && animContent} + {mobileProps.isOpen && whenOpened === 'show' && animContent} + + ); + }, + }, +); + +/** + * NavThemeToggle + */ + +export const NavThemeToggle = createComponent({ + id: 'NavThemeToggle', + baseElement: 'span', + render: (Root, { className, whenOpened = 'hide', ...props }) => { + const { theme: current, toggleTheme } = useTheme(); + const mobileProps = useNavMobileContext(); + const classes = styles(); + const content = ( + + + + + ); + + if (!mobileProps?.onOpenChange || whenOpened === 'no-effect') { + return content; + } + + const animContent = ( + + {content} + + ); + + return ( + <> + {!mobileProps.isOpen && whenOpened === 'hide' && animContent} + {mobileProps.isOpen && whenOpened === 'show' && animContent} + + ); + }, +}); + +/** + * Nav + */ + +export const Nav = withNamespace(NavRoot, { + Desktop: NavDesktop, + Mobile: NavMobile, + Logo: NavLogo, + Menu: NavMenu, + MenuItem: NavMenuItem, + Spacer: NavSpacer, + Connection: NavConnection, + ThemeToggle: NavThemeToggle, + MobileContent: NavMobileContent, +}); diff --git a/packages/ui/src/components/Nav/index.tsx b/packages/ui/src/components/Nav/index.tsx new file mode 100644 index 000000000..d01a77778 --- /dev/null +++ b/packages/ui/src/components/Nav/index.tsx @@ -0,0 +1,31 @@ +'use client'; + +export { + Nav, + NavConnection, + NavDesktop, + NavLogo, + NavMenu, + NavMenuItem, + NavMobile, + NavMobileContent, + NavRoot, + NavSpacer, + NavThemeToggle, +} from './Nav'; + +export type { + NavConnectionProps, + NavDesktopProps, + NavLogoProps, + NavMenuItemProps, + NavMenuProps, + NavMobileContentProps, + NavMobileProps, + NavProps, + NavThemeToggleProps, + NetworkObj, +} from './Nav'; + +export { NavMobileProvider, useNavMobileContext } from './useNavMobileContext'; +export { NavProvider, useNavContext } from './useNavContext'; diff --git a/packages/ui/src/components/Nav/styles.ts b/packages/ui/src/components/Nav/styles.ts new file mode 100644 index 000000000..76a361cbd --- /dev/null +++ b/packages/ui/src/components/Nav/styles.ts @@ -0,0 +1,54 @@ +import { tv } from 'tailwind-variants'; + +export const styles = tv({ + slots: { + logo: 'items-center justify-start', + menu: [ + 'flex gap-1 md:flex-col py-3 px-4 laptop:px-0 laptop:gap-4 laptop:flex-row', + 'not-first:border-t not-first:border-border laptop:not-first:border-t-0', + ], + menuItem: [ + 'relative h-auto text-color data-[active=true]:text-brand hover:text-brand', + 'laptop:data-[active=true]:before:content-[""]', + 'laptop:data-[active=true]:before:absolute', + 'laptop:data-[active=true]:before:block', + 'laptop:data-[active=true]:before:w-full', + 'laptop:data-[active=true]:before:h-1', + 'laptop:data-[active=true]:before:bg-brand', + 'laptop:data-[active=true]:before:top-[-24px]', + ], + navConnection: 'items-center', + navNetwork: 'h-8', + themeToggle: [ + 'relative cursor-pointer flex items-center px-2 w-12 h-8 rounded-full border-border', + 'bg-gray-3 select-none', + ], + themeToggleIcon: [ + 'absolute opacity-100 transition-all duration-200 text-icon transform', + 'aria-[label=Sun]:right-2', + 'aria-[label=Moon]:left-2', + 'dark-theme:aria-[label=Sun]:transform', + 'dark-theme:aria-[label=Sun]:opacity-0', + 'dark-theme:aria-[label=Sun]:-translate-x-full', + 'light-theme:aria-[label=Moon]:transform', + 'light-theme:aria-[label=Moon]:opacity-0', + 'light-theme:aria-[label=Moon]:translate-x-full', + ], + desktop: [ + 'gap-8 container mx-auto flex-row items-center', + 'laptop:px-4 laptop:flex min-h-[var(--nav-height)]', + ], + mobile: [ + 'flex-col border-b border-border', + 'laptop:hidden fuel-[NavLogo]:flex-1', + ], + navWrapper: 'border-b border-border min-h-[var(--nav-height)]', + mobileContent: [ + 'flex items-center py-2 px-4 border-b border-transparent', + 'transition-colors duration-200 ease-in-out', + 'min-h-[var(--nav-height)] data-[open=true]:border-border', + 'fuel-[NavLogo]:flex-1', + 'fuel-[IconButton]:ml-2 fuel-[IconButton]:text-icon', + ], + }, +}); diff --git a/packages/ui/src/components/Nav/useNavContext.tsx b/packages/ui/src/components/Nav/useNavContext.tsx new file mode 100644 index 000000000..fd864a3c8 --- /dev/null +++ b/packages/ui/src/components/Nav/useNavContext.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { NavProps } from './Nav'; + +type ContextProps = NavProps; + +// TODO: put this inside the component +const ctx = createContext({} as ContextProps); +export function useNavContext() { + return useContext(ctx); +} + +export const NavProvider = ctx.Provider; diff --git a/packages/ui/src/components/Nav/useNavMobileContext.tsx b/packages/ui/src/components/Nav/useNavMobileContext.tsx new file mode 100644 index 000000000..badd97078 --- /dev/null +++ b/packages/ui/src/components/Nav/useNavMobileContext.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +type ContextProps = { + isOpen: boolean; + onOpenChange: React.Dispatch>; +}; + +const ctx = createContext({} as ContextProps); +export function useNavMobileContext() { + return useContext(ctx); +} + +export const NavMobileProvider = ctx.Provider; diff --git a/packages/ui/src/components/Popover/Popover.stories.tsx b/packages/ui/src/components/Popover/Popover.stories.tsx new file mode 100644 index 000000000..a666d09e3 --- /dev/null +++ b/packages/ui/src/components/Popover/Popover.stories.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import type { Meta, StoryObj } from '@storybook/react'; +import { IconMessageDots } from '@tabler/icons-react'; + +import { Avatar } from '../Avatar/Avatar'; +import { Box, Flex, HStack } from '../Box'; +import { Button } from '../Button/Button'; +import { Checkbox } from '../Checkbox/Checkbox'; +import { Icon } from '../Icon/Icon'; +import { Text } from '../Text/Text'; +import { TextArea } from '../TextArea/TextArea'; + +import { Popover } from './Popover'; + +const meta: Meta = { + title: 'Overlay/Popover', + component: Popover, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Usage: Story = { + render: () => ( + + + + + + + + +