diff --git a/README.md b/README.md index 4dca772c17..b5e47d2599 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ | 📦 [`@rocket.chat/fuselage`](/packages/fuselage) | Rocket.Chat's React Components Library | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage?style=flat-square) | | 📦 [`@rocket.chat/fuselage-hooks`](/packages/fuselage-hooks) | React hooks for Fuselage, Rocket.Chat's design system and UI toolkit | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-hooks?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-hooks) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-hooks?style=flat-square) | | 📦 [`@rocket.chat/fuselage-polyfills`](/packages/fuselage-polyfills) | A bundle of useful poly/ponyfills used by fuselage | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-polyfills?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-polyfills) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-polyfills?style=flat-square) | +| 📦 [`fuselage-tamagui`](/packages/fuselage-tamagui) | | [![npm](https://img.shields.io/npm/v/fuselage-tamagui?style=flat-square)](https://www.npmjs.com/package/fuselage-tamagui) | ![deps](https://img.shields.io/librariesio/release/npm/fuselage-tamagui?style=flat-square) | +| 📦 [`@rocket.chat/fuselage-tamagui-tokens`](/packages/fuselage-tamagui-tokens) | Tamagui tokens for Fuselage, Rocket.Chat's | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-tamagui-tokens?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-tamagui-tokens) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-tamagui-tokens?style=flat-square) | | 📦 [`@rocket.chat/fuselage-toastbar`](/packages/fuselage-toastbar) | Fuselage ToastBar component | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-toastbar?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-toastbar) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-toastbar?style=flat-square) | | 📦 [`@rocket.chat/fuselage-tokens`](/packages/fuselage-tokens) | Design tokens for Fuselage, Rocket.Chat's design system | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-tokens?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-tokens) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-tokens?style=flat-square) | | 📦 [`@rocket.chat/icons`](/packages/icons) | Rocket.Chat's Icons | [![npm](https://img.shields.io/npm/v/@rocket.chat/icons?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/icons) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/icons?style=flat-square) | diff --git a/package.json b/package.json index 6188e48f32..b93c62844a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@changesets/cli": "~2.29.4", "@eslint/js": "~9.29.0", "@rocket.chat/prettier-config": "workspace:~", + "@tamagui/get-button-sized": "~1.130.8", "eslint": "~9.29.0", "eslint-import-resolver-typescript": "~4.4.3", "eslint-plugin-import": "~2.31.0", @@ -57,5 +58,13 @@ "volta": { "node": "22.16.0", "yarn": "4.9.2" + }, + "dependencies": { + "@rocket.chat/fuselage-tamagui-tokens": "workspace:~", + "@tamagui/animations-react-native": "~1.132.20", + "@tamagui/core": "~1.130.8", + "@tamagui/portal": "~1.130.8", + "@tamagui/stacks": "~1.130.8", + "tamagui": "~1.130.8" } } diff --git a/packages/fuselage-tamagui-tokens/README.md b/packages/fuselage-tamagui-tokens/README.md new file mode 100644 index 0000000000..e890bf4de8 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/README.md @@ -0,0 +1 @@ +# fuselage-tamagui-tokens diff --git a/packages/fuselage-tamagui-tokens/package.json b/packages/fuselage-tamagui-tokens/package.json new file mode 100644 index 0000000000..957b3792b7 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/package.json @@ -0,0 +1,52 @@ +{ + "name": "@rocket.chat/fuselage-tamagui-tokens", + "version": "0.33.2", + "description": "Tamagui tokens for Fuselage, Rocket.Chat's", + "homepage": "https://rocketchat.github.io/Rocket.Chat.Fuselage/", + "author": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/fuselage.git", + "directory": "packages/fuselage-tamagui-tokens" + }, + "bugs": { + "url": "https://github.com/RocketChat/fuselage/issues" + }, + "keywords": [ + "design", + "tokens", + "fuselage", + "tamagui", + "rocket.chat" + ], + "main": "./src/index.ts", + "publishConfig": { + "access": "public" + }, + "type": "module", + "devDependencies": { + "build-design-tokens": "workspace:~", + "eslint": "~9.21.0", + "eslint-config-prettier": "~8.8.0", + "lint-all": "workspace:~", + "npm-run-all": "^4.1.5", + "postcss-scss": "~4.0.9", + "prettier": "~3.5.2", + "rimraf": "^3.0.2", + "style-dictionary": "~4.3.3", + "stylelint": "~16.14.1", + "stylelint-order": "~6.0.4", + "stylelint-prettier": "~5.0.3", + "stylelint-scss": "~6.11.1" + }, + "dependencies": { + "@tamagui/core": "^1.125.22" + }, + "peerDependencies": { + "@tamagui/core": "*" + } +} diff --git a/packages/fuselage-tamagui-tokens/src/index.ts b/packages/fuselage-tamagui-tokens/src/index.ts new file mode 100644 index 0000000000..550ec37182 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/index.ts @@ -0,0 +1,202 @@ +import { createTamagui } from '@tamagui/core'; +import { badgeTokens } from './tokens/badge'; +import { buttonTokens } from './tokens/button'; +import { statusTokens } from './tokens/status'; +import { statusBulletTokens } from './tokens/status-bullet'; +import { surface } from './tokens/surface'; +import { shadow } from './tokens/shadow'; +import { stroke } from './tokens/stroke'; +import { font } from './tokens/font'; +import { colorTokens } from './tokens/colors'; +import { typographyTokens } from './tokens/typography'; + +const { badge } = badgeTokens; + +// Helper function to remap surface tokens +const reMapSurface = ( + prefix: K, + surface: S, +): { + [I in keyof S as `${K}-${I extends string ? I : never}`]: S[I]; +} => { + return Object.keys(surface).reduce((acc, key) => { + acc[`${prefix}-${key}`] = surface[key]; + return acc; + }, {} as any); +}; + + +// Utility functions and configuration setup + +export const config = createTamagui({ + defaultFont: 'body', + shouldAddPrefersColorThemes: true, + themeClassNameOnRoot: true, + + tokens: { + size: { + 0: 0, + 1: 4, + 2: 8, + 3: 16, + 4: 24, + 5: 32, + 6: 44, + 7: 56, + 8: 68, + 9: 80, + 10: 96, + true: 1, + }, + space: { + 0: 0, + 1: 4, + 2: 8, + 3: 16, + 4: 24, + 5: 32, + 6: 44, + 7: 56, + 8: 68, + 9: 80, + 10: 96, + true: 1, + }, + zIndex: { + 0: 0, + 1: 100, + 2: 200, + 3: 300, + 4: 400, + 5: 500, + dropdown: 1000, + modal: 1300, + tooltip: 1500, + }, + color: colorTokens, + radius: { + 0: 0, + 1: 4, + 2: 8, + 3: 12, + 4: 16, + 5: 20, + 6: 24, + 7: 28, + 8: 32, + 9: 36, + 10: 40, + true: 1, + }, + font: { + body: typographyTokens.fontFamilies.sans.join(','), + mono: typographyTokens.fontFamilies.mono.join(','), + }, + }, + + themes: { + light: { + background: colorTokens.white, + color: colorTokens.n900, + + // Surface tokens + ...reMapSurface('surface', surface.light), + backgroundColor: surface.light.neutral, + + // Shadow tokens + ...reMapSurface('shadow', shadow.light), + + // Stroke tokens + ...reMapSurface('stroke', stroke.light), + borderColor: stroke.light.medium, + + // Font tokens + ...reMapSurface('font', font.light), + + // Component tokens + ...buttonTokens.button.light, + ...statusTokens.status.light, + ...statusBulletTokens.statusBullet.light, + + // Badge tokens + 'badge-primary-background': badge.light.primary.background, + 'badge-primary-color': badge.light.primary.color, + 'badge-secondary-background': badge.light.secondary.background, + 'badge-secondary-color': badge.light.secondary.color, + 'badge-danger-background': badge.light.danger.background, + 'badge-danger-color': badge.light.danger.color, + 'badge-warning-background': badge.light.warning.background, + 'badge-warning-color': badge.light.warning.color, + 'badge-ghost-background': badge.light.ghost.background, + 'badge-ghost-color': badge.light.ghost.color, + 'badge-disabled-background': badge.light.disabled.background, + 'badge-disabled-color': badge.light.disabled.color, + }, + dark: { + background: colorTokens.n900, + color: colorTokens.white, + + // Surface tokens + ...reMapSurface('surface', surface.dark), + backgroundColor: surface.dark.neutral, + + // Shadow tokens + ...reMapSurface('shadow', shadow.dark), + + // Stroke tokens + ...reMapSurface('stroke', stroke.dark), + borderColor: stroke.dark.medium, + + // Font tokens + ...reMapSurface('font', font.dark), + + // Component tokens + ...buttonTokens.button.dark, + ...statusTokens.status.dark, + ...statusBulletTokens.statusBullet.dark, + + // Badge tokens + 'badge-primary-background': badge.dark.primary.background, + 'badge-primary-color': badge.dark.primary.color, + 'badge-secondary-background': badge.dark.secondary.background, + 'badge-secondary-color': badge.dark.secondary.color, + 'badge-danger-background': badge.dark.danger.background, + 'badge-danger-color': badge.dark.danger.color, + 'badge-warning-background': badge.dark.warning.background, + 'badge-warning-color': badge.dark.warning.color, + 'badge-ghost-background': badge.dark.ghost.background, + 'badge-ghost-color': badge.dark.ghost.color, + 'badge-disabled-background': badge.dark.disabled.background, + 'badge-disabled-color': badge.dark.disabled.color, + }, + }, + // Add essential Tamagui configurations + media: { + xs: { maxWidth: 599 }, + sm: { minWidth: 600, maxWidth: 767 }, + md: { minWidth: 768, maxWidth: 1023 }, + lg: { minWidth: 1024, maxWidth: 1279 }, + xl: { minWidth: 1280, maxWidth: 1599 }, + xxl: { minWidth: 1600, maxWidth: 1919 }, + xxxl: { minWidth: 1920 }, + gtXs: { minWidth: 600 }, + gtSm: { minWidth: 768 }, + gtMd: { minWidth: 1024 }, + gtLg: { minWidth: 1280 }, + gtXl: { minWidth: 1600 }, + gtXxl: { minWidth: 1920 }, + short: { maxHeight: 820 }, + tall: { minHeight: 820 }, + hoverNone: { hover: 'none' }, + pointerCoarse: { pointer: 'coarse' }, + }, +}); + +export default config; + +export type AppConfig = typeof config; + +declare module '@tamagui/core' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface TamaguiCustomConfig extends AppConfig {} +} diff --git a/packages/fuselage-tamagui-tokens/src/tokens/badge.ts b/packages/fuselage-tamagui-tokens/src/tokens/badge.ts new file mode 100644 index 0000000000..19270acb08 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/badge.ts @@ -0,0 +1,66 @@ +export const badgeTokens = { + badge: { + light: { + level0: '#F2F3F5', + level1: '#9EA2A8', + level2: '#1D74F5', + level3: '#F5455C', + level4: '#F38C39', + primary: { + background: '#1D74F5', + color: '#FFFFFF', + }, + secondary: { + background: '#9EA2A8', + color: '#FFFFFF', + }, + danger: { + background: '#F5455C', + color: '#FFFFFF', + }, + warning: { + background: '#F38C39', + color: '#1F2329', + }, + ghost: { + background: '#2F343D', + color: '#FFFFFF', + }, + disabled: { + background: '#F2F3F5', + color: '#9EA2A8', + }, + }, + dark: { + level0: '#2F343D', + level1: '#6C727A', + level2: '#095AD2', + level3: '#D40C26', + level4: '#E26D0E', + primary: { + background: '#095AD2', + color: '#FFFFFF', + }, + secondary: { + background: '#6C727A', + color: '#FFFFFF', + }, + danger: { + background: '#D40C26', + color: '#FFFFFF', + }, + warning: { + background: '#E26D0E', + color: '#FFFFFF', + }, + ghost: { + background: '#1F2329', + color: '#FFFFFF', + }, + disabled: { + background: '#2F343D', + color: '#6C727A', + }, + }, + }, +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/button.ts b/packages/fuselage-tamagui-tokens/src/tokens/button.ts new file mode 100644 index 0000000000..773854e6c1 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/button.ts @@ -0,0 +1,16 @@ +export const buttonTokens = { + button: { + light: { + secondaryBg: '#9EA2A8', + secondaryHover: '#6C727A', + secondaryPress: '#6C727A', + secondaryText: '#1F2329', + }, + dark: { + secondaryBg: '#6C727A', + secondaryHover: '#9EA2A8', + secondaryPress: '#9EA2A8', + secondaryText: '#FFFFFF', + } + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/colors.ts b/packages/fuselage-tamagui-tokens/src/tokens/colors.ts new file mode 100644 index 0000000000..452412a920 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/colors.ts @@ -0,0 +1,79 @@ +export const colorTokens = { + white: '#FFFFFF', + // Neutral colors + n100: '#F7F8FA', + n200: '#F2F3F5', + n250: '#EBECEF', + n300: '#EEEFF1', + n400: '#E4E7EA', + n450: '#D7DBE0', + n500: '#CBCED1', + n600: '#9EA2A8', + n700: '#6C737A', + n800: '#2F343D', + n900: '#1F2329', + // Red colors + r100: '#FFE9EC', + r200: '#FFC1C9', + r300: '#F98F9D', + r400: '#F5455C', + r500: '#EC0D2A', + r600: '#D40C26', + r700: '#BB0B21', + r800: '#9B1325', + r900: '#8B0719', + r1000: '#6B0513', + // Orange colors + o100: '#FDE8D7', + o200: '#FAD1B0', + o300: '#F7B27B', + o400: '#F59B53', + o500: '#F38C39', + o600: '#E26D0E', + o700: '#BD5A0B', + o800: '#974809', + o900: '#713607', + o1000: '#5B2C06', + // Purple colors + p100: '#F9EFFC', + p200: '#EDD0F7', + p300: '#DCA0EF', + p400: '#CA71E7', + p500: '#9F22C7', + p600: '#7F1B9F', + p700: '#5F1477', + p800: '#4A105D', + p900: '#350B42', + // Yellow colors + y100: '#FFF8E0', + y200: '#FFECAD', + y300: '#FFE383', + y400: '#FFD95A', + y500: '#FFD031', + y600: '#F3BE08', + y700: '#DFAC00', + y800: '#AC892F', + y900: '#8E6300', + y1000: '#573D00', + // Green colors + g100: '#E5FBF4', + g200: '#C0F6E4', + g300: '#96F0D2', + g400: '#6CE9C0', + g500: '#2DE0A5', + g600: '#1ECB92', + g700: '#19AC7C', + g800: '#148660', + g900: '#106D4F', + g1000: '#0D5940', + // Blue colors + b100: '#E8F2FF', + b200: '#D1EBFE', + b300: '#76B7FC', + b400: '#549DF9', + b500: '#156FF5', + b600: '#095AD2', + b700: '#10529E', + b800: '#01336B', + b900: '#012247', +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/font.ts b/packages/fuselage-tamagui-tokens/src/tokens/font.ts new file mode 100644 index 0000000000..e8b1abc429 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/font.ts @@ -0,0 +1,14 @@ +export const font = { + light: { + default: '#2F343D', + info: '#6C737A', + hint: '#9EA2A8', + disabled: '#CBCED1', + }, + dark: { + default: '#FFFFFF', + info: '#9EA2A8', + hint: '#6C737A', + disabled: '#4A4A4A', + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/shadow.ts b/packages/fuselage-tamagui-tokens/src/tokens/shadow.ts new file mode 100644 index 0000000000..6ee710e38a --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/shadow.ts @@ -0,0 +1,18 @@ +export const shadow = { + light: { + none: '0 0 0 transparent', + xs: '0 1px 2px rgba(47, 52, 61, 0.1)', + sm: '0 2px 4px rgba(47, 52, 61, 0.12)', + md: '0 4px 8px rgba(47, 52, 61, 0.16)', + lg: '0 6px 16px rgba(47, 52, 61, 0.2)', + xl: '0 8px 24px rgba(47, 52, 61, 0.24)', + }, + dark: { + none: '0 0 0 transparent', + xs: '0 1px 2px rgba(0, 0, 0, 0.2)', + sm: '0 2px 4px rgba(0, 0, 0, 0.24)', + md: '0 4px 8px rgba(0, 0, 0, 0.32)', + lg: '0 6px 16px rgba(0, 0, 0, 0.4)', + xl: '0 8px 24px rgba(0, 0, 0, 0.48)', + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/status-bullet.ts b/packages/fuselage-tamagui-tokens/src/tokens/status-bullet.ts new file mode 100644 index 0000000000..a58ebe93b3 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/status-bullet.ts @@ -0,0 +1,24 @@ +export const statusBulletTokens = { + statusBullet: { + light: { + 'status-bullet-online-background': '#2DE0A5', + 'status-bullet-online-border': '#1ECB92', + 'status-bullet-away-background': '#FFD031', + 'status-bullet-away-border': '#F3BE08', + 'status-bullet-busy-background': '#F5455C', + 'status-bullet-busy-border': '#D40C26', + 'status-bullet-offline-background': '#9EA2A8', + 'status-bullet-offline-border': '#6C737A', + }, + dark: { + 'status-bullet-online-background': '#2DE0A5', + 'status-bullet-online-border': '#1ECB92', + 'status-bullet-away-background': '#FFD031', + 'status-bullet-away-border': '#F3BE08', + 'status-bullet-busy-background': '#F5455C', + 'status-bullet-busy-border': '#D40C26', + 'status-bullet-offline-background': '#9EA2A8', + 'status-bullet-offline-border': '#6C737A', + } + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/status.ts b/packages/fuselage-tamagui-tokens/src/tokens/status.ts new file mode 100644 index 0000000000..d73af01870 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/status.ts @@ -0,0 +1,24 @@ +export const statusTokens = { + status: { + light: { + 'status-online-background': '#2DE0A5', + 'status-online-border': '#1ECB92', + 'status-away-background': '#FFD031', + 'status-away-border': '#F3BE08', + 'status-busy-background': '#F5455C', + 'status-busy-border': '#D40C26', + 'status-offline-background': '#9EA2A8', + 'status-offline-border': '#6C737A', + }, + dark: { + 'status-online-background': '#2DE0A5', + 'status-online-border': '#1ECB92', + 'status-away-background': '#FFD031', + 'status-away-border': '#F3BE08', + 'status-busy-background': '#F5455C', + 'status-busy-border': '#D40C26', + 'status-offline-background': '#9EA2A8', + 'status-offline-border': '#6C737A', + } + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/stroke.ts b/packages/fuselage-tamagui-tokens/src/tokens/stroke.ts new file mode 100644 index 0000000000..e45bab5e6f --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/stroke.ts @@ -0,0 +1,14 @@ +export const stroke = { + light: { + extra: '#EBECEF', + light: '#F2F3F5', + medium: '#CBCED1', + dark: '#9EA2A8', + }, + dark: { + extra: '#2F343D', + light: '#1F2329', + medium: '#6C737A', + dark: '#9EA2A8', + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/surface.ts b/packages/fuselage-tamagui-tokens/src/tokens/surface.ts new file mode 100644 index 0000000000..c801db9ee9 --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/surface.ts @@ -0,0 +1,12 @@ +export const surface = { + light: { + neutral: '#FFFFFF', + tint: '#F7F8FA', + dark: '#2F343D', + }, + dark: { + neutral: '#2F343D', + tint: '#1F2329', + light: '#FFFFFF', + } +}; diff --git a/packages/fuselage-tamagui-tokens/src/tokens/typography.ts b/packages/fuselage-tamagui-tokens/src/tokens/typography.ts new file mode 100644 index 0000000000..11dc45098b --- /dev/null +++ b/packages/fuselage-tamagui-tokens/src/tokens/typography.ts @@ -0,0 +1,19 @@ +export const typographyTokens = { + fontFamilies: { + sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Helvetica Neue', 'Arial', 'sans-serif'], + mono: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', 'monospace'], + }, + fontScales: { + hero: { fontSize: 48 }, + h1: { fontSize: 32 }, + h2: { fontSize: 24 }, + h3: { fontSize: 20 }, + h4: { fontSize: 16 }, + h5: { fontSize: 14 }, + p1: { fontSize: 16 }, + p2: { fontSize: 14 }, + c1: { fontSize: 12 }, + c2: { fontSize: 10 }, + micro: { fontSize: 9 }, + } +}; diff --git a/packages/fuselage-tamagui/.storybook/main.ts b/packages/fuselage-tamagui/.storybook/main.ts new file mode 100644 index 0000000000..6eb31844f1 --- /dev/null +++ b/packages/fuselage-tamagui/.storybook/main.ts @@ -0,0 +1,36 @@ +import type { StorybookConfig } from '@storybook/react-webpack5'; + +import { join, dirname } from "path" + +/** +* This function is used to resolve the absolute path of a package. +* It is needed in projects that use Yarn PnP or are set up within a monorepo. +*/ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, 'package.json'))) +} +const config: StorybookConfig = { + "stories": [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../src/**/*.stories.@(js|jsx|ts|tsx)" + ], + "addons": [ + getAbsolutePath('@storybook/addon-webpack5-compiler-swc'), + getAbsolutePath('@storybook/addon-essentials'), + getAbsolutePath('@storybook/addon-onboarding'), + getAbsolutePath('@storybook/addon-interactions'), + ], + "framework": { + "name": getAbsolutePath('@storybook/react-webpack5'), + "options": {} + }, + docs: { + autodocs: true, + }, + typescript: { + reactDocgen: 'react-docgen', + } +}; + +export default config; \ No newline at end of file diff --git a/packages/fuselage-tamagui/.storybook/preview.tsx b/packages/fuselage-tamagui/.storybook/preview.tsx new file mode 100644 index 0000000000..2140d29dfd --- /dev/null +++ b/packages/fuselage-tamagui/.storybook/preview.tsx @@ -0,0 +1,31 @@ +import type { Preview } from '@storybook/react' +import { TamaguiProvider } from 'tamagui' +import { config } from '../tamagui.config' +import React from 'react' + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + docs: { + toc: true, + source: { + excludeDecorators: true, + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} + +export default preview \ No newline at end of file diff --git a/packages/fuselage-tamagui/README.md b/packages/fuselage-tamagui/README.md new file mode 100644 index 0000000000..cd122346fb --- /dev/null +++ b/packages/fuselage-tamagui/README.md @@ -0,0 +1 @@ +# fuselage-tamagui diff --git a/packages/fuselage-tamagui/package.json b/packages/fuselage-tamagui/package.json new file mode 100644 index 0000000000..5247dac924 --- /dev/null +++ b/packages/fuselage-tamagui/package.json @@ -0,0 +1,42 @@ +{ + "name": "fuselage-tamagui", + "main": "src/index.ts", + "types": "src/index.ts", + "packageManager": "yarn@4.6.0", + "devDependencies": { + "@babel/core": "^7.22.5", + "@babel/preset-react": "^7.22.5", + "@babel/preset-typescript": "^7.22.5", + "@storybook/addon-essentials": "^8.6.12", + "@storybook/addon-interactions": "^8.6.12", + "@storybook/addon-onboarding": "^8.6.12", + "@storybook/blocks": "^8.6.12", + "@storybook/react": "^8.6.12", + "@storybook/react-webpack5": "^8.6.12", + "@storybook/test": "^8.6.12", + "@tamagui/babel-plugin": "^1.74.3", + "@types/node": "~24.0.10", + "@types/react": "~19.1.8", + "@types/react-native-web": "^0", + "babel-loader": "^9.1.2", + "storybook": "^8.6.12", + "ts-loader": "~9.5.2", + "typescript": "~5.8.3" + }, + "scripts": { + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "@rocket.chat/fuselage-hooks": "workspace:~", + "@rocket.chat/fuselage-tamagui-tokens": "workspace:~", + "@rocket.chat/fuselage-tokens": "workspace:~", + "@tamagui/animations-react-native": "~1.132.20", + "@tamagui/core": "~1.126.5", + "@tamagui/get-button-sized": "~1.130.8", + "@tamagui/get-font-sized": "~1.130.8", + "@tamagui/get-size": "~1.27.3", + "react-native-web": "~0.20.0", + "tamagui": "~1.126.5" + } +} diff --git a/packages/fuselage-tamagui/src/Theme.ts b/packages/fuselage-tamagui/src/Theme.ts new file mode 100644 index 0000000000..e6632c4ca1 --- /dev/null +++ b/packages/fuselage-tamagui/src/Theme.ts @@ -0,0 +1,28 @@ +import { tokens } from '@rocket.chat/fuselage-tamagui-tokens'; + +// Re-export tokens from the shared package +export { tokens }; +export type { Theme } from '@rocket.chat/fuselage-tamagui-tokens'; + +export type ThemeTokens = typeof tokens; +export type TokenKeys = keyof typeof tokens; +export type TokenValues = keyof (typeof tokens)[T]; + +// Theme interface +export interface Theme { + tokens: ThemeTokens; + getToken: (category: T, value: TokenValues) => Token; +} + +// Create the theme +export const createTheme = (): Theme => { + return { + tokens, + getToken: (category: T, value: TokenValues) => { + return tokens[category][value as string]; + }, + }; +}; + +// Export the default theme +export const theme = createTheme(); diff --git a/packages/fuselage-tamagui/src/components/Anchor/Anchor.tsx b/packages/fuselage-tamagui/src/components/Anchor/Anchor.tsx new file mode 100644 index 0000000000..f032c213f2 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Anchor/Anchor.tsx @@ -0,0 +1,29 @@ +import { styled } from '@tamagui/core' +import { Anchor as TamaguiAnchor } from '@tamagui/web' + +export const Anchor = styled(TamaguiAnchor, { + name: 'Anchor', + textDecorationLine: 'none', + + hoverStyle: { + textDecorationLine: 'none', + }, + + pressStyle: { + textDecorationLine: 'none', + }, + + variants: { + size: { + xs: { fontSize: '$1', lineHeight: '$1' }, + sm: { fontSize: '$2', lineHeight: '$2' }, + md: { fontSize: '$3', lineHeight: '$3' }, + lg: { fontSize: '$4', lineHeight: '$4' }, + xl: { fontSize: '$5', lineHeight: '$5' }, + }, + } as const, + + defaultVariants: { + size: 'md', + }, +}) diff --git a/packages/fuselage-tamagui/src/components/Anchor/index.ts b/packages/fuselage-tamagui/src/components/Anchor/index.ts new file mode 100644 index 0000000000..5041ec191c --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Anchor/index.ts @@ -0,0 +1 @@ +export { Anchor } from './Anchor' diff --git a/packages/fuselage-tamagui/src/components/AnimatedVisibility/AnimatedVisibility.stories.tsx b/packages/fuselage-tamagui/src/components/AnimatedVisibility/AnimatedVisibility.stories.tsx new file mode 100644 index 0000000000..207b707880 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/AnimatedVisibility/AnimatedVisibility.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { YStack } from 'tamagui'; + +import { AnimatedVisibility } from './AnimatedVisibility'; + +const meta = { + title: 'Layout/AnimatedVisibility', + component: AnimatedVisibility, + parameters: { + docs: { + description: { + component: 'AnimatedVisibility', + }, + }, + }, + argTypes: { + visibility: { + description: 'Determines the visibility state of the component', + control: 'radio', + options: [ + (AnimatedVisibility as any).VISIBLE, + (AnimatedVisibility as any).HIDDEN, + (AnimatedVisibility as any).HIDING, + (AnimatedVisibility as any).UNHIDING, + ], + table: { + defaultValue: { summary: (AnimatedVisibility as any).VISIBLE }, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + children: ( + + Visible + + ), + visibility: (AnimatedVisibility as any).VISIBLE, + }, +}; \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/AnimatedVisibility/AnimatedVisibility.tsx b/packages/fuselage-tamagui/src/components/AnimatedVisibility/AnimatedVisibility.tsx new file mode 100644 index 0000000000..ce8c78d159 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/AnimatedVisibility/AnimatedVisibility.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { styled, Stack, Theme } from 'tamagui'; + +export type VisibilityType = 'hidden' | 'visible' | 'hiding' | 'unhiding'; + +type AnimatedVisibilityProps = { + children: ReactNode; + visibility?: VisibilityType; +}; + +const AnimatedStack = styled(Stack, { + opacity: 1, + y: 0, + animation: 'visibility', + variants: { + visibility: { + hiding: { + opacity: 0, + y: 16, + }, + unhiding: { + opacity: 1, + y: 0, + }, + }, + } as const, +}); + +const Visibility = { + HIDDEN: 'hidden' as VisibilityType, + VISIBLE: 'visible' as VisibilityType, + HIDING: 'hiding' as VisibilityType, + UNHIDING: 'unhiding' as VisibilityType, +}; + +export const AnimatedVisibility = ({ + children, + visibility: propVisibility = 'hidden', +}: AnimatedVisibilityProps) => { + const [visibility, setVisibility] = + useState(propVisibility); + + useEffect(() => { + setVisibility((visibility) => { + if ( + propVisibility === Visibility.VISIBLE && + visibility !== propVisibility + ) { + return Visibility.UNHIDING; + } + + if ( + propVisibility === Visibility.HIDDEN && + visibility !== propVisibility + ) { + return Visibility.HIDING; + } + + return visibility; + }); + }, [propVisibility]); + + const handleAnimationEnd = useCallback(() => { + setVisibility((visibility) => { + if (visibility === Visibility.HIDING) { + return Visibility.HIDDEN; + } + + if (visibility === Visibility.UNHIDING) { + return Visibility.VISIBLE; + } + + return visibility; + }); + }, []); + + if (visibility === 'hidden') { + return null; + } + + return ( + + + {children} + + + ); +}; + +(AnimatedVisibility as any).HIDDEN = Visibility.HIDDEN; +(AnimatedVisibility as any).VISIBLE = Visibility.VISIBLE; +(AnimatedVisibility as any).HIDING = Visibility.HIDING; +(AnimatedVisibility as any).UNHIDING = Visibility.UNHIDING; \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/AnimatedVisibility/index.ts b/packages/fuselage-tamagui/src/components/AnimatedVisibility/index.ts new file mode 100644 index 0000000000..70128dc91e --- /dev/null +++ b/packages/fuselage-tamagui/src/components/AnimatedVisibility/index.ts @@ -0,0 +1,2 @@ +export { AnimatedVisibility } from './AnimatedVisibility'; +export type { VisibilityType } from './AnimatedVisibility'; diff --git a/packages/fuselage-tamagui/src/components/AutoComplete/AutoComplete.stories.tsx b/packages/fuselage-tamagui/src/components/AutoComplete/AutoComplete.stories.tsx new file mode 100644 index 0000000000..c0a06a515b --- /dev/null +++ b/packages/fuselage-tamagui/src/components/AutoComplete/AutoComplete.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryFn } from "@storybook/react" +import { AutoComplete } from "./AutoComplete" +import { useState } from "react" +import { XStack, YStack, Text } from 'tamagui' + +const meta: Meta = { + title: "INPUTS/AutoComplete", + component: AutoComplete, + parameters:{ + layout:"centered", + }, + tags: ["autodocs"], +}; + +export default meta; + +const sampleOptions = [ + { value: 'test1', label: 'test1' }, + { value: 'test2', label: 'test2' }, + { value: 'test3', label: 'test3' }, + { value: 'test4', label: 'test4' }, + { value: 'test5', label: 'test5' }, +]; + +export const Default: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState([]); + + return ( + + + + ); +}; + +export const CustomSelected: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState(['test1']); + + return ( + + + + ); +}; + +export const Multiple: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState(['test1', 'test3']); + + return ( + + + + ); +}; + +export const MultipleCustomSelected: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState(['test1', 'test3']); + + return ( + + + + ); +}; + +export const CustomItem: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState([]); + + return ( + + + + ); +}; + +export const WithPlaceholder: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState([]); + + return ( + + + + ); +}; + +export const Disabled: StoryFn = () => { + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState(['test1']); + + return ( + + + + ); +}; diff --git a/packages/fuselage-tamagui/src/components/AutoComplete/AutoComplete.tsx b/packages/fuselage-tamagui/src/components/AutoComplete/AutoComplete.tsx new file mode 100644 index 0000000000..b465ebac1a --- /dev/null +++ b/packages/fuselage-tamagui/src/components/AutoComplete/AutoComplete.tsx @@ -0,0 +1,281 @@ +import type { + AllHTMLAttributes, + ComponentProps, + ElementType, + ReactElement, +} from 'react'; +import { useMemo, useRef, useState, useEffect } from 'react'; +import { + Input, + YStack, + Text, + AnimatePresence, + XStack, + styled, +} from 'tamagui'; + +import { Icon } from '../Icon'; +import { Options, useCursor } from '../Options'; + +// Styled container to match the search bar in the image +const SearchBarContainer = styled(XStack, { + name: 'SearchBarContainer', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + width: 300, + height: 40, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#E4E7EA', + borderRadius: 6, + fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: 14, + lineHeight: 20, + fontWeight: '400', + color: '#1F2329', + cursor: 'text', + overflow: 'visible', + + // Focus state - blue border like in the image + focusStyle: { + borderColor: '#156FF5', + }, + + // Error state + variants: { + error: { + true: { + borderColor: '#EC0D2A', + }, + }, + disabled: { + true: { + backgroundColor: '#F7F8FA', + borderColor: '#E4E7EA', + color: '#9EA2A8', + cursor: 'not-allowed', + opacity: 0.5, + pointerEvents: 'none', + }, + }, + } as const, +}); + +// Styled input to be clean and simple +const SearchInput = styled(Input, { + name: 'SearchInput', + flex: 1, + borderWidth: 0, + backgroundColor: 'transparent', + paddingHorizontal: 16, + paddingVertical: 8, + outline: 'none', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + fontWeight: 'inherit', + color: 'inherit', + + // Placeholder styling + placeholderTextColor: '#9EA2A8', + + // Remove default input styling + '&:focus': { + outline: 'none', + borderWidth: 0, + }, + + // Disabled state + variants: { + disabled: { + true: { + color: '#9EA2A8', + cursor: 'not-allowed', + }, + }, + } as const, +}); + +type AutoCompleteOption = { + value: string; + label: unknown; +}; + +type AutoCompleteProps = { + value?: string[]; + filter?: string; + setFilter: (filter: string) => void; + options: AutoCompleteOption[]; + renderItem?: ElementType; + renderSelected?: ElementType; + onChange?: (value: string[]) => void; + renderEmpty?: ElementType; + placeholder?: string; + error?: boolean; + disabled?: boolean; + 'aria-label'?: string; +} & Omit, 'onChange' | 'aria-label'>; + +export function AutoComplete({ + value = [], + filter = '', + setFilter, + options = [], + renderItem, + renderSelected: RenderSelected, + onChange = () => {}, + renderEmpty, + placeholder = 'Search...', + error, + disabled, + 'aria-label': ariaLabel, + onBlur: onBlurAction = () => {}, + ...props +}: AutoCompleteProps): ReactElement { + const ref = useRef(null); + const [hasFocus, setHasFocus] = useState(false); + const [hasInteracted, setHasInteracted] = useState(false); + + const handleSelect = (currentValue: any) => { + onChange([currentValue]); + setFilter(''); + hide(); + setHasInteracted(false); + }; + + const memoizedOptions = useMemo( + () => options.map(({ value, label }) => [value, label]), + [options] + ); + + const [cursor, handleKeyDown, , reset, [optionsAreVisible, hide, show]] = + useCursor(value, memoizedOptions, handleSelect); + + const handleOnBlur = (event: any) => { + setHasFocus(false); + hide(); + setHasInteracted(false); + onBlurAction(event); + }; + + const handleFocus = () => { + setHasFocus(true); + setHasInteracted(true); + show(); + }; + + const handleClick = () => { + ref.current?.focus(); + }; + + const handleClear = () => { + setFilter(''); + ref.current?.focus(); + }; + + useEffect(reset, [filter]); + + // Only show dropdown if user has interacted and there are options + const shouldShowDropdown = hasInteracted && optionsAreVisible && options.length > 0; + + return ( + + + {/* Search Input */} + + + {/* Search Icon or Clear Button */} + + {filter ? ( + × + ) : ( + + + + + )} + + + + {/* Dropdown Options */} + + {shouldShowDropdown && ( + + + + )} + + + ); +} + +AutoComplete.displayName = 'AutoComplete'; \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/AutoComplete/index.ts b/packages/fuselage-tamagui/src/components/AutoComplete/index.ts new file mode 100644 index 0000000000..6f8e5ddc8f --- /dev/null +++ b/packages/fuselage-tamagui/src/components/AutoComplete/index.ts @@ -0,0 +1 @@ +export * from './AutoComplete'; diff --git a/packages/fuselage-tamagui/src/components/Avatar/Avatar.tsx b/packages/fuselage-tamagui/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..e2c9a4eaed --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Avatar/Avatar.tsx @@ -0,0 +1,26 @@ +import type { ImageProps } from 'tamagui'; +import { Image, YStack } from 'tamagui'; + +export type AvatarProps = ImageProps & { + size?: number; + rounded?: boolean; + url: string; +}; + +export const Avatar = ({ + size = 36, + rounded = false, + url, + ...props +}: AvatarProps) => ( + + + +); + +Avatar.displayName = 'Avatar'; diff --git a/packages/fuselage-tamagui/src/components/Avatar/index.ts b/packages/fuselage-tamagui/src/components/Avatar/index.ts new file mode 100644 index 0000000000..27700fe3f3 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Avatar/index.ts @@ -0,0 +1 @@ +export * from './Avatar'; diff --git a/packages/fuselage-tamagui/src/components/Badge/Badge.stories.tsx b/packages/fuselage-tamagui/src/components/Badge/Badge.stories.tsx new file mode 100644 index 0000000000..df3d890820 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Badge/Badge.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Badge } from './Badge'; +import { Stack } from 'tamagui'; + +const meta: Meta = { + title: 'Data Display/Badge', + component: Badge, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + is: { + description: 'Specify that a standard HTML element should behave like a defined custom built-in element', + control: 'text', + }, + variant: { + description: 'Badge variant', + control: 'select', + options: ['primary', 'secondary', 'danger', 'warning', 'ghost'], + }, + small: { + description: 'Small badge size', + control: 'boolean', + }, + disabled: { + description: 'Disabled state', + control: 'boolean', + }, + className: { + description: 'CSS class name', + control: 'text', + }, + title: { + description: 'Title attribute', + control: 'text', + }, + children: { + description: 'Badge content', + control: 'text', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const Template = (args: any) => ( + + + +); + +export const Default: Story = { + render: Template, +}; + +export const Primary: Story = { + args: { + variant: 'primary', + }, + render: Template, +}; + +export const Secondary: Story = { + args: { + variant: 'secondary', + }, + render: Template, +}; + +export const Danger: Story = { + args: { + variant: 'danger', + }, + render: Template, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, + render: Template, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + render: Template, +}; + +export const WithValue: Story = { + args: { + children: '99', + variant: 'primary', + }, + render: Template, +}; + +export const Small: Story = { + args: { + children: '', + variant: 'primary', + small: true, + }, + render: Template, +}; diff --git a/packages/fuselage-tamagui/src/components/Badge/Badge.tsx b/packages/fuselage-tamagui/src/components/Badge/Badge.tsx new file mode 100644 index 0000000000..2e0da91636 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Badge/Badge.tsx @@ -0,0 +1,94 @@ +import { styled, Text, GetProps } from 'tamagui'; +import { forwardRef } from 'react'; + +const StyledBadge = styled(Text, { + name: 'Badge', + + display: 'flex', + overflow: 'hidden', + justifyContent: 'center', + alignItems: 'center', + + width: 'fit-content', + minWidth: 16, + minHeight: 16, + + paddingHorizontal: 8, + paddingVertical: 4, + + textAlign: 'center', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + wordBreak: 'keep-all', + + borderRadius: 9999, + fontSize: 10, + lineHeight: 12, + fontWeight: '500', + fontFamily: 'system-ui, -apple-system, sans-serif', + + variants: { + variant: { + primary: { + color: '#FFFFFF', + backgroundColor: '#156FF5', + }, + secondary: { + color: '#FFFFFF', + backgroundColor: '#6C757D', + }, + danger: { + color: '#FFFFFF', + backgroundColor: '#DC3545', + }, + warning: { + color: '#FFFFFF', + backgroundColor: '#FFC107', + }, + ghost: { + color: '#FFFFFF', + backgroundColor: '#495057', + }, + }, + disabled: { + true: { + color: '#6C757D', + backgroundColor: '#F8F9FA', + }, + }, + small: { + true: { + minWidth: 8, + minHeight: 8, + paddingHorizontal: 2, + fontSize: 8, + lineHeight: 8, + }, + }, + } as const, + + defaultVariants: { + variant: 'secondary', + }, +}); + +export type BadgeProps = GetProps & { + is?: any; + children?: any; + title?: any; + className?: string; +}; + +export const Badge = forwardRef( + function Badge({ is: Tag = 'span', className, title, ...props }, ref) { + return ( + + ); + } +); diff --git a/packages/fuselage-tamagui/src/components/Badge/index.ts b/packages/fuselage-tamagui/src/components/Badge/index.ts new file mode 100644 index 0000000000..26a9e305c7 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Badge/index.ts @@ -0,0 +1 @@ +export { Badge } from './Badge'; diff --git a/packages/fuselage-tamagui/src/components/Box/Box.spec.tsx b/packages/fuselage-tamagui/src/components/Box/Box.spec.tsx new file mode 100644 index 0000000000..13ba05fcf1 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/Box.spec.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react' +import { Box } from './Box' + +describe('Box', () => { + it('renders without crashing', () => { + render() + }) + + it('applies elevation styles', () => { + const { container } = render() + expect(container.firstChild).toHaveStyle({ + boxShadow: expect.any(String) + }) + }) + + it('renders as different elements', () => { + const { container } = render() + expect(container.firstChild?.nodeName).toBe('SPAN') + }) + + it('handles invisible prop', () => { + const { container } = render() + expect(container.firstChild).toHaveStyle({ + visibility: 'hidden' + }) + }) +}) \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/Box.stories.tsx b/packages/fuselage-tamagui/src/components/Box/Box.stories.tsx new file mode 100644 index 0000000000..fffea55037 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/Box.stories.tsx @@ -0,0 +1,181 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Box } from './Box'; + +const meta = { + title: 'Layout/Box', + component: Box, + parameters: { + componentSubtitle: 'A primitive box component with normalized styles', + docs: { + description: { + component: 'A primitive component that serves as the base for other components.' + }, + status: 'stable', + date: '2025-07-03 12:37:51', + author: 'Muskan0400' + } + }, + tags: ['autodocs'], + argTypes: { + elevation: { + description: 'Sets the elevation level of the box', + options: ['0', '1', '2', '1nb', '2nb'], + control: { type: 'radio' } + }, + invisible: { + description: 'Makes the box invisible while preserving its space', + control: { type: 'boolean' } + }, + is: { + description: 'Renders the box as a different HTML element', + control: { type: 'text' } + } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + children: 'Basic Box', + padding: 16, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E4E7EA', + borderRadius: 4, + }, +}; + +export const WithElevation: Story = { + render: () => ( + + + Elevation 0 + + + Elevation 1 + + + Elevation 2 + + + Elevation 1 (No Border) + + + Elevation 2 (No Border) + + + ), +}; + +export const WithFontScale: Story = { + render: () => ( + + Hero Text + Heading 1 + Heading 2 + Heading 3 + Heading 4 + Paragraph 1 + Paragraph 2 + Caption 1 + Caption 2 + Micro Text + + ), +}; + +export const Layouts: Story = { + render: () => ( + + + Column 1 + Column 2 + Column 3 + + + + Absolute + + Normal content + + + ), +}; + +export const WithTruncatedText: Story = { + args: { + withTruncatedText: true, + children: 'This is a very long text that should be truncated with ellipsis when it overflows the container width', + maxWidth: 200, + padding: 16, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E4E7EA', + borderRadius: 4, + }, +}; + +export const Invisible: Story = { + args: { + invisible: true, + children: 'This box is invisible', + padding: 16, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E4E7EA', + borderRadius: 4, + }, +}; + +export const AsLink: Story = { + args: { + is: 'a', + href: 'https://rocket.chat', + target: '_blank', + children: 'This box is a link', + padding: 16, + backgroundColor: '#FFFFFF', + color: '#156FF5', + borderWidth: 1, + borderColor: '#E4E7EA', + borderRadius: 4, + }, +}; + +export const WithAnimation: Story = { + args: { + animated: true, + children: 'This box has animation enabled', + padding: 16, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E4E7EA', + borderRadius: 4, + pressStyle: { + scale: 0.95, + }, + hoverStyle: { + scale: 1.05, + }, + }, +}; + +export const RichContent: Story = { + args: { + withRichContent: true, + padding: 16, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E4E7EA', + borderRadius: 4, + children: ( + <> + Rich Content + This box contains rich content with multiple elements + With different styles + + ), + }, +}; \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/Box.styles.scss b/packages/fuselage-tamagui/src/components/Box/Box.styles.scss new file mode 100644 index 0000000000..ed79b7e8ee --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/Box.styles.scss @@ -0,0 +1,28 @@ +.rcx-box { + box-sizing: border-box; + min-width: 0; + + &--animated { + transition: all 230ms ease-in-out; + } + + &--full { + width: 100%; + height: 100%; + } + + &--with-inline-elements { + display: inline-flex; + } + + &--with-block-elements { + display: flex; + } + + &--focusable { + &:focus { + outline: none; + box-shadow: 0 0 0 2px $focus-color; + } + } +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/Box.tsx b/packages/fuselage-tamagui/src/components/Box/Box.tsx new file mode 100644 index 0000000000..236d773ab2 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/Box.tsx @@ -0,0 +1,205 @@ +import { + Stack, + styled, + GetProps, + createStyledContext, + withStaticProperties, + Theme, +} from '@tamagui/core'; +import { forwardRef } from 'react'; + +const BoxContext = createStyledContext({ + size: '$true', +}); + +const StyledBox = styled(Stack, { + name: 'Box', + backgroundColor: 'transparent', + position: 'relative', + + variants: { + elevation: { + '0': { + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + '1': { + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + borderWidth: 1, + borderColor: '#E4E7EA', + }, + '2': { + shadowColor: '#000000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 4, + borderWidth: 1, + borderColor: '#E4E7EA', + }, + '1nb': { + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + '2nb': { + shadowColor: '#000000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 4, + }, + }, + invisible: { + true: { + visibility: 'hidden', + opacity: 0, + }, + }, + withTruncatedText: { + true: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + }, + fontScale: { + hero: { + fontSize: 32, + fontWeight: '700', + lineHeight: 40, + }, + h1: { + fontSize: 28, + fontWeight: '600', + lineHeight: 36, + }, + h2: { + fontSize: 24, + fontWeight: '600', + lineHeight: 32, + }, + h3: { + fontSize: 20, + fontWeight: '600', + lineHeight: 28, + }, + h4: { + fontSize: 18, + fontWeight: '600', + lineHeight: 24, + }, + p1: { + fontSize: 16, + fontWeight: '400', + lineHeight: 24, + }, + p2: { + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + c1: { + fontSize: 12, + fontWeight: '400', + lineHeight: 16, + }, + c2: { + fontSize: 11, + fontWeight: '400', + lineHeight: 14, + }, + micro: { + fontSize: 10, + fontWeight: '400', + lineHeight: 12, + }, + }, + }, + + defaultVariants: { + elevation: '0', + }, +}); + +export interface BoxProps extends GetProps { + animated?: boolean; + withRichContent?: boolean | 'inlineWithoutBreaks'; + htmlSize?: number; + focusable?: boolean; + is?: keyof JSX.IntrinsicElements; + fontSize?: number; + color?: string; + backgroundColor?: string; + borderRadius?: number; + padding?: number | string; + margin?: number | string; + elevation?: '0' | '1' | '2' | '1nb' | '2nb'; + invisible?: boolean; + withTruncatedText?: boolean; + fontScale?: 'hero' | 'h1' | 'h2' | 'h3' | 'h4' | 'p1' | 'p2' | 'c1' | 'c2' | 'micro'; +} + +export const Box = withStaticProperties( + forwardRef(({ + children, + animated, + withRichContent, + htmlSize, + focusable, + is, + fontSize, + color, + backgroundColor, + borderRadius, + padding, + margin, + elevation, + invisible, + withTruncatedText, + fontScale, + ...props + }, ref) => { + const elementType = is || 'div'; + + return ( + + {children} + + ); + }), + { + Props: BoxContext.Provider, + } +); + +Box.displayName = 'Box'; \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/BoxTransforms.tsx b/packages/fuselage-tamagui/src/components/Box/BoxTransforms.tsx new file mode 100644 index 0000000000..c3af3bd4ae --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/BoxTransforms.tsx @@ -0,0 +1,7 @@ +import { createContext, useContext } from 'react' + +type BoxTransformFn = (props: Record) => Record + +export const BoxTransforms = createContext(null) + +export const useBoxTransform = () => useContext(BoxTransforms) \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/StylingBox.tsx b/packages/fuselage-tamagui/src/components/Box/StylingBox.tsx new file mode 100644 index 0000000000..935cabb581 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/StylingBox.tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from 'react' +import { cloneElement } from 'react' +import type { GetProps } from 'tamagui' +import { StyledBox } from './Box' + +type StylingBoxProps = { + children: ReactElement<{ className?: string }> +} & GetProps + +export const StylingBox = ({ children, ...props }: StylingBoxProps) => { + return cloneElement(children, props) +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/index.ts b/packages/fuselage-tamagui/src/components/Box/index.ts new file mode 100644 index 0000000000..08123862d8 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/index.ts @@ -0,0 +1,4 @@ +export { Box } from './Box'; +export type { BoxProps } from './Box'; +export { StylingBox } from './StylingBox'; +export { BoxTransforms, useBoxTransform } from './BoxTransforms'; diff --git a/packages/fuselage-tamagui/src/components/Box/stories/Colors.stories.tsx b/packages/fuselage-tamagui/src/components/Box/stories/Colors.stories.tsx new file mode 100644 index 0000000000..418cfa8c8c --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/stories/Colors.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Box } from '../Box' + +const meta = { + title: 'Layout/Box/Colors', + component: Box, + tags: ['autodocs'], + parameters: { + date: '2025-07-03 12:40:14', + author: 'Muskan0400' + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const SurfaceColors: Story = { + args: { + backgroundColor: '$surface', + children: 'Surface Colors', + padding: '$4' + } +} + +export const StatusColors: Story = { + args: { + backgroundColor: '$status', + children: 'Status Colors', + padding: '$4' + } +} + +export const StrokeColors: Story = { + args: { + borderColor: '$stroke', + children: 'Stroke Colors', + padding: '$4' + } +} + +export const FontColors: Story = { + args: { + color: '$font', + children: 'Font Colors', + padding: '$4' + } +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/stories/Is.stories.tsx b/packages/fuselage-tamagui/src/components/Box/stories/Is.stories.tsx new file mode 100644 index 0000000000..d6ceb9e7b8 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/stories/Is.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Box } from '../Box' + +const meta = { + title: 'Layout/Box/Is', + component: Box, + tags: ['autodocs'], + parameters: { + date: '2025-07-03 12:40:14', + author: 'Muskan0400' + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const IsButton: Story = { + args: { + is: 'button', + children: 'Button', + padding: '$4' + } +} + +export const IsSpan: Story = { + args: { + is: 'span', + children: 'Span', + padding: '$4' + } +} + +export const IsH4: Story = { + args: { + is: 'h4', + children: 'Heading 4', + padding: '$4' + } +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/stories/Layout.stories.tsx b/packages/fuselage-tamagui/src/components/Box/stories/Layout.stories.tsx new file mode 100644 index 0000000000..a30cf20c03 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/stories/Layout.stories.tsx @@ -0,0 +1,149 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Box } from '../Box' + +const meta = { + title: 'Layout/Box/Layout', + component: Box, + tags: ['autodocs'], + parameters: { + date: '2025-07-03 12:40:14', + author: 'Muskan0400' + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Borders: Story = { + args: { + border: '$1', + children: 'Borders', + padding: '$4' + } +} + +export const BorderRadii: Story = { + args: { + borderRadius: '$1', + children: 'Border Radii', + padding: '$4' + } +} + +export const Display: Story = { + args: { + display: 'flex', + children: 'Display', + padding: '$4' + } +} + +export const Elevation: Story = { + args: { + elevation: '1', + children: 'Elevation', + padding: '$4' + } +} + +export const Heights: Story = { + args: { + height: 100, + children: 'Heights', + padding: '$4' + } +} + +export const Insets: Story = { + args: { + inset: '$4', + children: 'Insets', + padding: '$4' + } +} + +export const Invisible: Story = { + args: { + invisible: true, + children: 'Invisible', + padding: '$4' + } +} + +export const Margins: Story = { + args: { + margin: '$4', + children: 'Margins' + } +} + +export const Opacity: Story = { + args: { + opacity: 0.5, + children: 'Opacity', + padding: '$4' + } +} + +export const Paddings: Story = { + args: { + padding: '$4', + children: 'Paddings' + } +} + +export const Position: Story = { + args: { + position: 'relative', + children: 'Position', + padding: '$4' + } +} + +export const Widths: Story = { + args: { + width: 200, + children: 'Widths', + padding: '$4' + } +} + +export const Sizes: Story = { + args: { + size: '$4', + children: 'Sizes', + padding: '$4' + } +} + +export const TextAlign: Story = { + args: { + textAlign: 'center', + children: 'Text Align', + padding: '$4' + } +} + +export const VerticalAlign: Story = { + args: { + verticalAlign: 'middle', + children: 'Vertical Align', + padding: '$4' + } +} + +export const ZIndex: Story = { + args: { + zIndex: 1, + children: 'Z Index', + padding: '$4' + } +} + +export const Focusable: Story = { + args: { + focusable: true, + children: 'Focusable', + padding: '$4' + } +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Box/stories/RichContent.stories.tsx b/packages/fuselage-tamagui/src/components/Box/stories/RichContent.stories.tsx new file mode 100644 index 0000000000..91dbd9d8ee --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Box/stories/RichContent.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Box } from '../Box' + +const meta = { + title: 'Layout/Box/RichContent', + component: Box, + tags: ['autodocs'], + parameters: { + date: '2025-07-03 12:40:14', + author: 'Muskan0400' + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Block: Story = { + args: { + children:
Block Content
, + padding: '$4' + } +} + +export const Inline: Story = { + args: { + children: Inline Content, + padding: '$4' + } +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Bubble/Bubble.stories.tsx b/packages/fuselage-tamagui/src/components/Bubble/Bubble.stories.tsx new file mode 100644 index 0000000000..b91a3fc77f --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Bubble/Bubble.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Bubble } from './Bubble'; + +const meta: Meta = { + title: 'Components/Bubble', + component: Bubble, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + secondary: { + control: 'boolean', + description: 'Whether to use secondary styling', + }, + small: { + control: 'boolean', + description: 'Whether to use small size', + }, + onClick: { + action: 'clicked', + description: 'Callback when bubble is clicked', + }, + onDismiss: { + action: 'dismissed', + description: 'Callback when dismiss button is clicked', + }, + icon: { + control: 'text', + description: 'Icon name to display', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const IconAndLabel: Story = { + args: { + children: 'New messages', + onClick: () => console.log('Bubble clicked'), + }, +}; + +export const Dismissable: Story = { + args: { + children: 'New messages', + onDismiss: () => console.log('Bubble dismissed'), + }, +}; + +export const LabelOnly: Story = { + args: { + children: 'See new messages', + }, +}; + +export const Secondary: Story = { + args: { + children: 'See new messages', + secondary: true, + }, +}; + +export const SecondaryDismissable: Story = { + args: { + children: 'New messages', + secondary: true, + onDismiss: () => console.log('Bubble dismissed'), + }, + parameters: { + docs: { + description: { + story: 'Example with only a dismiss action - the label is not clickable.', + }, + }, + }, +}; + +export const WithoutAction: Story = { + args: { + children: '22 Nov 2023', + }, +}; + +export const WithLargeText: Story = { + args: { + children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vestibulum libero viverra nulla varius, a consequat ante malesuada. Fusce bibendum, lacus sed fermentum sagittis, urna erat viverra lacus, eu pellentesque neque est nec nisl. Morbi in lobortis dui, ac consectetur mi.', + }, +}; + +export const Small: Story = { + args: { + children: '22 Nov 2023', + small: true, + }, +}; diff --git a/packages/fuselage-tamagui/src/components/Bubble/Bubble.tsx b/packages/fuselage-tamagui/src/components/Bubble/Bubble.tsx new file mode 100644 index 0000000000..a7d01e3dd2 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Bubble/Bubble.tsx @@ -0,0 +1,108 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactNode } from 'react'; +import { + GetProps, + View, + createStyledContext, + styled, + withStaticProperties, +} from '@tamagui/web'; + +import { BubbleButton } from './BubbleButton'; +import { BubbleItem } from './BubbleItem'; + +export const BubbleContext = createStyledContext({ + small: false, + secondary: false, +}); + +export const BubbleFrame = styled(View, { + name: 'Bubble', + context: BubbleContext, + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + + variants: { + small: { + true: { + // Small variant styling will be handled by child components + }, + }, + hasDismiss: { + true: { + // Group styling for when dismiss button is present + }, + }, + }, +}); + +export type BubbleProps = GetProps & { + secondary?: boolean; + children: ReactNode; + small?: boolean; + onClick?: () => void; + icon?: IconName; + onDismiss?: () => void; + contentProps?: Omit, 'onClick'>; + dismissProps?: Omit, 'onClick'>; +}; + +export const Bubble = withStaticProperties( + ({ + secondary = false, + children, + onClick, + icon, + onDismiss, + small = false, + contentProps, + dismissProps, + ...props + }: BubbleProps) => ( + + {onClick ? ( + + ) : ( + + )} + {onDismiss && ( + + )} + + ), + { + Button: BubbleButton, + Item: BubbleItem, + } +); diff --git a/packages/fuselage-tamagui/src/components/Bubble/BubbleButton.tsx b/packages/fuselage-tamagui/src/components/Bubble/BubbleButton.tsx new file mode 100644 index 0000000000..75423a81f9 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Bubble/BubbleButton.tsx @@ -0,0 +1,211 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactNode } from 'react'; +import { + GetProps, + Text, + View, + createStyledContext, + styled, + withStaticProperties, +} from '@tamagui/web'; + +export const BubbleButtonContext = createStyledContext({ + small: false, + secondary: false, +}); + +export const BubbleButtonFrame = styled(View, { + name: 'BubbleButton', + context: BubbleButtonContext, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + textAlign: 'center', + whiteSpace: 'nowrap', + textDecoration: 'none', + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 4, + cursor: 'pointer', + userSelect: 'none', + fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontWeight: '500', + + // Blue button styling (like Button primary but always blue) + backgroundColor: '#156FF5', + borderColor: '#156FF5', + color: '#FFFFFF', + + hoverStyle: { + backgroundColor: '#095AD2', + borderColor: '#095AD2', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#10529E', + borderColor: '#10529E', + }, + focusVisibleStyle: { + backgroundColor: '#095AD2', + borderColor: '#156FF5', + outline: '2px solid #156FF5', + outlineOffset: 2, + }, + + variants: { + size: { + tiny: { + height: 34, + paddingHorizontal: 8, + minWidth: 24, + fontSize: 12, + lineHeight: 16, + }, + mini: { + height: 30, + paddingHorizontal: 8, + minWidth: 20, + fontSize: 12, + lineHeight: 16, + }, + small: { + height: 38, + paddingHorizontal: 8, + minWidth: 56, + fontSize: 14, + lineHeight: 16, + }, + medium: { + height: 42, + paddingHorizontal: 12, + minWidth: 64, + fontSize: 14, + lineHeight: 16, + }, + large: { + height: 58, + paddingHorizontal: 24, + minWidth: 96, + fontSize: 18, + lineHeight: 28, + }, + $true: { + height: 50, + paddingHorizontal: 16, + minWidth: 80, + fontSize: 18, + lineHeight: 28, + }, + }, + small: { + true: { + height: 38, + fontSize: 14, + lineHeight: 16, + paddingHorizontal: 8, + minWidth: 56, + }, + }, + secondary: { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#1F2329', + hoverStyle: { + backgroundColor: '#EEEFF1', + borderColor: '#D7DBE0', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#E4E7EA', + borderColor: '#CBCED1', + }, + focusVisibleStyle: { + backgroundColor: '#EEEFF1', + borderColor: '#156FF5', + outline: '2px solid #156FF5', + outlineOffset: 2, + }, + }, + false: { + // Blue button styling (like Button primary) + backgroundColor: '#156FF5', + borderColor: '#156FF5', + color: '#FFFFFF', + hoverStyle: { + backgroundColor: '#095AD2', + borderColor: '#095AD2', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#10529E', + borderColor: '#10529E', + }, + focusVisibleStyle: { + backgroundColor: '#095AD2', + borderColor: '#156FF5', + outline: '2px solid #156FF5', + outlineOffset: 2, + }, + }, + }, + isGroupFirst: { + true: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + }, + isGroupLast: { + true: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }, + }, +}); + +export type BubbleButtonProps = GetProps & { + onClick: () => void; + label?: ReactNode; + secondary?: boolean; + icon?: IconName; + small?: boolean; + isGroupFirst?: boolean; + isGroupLast?: boolean; +}; + +export const BubbleButton = withStaticProperties( + ({ + secondary = false, + label, + onClick, + icon, + small = false, + isGroupFirst = false, + isGroupLast = false, + ...props + }: BubbleButtonProps) => ( + + {/* Arrow removed for now */} + {label && ( + + {label} + + )} + + ), + { + Frame: BubbleButtonFrame, + } +); diff --git a/packages/fuselage-tamagui/src/components/Bubble/BubbleItem.tsx b/packages/fuselage-tamagui/src/components/Bubble/BubbleItem.tsx new file mode 100644 index 0000000000..67b8165a88 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Bubble/BubbleItem.tsx @@ -0,0 +1,121 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactNode } from 'react'; +import { + GetProps, + Text, + View, + createStyledContext, + styled, + withStaticProperties, +} from '@tamagui/web'; + +// Import Icon component - we'll need to create this or use existing one +// import { Icon } from '../Icon'; + +export const BubbleItemContext = createStyledContext({ + small: false, + secondary: false, +}); + +export const BubbleItemFrame = styled(View, { + name: 'BubbleItem', + context: BubbleItemContext, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + textAlign: 'center', + whiteSpace: 'nowrap', + textDecoration: 'none', + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 4, + cursor: 'pointer', + userSelect: 'none', + fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontWeight: '500', + + // Blue button styling (like Button primary but always blue) + backgroundColor: '#156FF5', + borderColor: '#156FF5', + color: '#FFFFFF', + + variants: { + small: { + true: { + height: 38, + fontSize: 14, + lineHeight: 16, + paddingHorizontal: 8, + minWidth: 56, + }, + }, + secondary: { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#1F2329', + }, + false: { + // Blue button styling (like Button primary) + backgroundColor: '#156FF5', + borderColor: '#156FF5', + color: '#FFFFFF', + }, + }, + isGroupFirst: { + true: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + }, + isGroupLast: { + true: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + }, + }, +}); + +export type BubbleItemProps = GetProps & { + label?: ReactNode; + secondary?: boolean; + icon?: IconName; + small?: boolean; + isGroupFirst?: boolean; + isGroupLast?: boolean; +}; + +export const BubbleItem = withStaticProperties( + ({ + secondary = false, + label, + icon, + small = false, + isGroupFirst = false, + isGroupLast = false, + ...props + }: BubbleItemProps) => ( + + {/* Arrow removed for now */} + {label && ( + + {label} + + )} + + ), + { + Frame: BubbleItemFrame, + } +); diff --git a/packages/fuselage-tamagui/src/components/Bubble/index.ts b/packages/fuselage-tamagui/src/components/Bubble/index.ts new file mode 100644 index 0000000000..7c995b077c --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Bubble/index.ts @@ -0,0 +1,8 @@ +export { Bubble, BubbleFrame, BubbleContext } from './Bubble'; +export type { BubbleProps } from './Bubble'; + +export { BubbleButton, BubbleButtonFrame, BubbleButtonContext } from './BubbleButton'; +export type { BubbleButtonProps } from './BubbleButton'; + +export { BubbleItem, BubbleItemFrame, BubbleItemContext } from './BubbleItem'; +export type { BubbleItemProps } from './BubbleItem'; diff --git a/packages/fuselage-tamagui/src/components/Button/Button.stories.tsx b/packages/fuselage-tamagui/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..be77dda205 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Button/Button.stories.tsx @@ -0,0 +1,363 @@ +import type { Meta, StoryFn } from "@storybook/react" +import { Button } from "./Button" +import { Spinner, XStack, YStack, Anchor, Text, Stack, View } from 'tamagui' +import { useState } from "react" +import { action } from '@storybook/addon-actions' + +// PropsVariationSection equivalent for Tamagui - matching Fuselage exactly +const PropsVariationSection = ({ + component: Component, + common = {}, + xAxis = {}, + yAxis = {} +}: { + component: any; + common?: any; + xAxis?: Record; + yAxis?: Record; +}) => { + const xAxisKeys = Object.keys(xAxis); + const yAxisKeys = Object.keys(yAxis); + + return ( + + {/* Header */} + + + + {xAxisKeys.map((xVariation, key) => ( + + {xVariation} + + ))} + + + + {/* Body */} + + {yAxisKeys.map((yVariation, y) => ( + + + {yVariation} + + {xAxisKeys.map((xKey, x) => ( + + + + + + ))} + + ))} + + + ); +}; + +const meta: Meta = { + title: "INPUTS/Button", + component: Button, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], +}; + +export default meta; + +export const Default: StoryFn = () => ( + +); + +export const Loading: StoryFn = () => ( + +); + +export const LoadingInteraction: StoryFn = () => { + const [isLoading, setIsLoading] = useState(false); + return ( + + ); +}; + +LoadingInteraction.parameters = { + docs: { + description: { + story: 'Click the button to see the loading state.', + }, + }, +}; + +export const Truncated: StoryFn = () => ( + + + +); + +export const Variants: StoryFn = () => ( + + + + + + + + + + + + + + + + + + +); + +export const Sizes: StoryFn = () => ( + + + + + +); + +export const AsLink: StoryFn = () => ( + +); + +export const States = () => ( + <> + + + + + , + }, + 'text': { + children: 'Button', + }, + 'primary': { + children: 'Button', + primary: true, + }, + 'secondary': { + children: 'Button', + secondary: true, + }, + 'danger': { + children: 'Button', + danger: true, + }, + 'secondary-danger': { + children: 'Button', + secondary: true, + danger: true, + }, + 'warning': { + children: 'Button', + warning: true, + }, + 'secondary-warning': { + children: 'Button', + secondary: true, + warning: true, + }, + 'success': { + children: 'Button', + success: true, + }, + 'secondary-success': { + children: 'Button', + secondary: true, + success: true, + }, + }} + /> + + + + + , + }, + 'text': { + children: 'Button', + }, + 'primary': { + children: 'Button', + primary: true, + }, + 'secondary': { + children: 'Button', + secondary: true, + }, + 'danger': { + children: 'Button', + danger: true, + }, + 'secondary-danger': { + children: 'Button', + secondary: true, + danger: true, + }, + 'warning': { + children: 'Button', + warning: true, + }, + 'secondary-warning': { + children: 'Button', + secondary: true, + warning: true, + }, + 'success': { + children: 'Button', + success: true, + }, + 'secondary-success': { + children: 'Button', + secondary: true, + success: true, + }, + }} + /> + +); + +export const AsIconButton: StoryFn = () => ( +
+ + {/* Horizontal line */} + + {/* Arrowhead pointing left */} + + {/* Short vertical line on the right */} + + +
+); +AsIconButton.parameters = { + docs: { + description: { + story: + 'See full IconButton documentation [here](../?path=/docs/inputs-iconbutton)', + }, + }, +}; \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Button/Button.stories.tsx.new b/packages/fuselage-tamagui/src/components/Button/Button.stories.tsx.new new file mode 100644 index 0000000000..c240f7e5a1 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Button/Button.stories.tsx.new @@ -0,0 +1,130 @@ +import type { Meta, StoryFn } from "@storybook/react" +import { Button } from "./Button" +import { Spinner, XStack, YStack, Anchor, Text, Stack } from 'tamagui' +import { useState } from "react" + +const meta: Meta = { + title: "INPUTS/Button", + component: Button, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} + +export default meta + +interface ButtonStateProps { + variant?: 'primary' | 'secondary' | 'danger' | 'secondary-danger' | 'warning' | 'secondary-warning' | 'success' | 'secondary-success' + state?: 'default' | 'hover' | 'active' | 'focus' | 'disabled' + size?: '$sm' | '$md' + withIcon?: boolean +} + +const ButtonState = ({ variant, state, size = '$md', withIcon }: ButtonStateProps) => { + const stateProps = { + ...(state === 'hover' && { className: 'hover' }), + ...(state === 'active' && { className: 'active' }), + ...(state === 'focus' && { className: 'focus focus-visible' }), + ...(state === 'disabled' && { disabled: true }) + } + + const variantProps = { + ...(variant === 'primary' && { Primary: true }), + ...(variant === 'secondary' && { Secondary: true }), + ...(variant === 'danger' && { Danger: true }), + ...(variant === 'secondary-danger' && { SecondaryDanger: true }), + ...(variant === 'warning' && { Warning: true }), + ...(variant === 'secondary-warning' && { SecondaryWarning: true }), + ...(variant === 'success' && { Success: true }), + ...(variant === 'secondary-success' && { SecondarySuccess: true }) + } + + return ( + + ) +} + +export const States: StoryFn = () => { + const variants = [ + { label: 'icon + text', withIcon: true }, + { label: 'text' }, + { label: 'primary', variant: 'primary' }, + { label: 'secondary', variant: 'secondary' }, + { label: 'danger', variant: 'danger' }, + { label: 'secondary-danger', variant: 'secondary-danger' }, + { label: 'warning', variant: 'warning' }, + { label: 'secondary-warning', variant: 'secondary-warning' }, + { label: 'success', variant: 'success' }, + { label: 'secondary-success', variant: 'secondary-success' } + ] + + const ButtonGrid = ({ size }: { size?: '$sm' | '$md' }) => ( + + {variants.map(({ label, variant, withIcon }) => ( + + + + {label} + + + + + + + + + + + ))} + + ) + + return ( + + States + + {/* Header */} + + + + default + hover + active + focus + disabled + + + + {/* Regular size buttons */} + + + {/* Small size buttons */} + + + + ) +} + +export const Sizes: StoryFn = () => ( + + + + + +) + +export const AsLink: StoryFn = () => ( + + + +) diff --git a/packages/fuselage-tamagui/src/components/Button/Button.tsx b/packages/fuselage-tamagui/src/components/Button/Button.tsx new file mode 100644 index 0000000000..e561446b9a --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Button/Button.tsx @@ -0,0 +1,681 @@ + +import { + GetProps, + SizeTokens, + View, + Text, + createStyledContext, + styled, + withStaticProperties, +} from '@tamagui/web' +import { isValidElement } from 'react' +import './button.css' + +export const ButtonContext = createStyledContext({ + size: 'medium' as SizeTokens, +}) + +export const ButtonFrame = styled(View, { + name: 'Button', + context: ButtonContext, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + textAlign: 'center', + whiteSpace: 'nowrap', + textDecoration: 'none', + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 4, + cursor: 'pointer', + userSelect: 'none', + fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontWeight: '500', + appearance: 'none', + + // Default secondary button styling (matching Fuselage exactly) + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#1F2329', + + hoverStyle: { + backgroundColor: '#D7DBE0', + borderColor: '#CBCED1', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#CBCED1', + borderColor: '#B8BEC4', + }, + focusVisibleStyle: { + backgroundColor: '#EBECEF', + borderColor: '#1F2329', + color: '#1F2329', + borderWidth: 2, + boxShadow: '0 0 0 2px #156FF5', + }, + + variants: { + // Size variants matching original Fuselage exactly + size: { + default: { + height: 48, + paddingHorizontal: 24, + minWidth: 96, // 2 * height + fontSize: 18, + lineHeight: 28, + }, + tiny: { + height: 24, + paddingHorizontal: 8, + minWidth: 48, // 2 * height + fontSize: 12, + lineHeight: 16, + }, + mini: { + height: 20, + paddingHorizontal: 8, + minWidth: 40, // 2 * height + fontSize: 12, + lineHeight: 16, + }, + small: { + height: 28, + paddingHorizontal: 8, + minWidth: 56, // 2 * height + fontSize: 14, + lineHeight: 16, + }, + medium: { + height: 32, + paddingHorizontal: 12, + minWidth: 64, // 2 * height + fontSize: 14, + lineHeight: 16, + }, + large: { + height: 48, + paddingHorizontal: 24, + minWidth: 96, // 2 * height + fontSize: 18, + lineHeight: 28, + }, + }, + square: { + true: { + aspectRatio: 1, + paddingHorizontal: 0, + minWidth: 'auto', + width: 'auto', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + }, + }, + // Square size variants matching Fuselage exactly + 'tiny-square': { + true: { + width: 24, + minWidth: 24, + height: 24, + padding: 0, + }, + }, + 'mini-square': { + true: { + width: 20, + minWidth: 20, + height: 20, + padding: 0, + }, + }, + 'small-square': { + true: { + width: 28, + minWidth: 28, + height: 28, + padding: 0, + }, + }, + 'medium-square': { + true: { + width: 32, + minWidth: 32, + height: 32, + padding: 0, + }, + }, + 'large-square': { + true: { + width: 40, + minWidth: 40, + height: 40, + padding: 0, + }, + }, + loading: { + true: { + opacity: 0.6, + pointerEvents: 'none', + backgroundColor: '#F7F8FA', + borderColor: '#F2F3F5', + color: '#9EA2A8', + }, + }, + disabled: { + true: { + opacity: 1, + cursor: 'not-allowed', + pointerEvents: 'none', + }, + }, + // Disabled variants for different button types + 'disabled-primary': { + true: { + backgroundColor: '#D1EBFE', + borderColor: '#D1EBFE', + color: '#FFFFFF', + }, + }, + 'disabled-secondary': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#9EA2A8', + }, + }, + 'disabled-danger': { + true: { + backgroundColor: '#FFC1C9', + borderColor: '#FFC1C9', + color: '#FFFFFF', + }, + }, + 'disabled-warning': { + true: { + backgroundColor: '#C0F6E4', + borderColor: '#C0F6E4', + color: '#FFFFFF', + }, + }, + 'disabled-success': { + true: { + backgroundColor: '#C0F6E4', + borderColor: '#C0F6E4', + color: '#FFFFFF', + }, + }, + 'disabled-secondary-danger': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#F98F9D', + }, + }, + 'disabled-secondary-warning': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#F7B27B', + }, + }, + 'disabled-secondary-success': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#96F0D2', + }, + }, + 'disabled-secondary-info': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#76B7FC', + }, + }, + // Button variants matching original Fuselage exactly + primary: { + true: { + backgroundColor: '#156FF5', + borderColor: '#156FF5', + color: '#FFFFFF', + hoverStyle: { + backgroundColor: '#095AD2', + borderColor: '#095AD2', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#0747A6', + borderColor: '#0747A6', + }, + focusVisibleStyle: { + backgroundColor: '#156FF5', + borderColor: '#1F2329', + color: '#FFFFFF', + borderWidth: 2, + boxShadow: '0 0 0 2px #156FF5', + }, + }, + }, + secondary: { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#1F2329', + hoverStyle: { + backgroundColor: '#D7DBE0', + borderColor: '#CBCED1', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#CBCED1', + borderColor: '#B8BEC4', + }, + focusVisibleStyle: { + backgroundColor: '#EBECEF', + borderColor: '#1F2329', + color: '#1F2329', + borderWidth: 2, + boxShadow: '0 0 0 2px #1F2329', + }, + }, + }, + danger: { + true: { + backgroundColor: '#EC0D2A', + borderColor: '#EC0D2A', + color: '#FFFFFF', + hoverStyle: { + backgroundColor: '#D40C26', + borderColor: '#D40C26', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#A5091C', + borderColor: '#A5091C', + }, + focusVisibleStyle: { + backgroundColor: '#EC0D2A', + borderColor: '#1F2329', + color: '#FFFFFF', + borderWidth: 2, + boxShadow: '0 0 0 2px #EC0D2A', + }, + }, + }, + warning: { + true: { + backgroundColor: '#FFD031', + borderColor: '#FFD031', + color: '#1F2329', + hoverStyle: { + backgroundColor: '#F3BE08', + borderColor: '#F3BE08', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#C99A00', + borderColor: '#C99A00', + }, + focusVisibleStyle: { + backgroundColor: '#FFD031', + borderColor: '#1F2329', + color: '#1F2329', + borderWidth: 2, + boxShadow: '0 0 0 2px #FFD031', + }, + }, + }, + success: { + true: { + backgroundColor: '#2DE0A5', + borderColor: '#2DE0A5', + color: '#FFFFFF', + hoverStyle: { + backgroundColor: '#1ECB92', + borderColor: '#1ECB92', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#0F9B6B', + borderColor: '#0F9B6B', + }, + focusVisibleStyle: { + backgroundColor: '#2DE0A5', + borderColor: '#1F2329', + color: '#FFFFFF', + borderWidth: 2, + boxShadow: '0 0 0 2px #2DE0A5', + }, + }, + }, + info: { + true: { + backgroundColor: '#156FF5', + borderColor: '#156FF5', + color: '#FFFFFF', + hoverStyle: { + backgroundColor: '#095AD2', + borderColor: '#095AD2', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#0747A6', + borderColor: '#0747A6', + }, + focusVisibleStyle: { + backgroundColor: '#156FF5', + borderColor: '#1F2329', + color: '#FFFFFF', + borderWidth: 2, + boxShadow: '0 0 0 2px #156FF5', + }, + }, + }, + // Secondary variants + 'secondary-danger': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#EC0D2A', + hoverStyle: { + backgroundColor: '#D7DBE0', + borderColor: '#CBCED1', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#CBCED1', + borderColor: '#B8BEC4', + }, + focusVisibleStyle: { + backgroundColor: '#EBECEF', + borderColor: '#1F2329', + color: '#EC0D2A', + borderWidth: 2, + boxShadow: '0 0 0 2px #EC0D2A', + }, + }, + }, + 'secondary-warning': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#F3BE08', + hoverStyle: { + backgroundColor: '#D7DBE0', + borderColor: '#CBCED1', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#CBCED1', + borderColor: '#B8BEC4', + }, + focusVisibleStyle: { + backgroundColor: '#EBECEF', + borderColor: '#1F2329', + color: '#F3BE08', + borderWidth: 2, + boxShadow: '0 0 0 2px #FFD031', + }, + }, + }, + 'secondary-success': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#2DE0A5', + hoverStyle: { + backgroundColor: '#D7DBE0', + borderColor: '#CBCED1', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#CBCED1', + borderColor: '#B8BEC4', + }, + focusVisibleStyle: { + backgroundColor: '#EBECEF', + borderColor: '#1F2329', + color: '#2DE0A5', + borderWidth: 2, + boxShadow: '0 0 0 2px #2DE0A5', + }, + }, + }, + 'secondary-info': { + true: { + backgroundColor: '#EBECEF', + borderColor: '#E4E7EA', + color: '#156FF5', + hoverStyle: { + backgroundColor: '#D7DBE0', + borderColor: '#CBCED1', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: '#CBCED1', + borderColor: '#B8BEC4', + }, + focusVisibleStyle: { + backgroundColor: '#EBECEF', + borderColor: '#1F2329', + color: '#156FF5', + borderWidth: 2, + boxShadow: '0 0 0 2px #156FF5', + }, + }, + }, + // Link variant + asLink: { + true: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '#156FF5', + hoverStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '#095AD2', + cursor: 'pointer', + }, + pressStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '#10529E', + }, + focusVisibleStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + boxShadow: '0 0 0 2px #156FF5', + }, + }, + }, + } as const, + + defaultVariants: { + size: 'default', + }, +}) + +type ButtonProps = GetProps & { + icon?: any; + loading?: boolean; + external?: boolean; + square?: boolean; + href?: string; + asLink?: boolean; + primary?: boolean; + secondary?: boolean; + danger?: boolean; + warning?: boolean; + success?: boolean; + info?: boolean; + tiny?: boolean; + mini?: boolean; + small?: boolean; + medium?: boolean; + large?: boolean; +}; + +export const ButtonText = styled(Text, { + name: 'ButtonText', + context: ButtonContext, + color: 'inherit', + userSelect: 'none', + fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontWeight: '500', + textAlign: 'center', + whiteSpace: 'nowrap', + textDecoration: 'none', + width: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', +}) + +const ButtonIcon = (props: { children: any }) => { + return isValidElement(props.children) ? props.children : null +} + +// Add User icon component matching Fuselage +const AddUserIcon = () => ( + + + + + + +) + +const ButtonComponent = ({ + icon, + loading, + external, + children, + disabled, + href, + asLink, + is = 'button', + primary, + secondary, + danger, + warning, + success, + info, + tiny, + mini, + small, + medium, + large, + square, + ...props +}: ButtonProps) => { + // Determine size variant + const sizeVariant = tiny ? 'tiny' : + mini ? 'mini' : + small ? 'small' : + medium ? 'medium' : + large ? 'large' : 'default'; + + // Determine button variant (matching Fuselage logic exactly) + const buttonVariant = primary ? 'primary' : + secondary && success ? 'secondary-success' : + secondary && warning ? 'secondary-warning' : + secondary && danger ? 'secondary-danger' : + success ? 'success' : + warning ? 'warning' : + danger ? 'danger' : + secondary ? 'secondary' : undefined; + + // Determine square variants + const squareVariants = square ? { + 'tiny-square': tiny, + 'mini-square': mini, + 'small-square': small, + 'medium-square': medium, + 'large-square': large, + } : {}; + + // Determine disabled variants + const isDisabled = disabled || loading; + const disabledVariant = isDisabled ? + (primary ? 'disabled-primary' : + secondary && success ? 'disabled-secondary-success' : + secondary && warning ? 'disabled-secondary-warning' : + secondary && danger ? 'disabled-secondary-danger' : + success ? 'disabled-success' : + warning ? 'disabled-warning' : + danger ? 'disabled-danger' : + secondary ? 'disabled-secondary' : undefined) : undefined; + + const extraProps = { + ...(external ? { + rel: 'noopener noreferrer', + target: '_blank', + } : {}), + ...(href ? { href } : {}), + ...(asLink || is === 'a' ? { tag: 'a' } : {}), + }; + + return ( + + + {icon && !loading && {icon}} + {loading && ( + + )} + {children} + + + ); +}; + +export const Button = withStaticProperties(ButtonComponent, { + Props: ButtonContext.Provider, + Text: ButtonText, + Icon: ButtonIcon, +}); \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Button/button.css b/packages/fuselage-tamagui/src/components/Button/button.css new file mode 100644 index 0000000000..99595b0bf4 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Button/button.css @@ -0,0 +1,236 @@ +.storybook-button { + display: inline-block; + cursor: pointer; + border: 0; + border-radius: 3em; + font-weight: 700; + line-height: 1; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +.storybook-button--primary { + background-color: #555ab9; + color: white; +} +.storybook-button--secondary { + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; + background-color: transparent; + color: #333; +} +.storybook-button--small { + padding: 10px 16px; + font-size: 12px; +} +.storybook-button--medium { + padding: 11px 20px; + font-size: 14px; +} +.storybook-button--large { + padding: 12px 24px; + font-size: 16px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Default button states */ +.hover { + background-color: #D7DBE0 !important; + border-color: #CBCED1 !important; +} + +.active { + background-color: #CBCED1 !important; + border-color: #B8BEC4 !important; +} + +/* Primary button states */ +[data-primary="true"].hover { + background-color: #095AD2 !important; + border-color: #095AD2 !important; +} + +[data-primary="true"].active { + background-color: #0747A6 !important; + border-color: #0747A6 !important; +} + +/* Secondary button states */ +[data-secondary="true"].hover { + background-color: #D7DBE0 !important; + border-color: #CBCED1 !important; +} + +[data-secondary="true"].active { + background-color: #CBCED1 !important; + border-color: #B8BEC4 !important; +} + +/* Danger button states */ +[data-danger="true"].hover { + background-color: #D40C26 !important; + border-color: #D40C26 !important; +} + +[data-danger="true"].active { + background-color: #A5091C !important; + border-color: #A5091C !important; +} + +/* Warning button states */ +[data-warning="true"].hover { + background-color: #F3BE08 !important; + border-color: #F3BE08 !important; +} + +[data-warning="true"].active { + background-color: #C99A00 !important; + border-color: #C99A00 !important; +} + +/* Success button states */ +[data-success="true"].hover { + background-color: #1ECB92 !important; + border-color: #1ECB92 !important; +} + +[data-success="true"].active { + background-color: #0F9B6B !important; + border-color: #0F9B6B !important; +} + +/* Info button states */ +[data-info="true"].hover { + background-color: #095AD2 !important; + border-color: #095AD2 !important; +} + +[data-info="true"].active { + background-color: #0747A6 !important; + border-color: #0747A6 !important; +} + +/* Secondary-danger button states */ +[data-secondary-danger="true"].hover { + background-color: #D7DBE0 !important; + border-color: #CBCED1 !important; +} + +[data-secondary-danger="true"].active { + background-color: #CBCED1 !important; + border-color: #B8BEC4 !important; +} + +/* Secondary-warning button states */ +[data-secondary-warning="true"].hover { + background-color: #D7DBE0 !important; + border-color: #CBCED1 !important; +} + +[data-secondary-warning="true"].active { + background-color: #CBCED1 !important; + border-color: #B8BEC4 !important; +} + +/* Secondary-success button states */ +[data-secondary-success="true"].hover { + background-color: #D7DBE0 !important; + border-color: #CBCED1 !important; +} + +[data-secondary-success="true"].active { + background-color: #CBCED1 !important; + border-color: #B8BEC4 !important; +} + +/* Secondary-info button states */ +[data-secondary-info="true"].hover { + background-color: #D7DBE0 !important; + border-color: #CBCED1 !important; +} + +[data-secondary-info="true"].active { + background-color: #CBCED1 !important; + border-color: #B8BEC4 !important; +} + +/* Focus state styles for button variants */ +.focus.focus-visible { + border: 2px solid #1F2329 !important; + box-shadow: 0 0 0 2px !important; +} + +/* Default/Secondary button focus */ +.focus.focus-visible { + background-color: #EBECEF !important; + color: #1F2329 !important; + box-shadow: 0 0 0 2px #156FF5 !important; +} + +/* Primary button focus - keeps blue background */ +[data-primary="true"].focus.focus-visible { + background-color: #156FF5 !important; + color: #FFFFFF !important; + box-shadow: 0 0 0 2px #156FF5 !important; +} + +/* Danger button focus - keeps red background */ +[data-danger="true"].focus.focus-visible { + background-color: #EC0D2A !important; + color: #FFFFFF !important; + box-shadow: 0 0 0 2px #EC0D2A !important; +} + +/* Warning button focus - keeps yellow background */ +[data-warning="true"].focus.focus-visible { + background-color: #FFD031 !important; + color: #1F2329 !important; + box-shadow: 0 0 0 2px #FFD031 !important; +} + +/* Success button focus - keeps green background */ +[data-success="true"].focus.focus-visible { + background-color: #2DE0A5 !important; + color: #FFFFFF !important; + box-shadow: 0 0 0 2px #2DE0A5 !important; +} + +/* Info button focus - keeps blue background */ +[data-info="true"].focus.focus-visible { + background-color: #156FF5 !important; + color: #FFFFFF !important; + box-shadow: 0 0 0 2px #156FF5 !important; +} + +/* Secondary-danger button focus - grey background with red text */ +[data-secondary-danger="true"].focus.focus-visible { + background-color: #EBECEF !important; + color: #EC0D2A !important; + box-shadow: 0 0 0 2px #EC0D2A !important; +} + +/* Secondary-warning button focus - grey background with yellow text */ +[data-secondary-warning="true"].focus.focus-visible { + background-color: #EBECEF !important; + color: #F3BE08 !important; + box-shadow: 0 0 0 2px #FFD031 !important; +} + +/* Secondary-success button focus - grey background with green text */ +[data-secondary-success="true"].focus.focus-visible { + background-color: #EBECEF !important; + color: #2DE0A5 !important; + box-shadow: 0 0 0 2px #2DE0A5 !important; +} + +/* Secondary-info button focus - grey background with blue text */ +[data-secondary-info="true"].focus.focus-visible { + background-color: #EBECEF !important; + color: #156FF5 !important; + box-shadow: 0 0 0 2px #156FF5 !important; +} diff --git a/packages/fuselage-tamagui/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/fuselage-tamagui/src/components/ButtonGroup/ButtonGroup.stories.tsx new file mode 100644 index 0000000000..bb1690b71a --- /dev/null +++ b/packages/fuselage-tamagui/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Button } from '../Button/Button' +import { ButtonGroup } from './ButtonGroup' + +const meta: Meta = { + title: 'INPUTS/ButtonGroup', + component: ButtonGroup, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + + + ), +} + +export const Large: Story = { + render: () => ( + + + + + + ), +} + +export const Small: Story = { + render: () => ( + + + + + + ), +} + +export const Wrap: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + ), +} + +export const Stretch: Story = { + render: () => ( + + + + + + ), +} + +export const Vertical: Story = { + render: () => ( + + + + + + ), +} + +export const VerticalLarge: Story = { + render: () => ( + + + + + + ), +} + +export const VerticalSmall: Story = { + render: () => ( + + + + + + ), +} + +export const VerticalStretch: Story = { + render: () => ( + + + + + + ), +} + +export const AlignedAtCenter: Story = { + render: () => ( + + + + + + ), +} + +export const AlignedAtStart: Story = { + render: () => ( + + + + + + ), +} + +export const AlignedAtEnd: Story = { + render: () => ( + + + + + + ), +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/ButtonGroup/ButtonGroup.tsx b/packages/fuselage-tamagui/src/components/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 0000000000..043a292492 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,132 @@ +import { styled, XStack, GetProps } from 'tamagui' +import type { ReactNode } from 'react' + +const StyledButtonGroup = styled(XStack, { + name: 'ButtonGroup', + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'flex-start', + alignItems: 'center', + gap: 16, // Default gap between buttons + + variants: { + size: { + small: { gap: 8 }, + medium: { gap: 16 }, + large: { gap: 24 }, + }, + align: { + start: { justifyContent: 'flex-start' }, + center: { justifyContent: 'center' }, + end: { justifyContent: 'flex-end' }, + }, + vertical: { + true: { + flexDirection: 'column', + alignItems: 'stretch', + gap: 12, // Vertical gap + }, + }, + wrap: { + true: { + flexWrap: 'wrap', + marginBottom: -16, + rowGap: 16, // Vertical gap in wrap mode + columnGap: 16, // Horizontal gap in wrap mode + }, + }, + stretch: { + true: { + justifyContent: 'stretch', + alignItems: 'stretch', + flexGrow: 1, + }, + }, + } as const, + + defaultVariants: { + align: 'start', + }, +}) + +// Styled wrapper for button items to handle spacing +const ButtonGroupItem = styled(XStack, { + name: 'ButtonGroupItem', + // Remove individual margins since we're using gap + + // Small size spacing + '.rcx-button-group--small &': { + // Gap is handled by parent + }, + + // Large size spacing + '.rcx-button-group--large &': { + // Gap is handled by parent + }, + + // Wrap mode spacing + '.rcx-button-group--wrap > &': { + // Gap is handled by parent with rowGap and columnGap + }, + + // Stretch mode + '.rcx-button-group--stretch > &': { + flexGrow: 1, + }, + + // Vertical mode + '.rcx-button-group--vertical &': { + // Gap is handled by parent + }, + + // Vertical large spacing + '.rcx-button-group--vertical.rcx-button-group--large > &': { + // Gap is handled by parent + }, + + // Vertical small spacing + '.rcx-button-group--vertical.rcx-button-group--small > &': { + // Gap is handled by parent + }, +}) + +export type ButtonGroupProps = GetProps & { + children?: ReactNode + align?: 'start' | 'center' | 'end' + vertical?: boolean + wrap?: boolean + stretch?: boolean + small?: boolean + large?: boolean +} + +export const ButtonGroup = ({ + children, + small, + large, + ...props +}: ButtonGroupProps) => { + // Determine size class + const sizeClass = small ? 'rcx-button-group--small' : + large ? 'rcx-button-group--large' : ''; + + // Build className for styling + const className = [ + 'rcx-button-group', + props.stretch && 'rcx-button-group--stretch', + props.vertical && 'rcx-button-group--vertical', + props.wrap && 'rcx-button-group--wrap', + sizeClass, + ].filter(Boolean).join(' '); + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/ButtonGroup/index.ts b/packages/fuselage-tamagui/src/components/ButtonGroup/index.ts new file mode 100644 index 0000000000..654bfdba2c --- /dev/null +++ b/packages/fuselage-tamagui/src/components/ButtonGroup/index.ts @@ -0,0 +1 @@ +export * from './ButtonGroup' \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.spec.tsx b/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.spec.tsx new file mode 100644 index 0000000000..8f371c26e3 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.spec.tsx @@ -0,0 +1,41 @@ +import { render, fireEvent } from '@testing-library/react' +import { CheckBox } from './CheckBox' + +describe('CheckBox', () => { + it('renders without crashing', () => { + render() + }) + + it('handles checked state correctly', () => { + const { getByRole } = render() + const checkbox = getByRole('checkbox') as HTMLInputElement + + expect(checkbox.checked).toBe(false) + fireEvent.click(checkbox) + expect(checkbox.checked).toBe(true) + }) + + it('handles indeterminate state correctly', () => { + const { getByRole } = render() + const checkbox = getByRole('checkbox') as HTMLInputElement + + expect(checkbox.indeterminate).toBe(true) + }) + + it('handles disabled state correctly', () => { + const { getByRole } = render() + const checkbox = getByRole('checkbox') as HTMLInputElement + + expect(checkbox.disabled).toBe(true) + }) + + it('calls onChange handler', () => { + const handleChange = jest.fn() + const { getByRole } = render( + + ) + + fireEvent.click(getByRole('checkbox')) + expect(handleChange).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.stories.tsx b/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.stories.tsx new file mode 100644 index 0000000000..dfd3e88ad8 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { YStack, XStack, Text } from 'tamagui'; + +import { CheckBox } from './CheckBox'; + +const meta = { + title: 'INPUTS/CheckBox', + component: CheckBox, + parameters: { + layout: 'centered', + }, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large'], + }, + checked: { + control: 'boolean', + }, + indeterminate: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'aria-label': 'Default checkbox', + }, +}; + +export const Checked: Story = { + args: { + 'aria-label': 'Checked checkbox', + 'checked': true, + }, +}; + +export const Indeterminate: Story = { + args: { + 'aria-label': 'Indeterminate checkbox', + 'indeterminate': true, + }, +}; + +export const Disabled: Story = { + args: { + 'aria-label': 'Disabled checkbox', + 'disabled': true, + }, +}; + +export const DisabledChecked: Story = { + args: { + 'aria-label': 'Disabled checked checkbox', + 'disabled': true, + 'checked': true, + }, +}; + +export const States: Story = { + render: () => ( + + + checked + unchecked + + + + {/* Default row */} + + default + + + + + {/* Hover row */} + + hover + + + + + {/* Active row */} + + active + + + + + {/* Focus row */} + + focus + + + + + {/* Disabled row */} + + disabled + + + + + + ), +}; diff --git a/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.tsx b/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.tsx new file mode 100644 index 0000000000..5d21d69831 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/CheckBox/CheckBox.tsx @@ -0,0 +1,207 @@ +import { styled, Stack, GetProps, YStack } from 'tamagui' +import type { ComponentProps } from 'react' +import { forwardRef, useLayoutEffect, useRef, useCallback } from 'react' + +const CheckBoxWrapper = styled(YStack, { + name: 'CheckBox', + display: 'inline-flex', + cursor: 'pointer', + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + + variants: { + size: { + small: { width: 16, height: 16 }, + medium: { width: 20, height: 20 }, + large: { width: 24, height: 24 }, + }, + }, + + defaultVariants: { + size: 'medium', + }, +}) + +const CheckBoxFake = styled(Stack, { + position: 'relative', + width: '100%', + height: '100%', + borderWidth: 2, + borderColor: '#E4E7EA', + borderRadius: 4, + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + transition: 'all 0.2s ease', + + // Hover state + hoverStyle: { + borderColor: '#156FF5', + }, + + // Focus state + focusStyle: { + borderColor: '#156FF5', + outline: '2px solid #156FF5', + outlineOffset: 2, + }, + + variants: { + checked: { + true: { + backgroundColor: '#156FF5', + borderColor: '#156FF5', + }, + }, + indeterminate: { + true: { + backgroundColor: '#156FF5', + borderColor: '#156FF5', + }, + }, + disabled: { + true: { + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: '#F7F8FA', + borderColor: '#E4E7EA', + hoverStyle: { + borderColor: '#E4E7EA', + }, + focusStyle: { + borderColor: '#E4E7EA', + outline: 'none', + }, + }, + }, + } as const, +}) + +const CheckMark = styled(Stack, { + width: '60%', + height: '60%', + alignItems: 'center', + justifyContent: 'center', +}) + +const IndeterminateMark = styled(Stack, { + width: '60%', + height: 2, + backgroundColor: '#FFFFFF', + borderRadius: 1, +}) + +export type CheckBoxProps = GetProps & { + indeterminate?: boolean + checked?: boolean + defaultChecked?: boolean + disabled?: boolean + onChange?: (event: React.ChangeEvent) => void + size?: 'small' | 'medium' | 'large' + 'aria-label'?: string +} + +export const CheckBox = forwardRef(function CheckBox( + { + indeterminate, + checked, + defaultChecked, + disabled, + onChange, + size = 'medium', + 'aria-label': ariaLabel, + ...props + }, + ref +) { + const innerRef = useRef(null) + const mergedRef = (node: HTMLInputElement) => { + innerRef.current = node + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + } + + useLayoutEffect(() => { + if (innerRef.current && indeterminate !== undefined) { + innerRef.current.indeterminate = indeterminate + } + }, [indeterminate]) + + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + if (innerRef.current && indeterminate !== undefined) { + innerRef.current.indeterminate = indeterminate + } + if (onChange) { + onChange(event) + } + }, + [indeterminate, onChange] + ) + + const handleWrapperClick = useCallback((e: React.MouseEvent) => { + if (innerRef.current && !disabled) { + innerRef.current.checked = !innerRef.current.checked + const event = new Event('change', { bubbles: true }) + innerRef.current.dispatchEvent(event) + } + }, [disabled]) + + return ( + + + + {indeterminate ? ( + + ) : checked ? ( + + + + + + ) : null} + + + ) +}) + +CheckBox.displayName = 'CheckBox' \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/CheckBox/index.ts b/packages/fuselage-tamagui/src/components/CheckBox/index.ts new file mode 100644 index 0000000000..26750bca06 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/CheckBox/index.ts @@ -0,0 +1 @@ +export * from './CheckBox' \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Chip/Chip.tsx b/packages/fuselage-tamagui/src/components/Chip/Chip.tsx new file mode 100644 index 0000000000..687e851e3a --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Chip/Chip.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import type { ButtonProps } from 'tamagui'; +import { Button, Image, Text, XStack } from 'tamagui'; + +import { Icon } from '../Icon'; + +type ChipProps = ButtonProps & { + thumbUrl?: string; + renderThumb?: (props: { url: string }) => ReactNode; + renderDismissSymbol?: () => ReactNode; +}; + +const defaultRenderThumb = ({ url }: { url: string }) => ( + +); + +const defaultRenderDismissSymbol = () => ; + +export const Chip = ({ + children, + thumbUrl, + onPress, + renderThumb = defaultRenderThumb, + renderDismissSymbol = defaultRenderDismissSymbol, + ...rest +}: ChipProps) => { + const onDismiss = onPress; + + return ( + + ); +}; + +Chip.displayName = 'Chip'; diff --git a/packages/fuselage-tamagui/src/components/Chip/index.ts b/packages/fuselage-tamagui/src/components/Chip/index.ts new file mode 100644 index 0000000000..99a3b02412 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Chip/index.ts @@ -0,0 +1 @@ +export * from './Chip'; diff --git a/packages/fuselage-tamagui/src/components/Divider/Divider.stories.tsx b/packages/fuselage-tamagui/src/components/Divider/Divider.stories.tsx new file mode 100644 index 0000000000..a05e18b1c0 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Divider/Divider.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Divider } from './Divider' +import { XStack, Button } from 'tamagui' +import { Icon } from '../Icon/Icon' + +const meta: Meta = { + title: 'Data Display/Divider', + component: Divider, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => +} + +export const WithText: Story = { + render: () => Divider +} + +export const Vertical: Story = { + render: () => ( + + ) +} + +export const AsButtonSeparator: Story = { + render: () => ( + + + + + +``` + +### Danger Variant +```tsx +Error Section +``` + +## Accessibility + +- Uses `accessibilityRole="separator"` for screen readers +- Properly indicates content separation diff --git a/packages/fuselage-tamagui/src/components/Divider/index.ts b/packages/fuselage-tamagui/src/components/Divider/index.ts new file mode 100644 index 0000000000..8a62ff19a7 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Divider/index.ts @@ -0,0 +1 @@ +export { Divider } from './Divider' diff --git a/packages/fuselage-tamagui/src/components/Divider/index.tsx b/packages/fuselage-tamagui/src/components/Divider/index.tsx new file mode 100644 index 0000000000..a2a19795be --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Divider/index.tsx @@ -0,0 +1 @@ +export * from './Divider' \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.spec.tsx b/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.spec.tsx new file mode 100644 index 0000000000..00f41c1a0d --- /dev/null +++ b/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.spec.tsx @@ -0,0 +1,50 @@ +import { render, fireEvent } from '@testing-library/react' +import { EmailInput } from './EmailInput' + +describe('EmailInput', () => { + it('renders without crashing', () => { + render() + }) + + it('renders with placeholder', () => { + const { getByPlaceholderText } = render( + + ) + expect(getByPlaceholderText('Enter email')).toBeInTheDocument() + }) + + it('handles value changes', () => { + const handleChange = jest.fn() + const { getByRole } = render( + + ) + + fireEvent.change(getByRole('textbox'), { + target: { value: 'test@example.com' }, + }) + expect(handleChange).toHaveBeenCalled() + }) + + it('displays addon when provided', () => { + const { container } = render( + } aria-label="Email input" /> + ) + expect(container.querySelector('[data-testid="addon"]')).toBeInTheDocument() + }) + + it('applies error styles when error is provided', () => { + const { getByRole } = render( + + ) + expect(getByRole('textbox')).toHaveStyle({ + borderColor: expect.any(String), + }) + }) + + it('applies disabled styles when disabled', () => { + const { getByRole } = render( + + ) + expect(getByRole('textbox')).toBeDisabled() + }) +}) \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.stories.tsx b/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.stories.tsx new file mode 100644 index 0000000000..4b4464c48d --- /dev/null +++ b/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { YStack } from 'tamagui'; + +import { Icon } from '../Icon'; + +import { EmailInput } from './EmailInput'; + +const meta = { + title: 'Inputs/EmailInput', + component: EmailInput, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'aria-label': 'email', + }, +}; + +export const WithPlaceholder: Story = { + args: { + 'aria-label': 'email', + 'placeholder': 'Placeholder', + }, +}; + +export const WithValue: Story = { + args: { + 'aria-label': 'email', + 'defaultValue': 'support@rocket.chat', + }, +}; + +export const WithAddon: Story = { + args: { + 'aria-label': 'email', + 'addon': , + }, +}; + +export const Small: Story = { + args: { + 'aria-label': 'email', + 'size': 'small', + }, +}; + +export const WithError: Story = { + args: { + 'aria-label': 'email', + 'error': 'Error', + }, +}; + +export const Disabled: Story = { + args: { + 'aria-label': 'email', + 'disabled': true, + }, +}; + +export const States: Story = { + render: () => ( + + + } + /> + + + + } + /> + + ), +}; diff --git a/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.tsx b/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.tsx new file mode 100644 index 0000000000..a44b0e33e4 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/EmailInput/EmailInput.tsx @@ -0,0 +1,50 @@ + +import type { ComponentProps, ReactNode, Ref } from 'react'; +import { forwardRef } from 'react'; + +import { InputBox } from '../InputBox'; + +type EmailInputProps = Omit, 'type'> & { + addon?: ReactNode; + error?: string; +}; + +// Import from InputBox once it's converted to tsx +type InputType = + | 'button' + | 'checkbox' + | 'color' + | 'date' + | 'datetime' + | 'datetime-local' + | 'email' + | 'file' + | 'hidden' + | 'image' + | 'month' + | 'number' + | 'password' + | 'radio' + | 'range' + | 'reset' + | 'search' + | 'submit' + | 'tel' + | 'text' + | 'time' + | 'url' + | 'week' + | 'textarea' + | 'select'; + +const type: InputType = 'email'; + +/** + * An input for email addresses. + */ +export const EmailInput = forwardRef(function EmailInput( + props: EmailInputProps, + ref: Ref, +) { + return ; +}); diff --git a/packages/fuselage-tamagui/src/components/EmailInput/index.ts b/packages/fuselage-tamagui/src/components/EmailInput/index.ts new file mode 100644 index 0000000000..b2e4121bda --- /dev/null +++ b/packages/fuselage-tamagui/src/components/EmailInput/index.ts @@ -0,0 +1 @@ +export * from './EmailInput'; diff --git a/packages/fuselage-tamagui/src/components/Field/Field.stories.tsx b/packages/fuselage-tamagui/src/components/Field/Field.stories.tsx new file mode 100644 index 0000000000..dd58c07942 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Field/Field.stories.tsx @@ -0,0 +1,218 @@ +import type { StoryFn, Meta } from '@storybook/react' +import { useState } from 'react' +import { CheckBox } from '../CheckBox' +import { InputBox } from '../InputBox' +import { RadioButton } from '../RadioButton' +import { ToggleSwitch } from '../ToggleSwitch' +import { Field } from './Field' +import { FieldDescription } from './FieldDescription' +import { FieldError } from './FieldError' +import { FieldHint } from './FieldHint' +import { FieldLabel } from './FieldLabel' +import { FieldLabelInfo } from './FieldLabelInfo' +import { FieldLink } from './FieldLink' +import { FieldRow } from './FieldRow' + +export default { + title: 'Inputs/Field', + component: Field, +} satisfies Meta + +export const Default: StoryFn = () => ( + + + Label + + + Description + + + + Error feedback + + Hint + Link + + +) + +export const WithTextInput: StoryFn = () => ( + + + Label + + + Description + + + + Error feedback + + Hint + Link + + +) + +export const WithTextArea: StoryFn = () => ( + + + Label + + + Description + + + + Error feedback + + Hint + Link + + +) + +export const WithRadioButton: StoryFn = () => { + const [isChecked, setIsChecked] = useState(false) + + const handleChange = (e: React.ChangeEvent) => { + console.log('Radio button clicked, new value:', e.target.checked) + setIsChecked(e.target.checked) + } + + return ( + + + + Label + + + + + Description - Radio button is {isChecked ? 'checked' : 'unchecked'} + Error feedback + + Hint + Link + + + ) +} + +export const WithToggleSwitch: StoryFn = () => { + const [isChecked, setIsChecked] = useState(false) + + return ( + + + + Label + + + + + Description + Error feedback + + Hint + Link + + + ) +} + +export const WithCheckbox: StoryFn = () => { + const [isChecked, setIsChecked] = useState(false) + + const handleChange = (e: React.ChangeEvent) => { + console.log('Checkbox clicked, new value:', e.target.checked) + setIsChecked(e.target.checked) + } + + return ( + + + + Label + + + + + Description - Checkbox is {isChecked ? 'checked' : 'unchecked'} + Error feedback + + Hint + Link + + + ) +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Field/Field.tsx b/packages/fuselage-tamagui/src/components/Field/Field.tsx new file mode 100644 index 0000000000..e8adcdcb2f --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Field/Field.tsx @@ -0,0 +1,15 @@ +import type { ComponentPropsWithoutRef } from 'react' +import { createContext } from 'react' +import { YStack, GetProps } from 'tamagui' + +export const FieldContext = createContext(false) + +export type FieldProps = GetProps + +export function Field(props: FieldProps) { + return ( + + + + ) +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Field/FieldDescription.tsx b/packages/fuselage-tamagui/src/components/Field/FieldDescription.tsx new file mode 100644 index 0000000000..3c6fc9e05f --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Field/FieldDescription.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithoutRef } from 'react' +import { useContext } from 'react' +import { Text, GetProps } from 'tamagui' +import { FieldContext } from './Field' + +type FieldDescriptionProps = GetProps + +export const FieldDescription = (props: FieldDescriptionProps) => { + const isInsideField = useContext(FieldContext) + + if (process.env.NODE_ENV === 'development' && !isInsideField) { + throw new Error('FieldDescription should be used as children of Field Component') + } + + return +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Field/FieldError.tsx b/packages/fuselage-tamagui/src/components/Field/FieldError.tsx new file mode 100644 index 0000000000..e69ffd5227 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Field/FieldError.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithoutRef } from 'react' +import { useContext } from 'react' +import { Text, GetProps } from 'tamagui' +import { FieldContext } from './Field' + +type FieldErrorProps = GetProps + +export const FieldError = (props: FieldErrorProps) => { + const isInsideField = useContext(FieldContext) + + if (process.env.NODE_ENV === 'development' && !isInsideField) { + throw new Error('FieldError should be used as children of Field Component') + } + + return +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Field/FieldHint.tsx b/packages/fuselage-tamagui/src/components/Field/FieldHint.tsx new file mode 100644 index 0000000000..366dd47ebd --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Field/FieldHint.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithoutRef } from 'react' +import { useContext } from 'react' +import { Text, GetProps } from 'tamagui' +import { FieldContext } from './Field' + +type FieldHintProps = GetProps + +export const FieldHint = (props: FieldHintProps) => { + const isInsideField = useContext(FieldContext) + + if (process.env.NODE_ENV === 'development' && !isInsideField) { + throw new Error('FieldHint should be used as children of Field Component') + } + + return +} \ No newline at end of file diff --git a/packages/fuselage-tamagui/src/components/Field/FieldLabel.tsx b/packages/fuselage-tamagui/src/components/Field/FieldLabel.tsx new file mode 100644 index 0000000000..a71233aa14 --- /dev/null +++ b/packages/fuselage-tamagui/src/components/Field/FieldLabel.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithoutRef } from 'react' +import { useContext } from 'react' +import { Label } from '../Label' +import { FieldContext } from './Field' + +type FieldLabelProps = ComponentPropsWithoutRef + +export const FieldLabel = (props: FieldLabelProps) => { + const isInsideField = useContext(FieldContext) + + if (process.env.NODE_ENV === 'development' && !isInsideField) { + throw new Error('FieldLabel should be used as children of Field Component') + } + + return