diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json new file mode 100644 index 0000000000..b598f9fedb --- /dev/null +++ b/.cursor/hooks/state/continual-learning.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "lastRunAtMs": 0, + "turnsSinceLastRun": 1, + "lastTranscriptMtimeMs": null, + "lastProcessedGenerationId": "6d6faa1c-92bf-4768-a486-ad183ef31872", + "trialStartedAtMs": null +} diff --git a/.gitignore b/.gitignore index 78ecd79c12..cdf6dec539 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules/ **/.storybook/jest-results.json **/bundle-report.html .env +dist \ No newline at end of file diff --git a/README.md b/README.md index 9db7fae7b6..9f46eda838 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ | 📦 [`@rocket.chat/css-supports`](/packages/css-supports) | Memoized and SSR-compatible facade of CSS.supports API | [![npm](https://img.shields.io/npm/v/@rocket.chat/css-supports?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/css-supports) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/css-supports?style=flat-square) | | 📦 [`@rocket.chat/emitter`](/packages/emitter) | Event Emitter by Rocket.Chat | [![npm](https://img.shields.io/npm/v/@rocket.chat/emitter?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/emitter) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/emitter?style=flat-square) | | 📦 [`@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-box`](/packages/fuselage-box) | Box component and styling utilities extracted from @rocket.chat/fuselage | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-box?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-box) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-box?style=flat-square) | | 📦 [`@rocket.chat/fuselage-forms`](/packages/fuselage-forms) | A set of component wrappers to provide ease of use and accessibility out-of-box. | [![npm](https://img.shields.io/npm/v/@rocket.chat/fuselage-forms?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-forms) | ![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-forms?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-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) | diff --git a/package.json b/package.json index d334287b83..15317315ea 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@rocket.chat/fuselage-monorepo", "private": true, "workspaces": [ + "apps/*", "packages/*", "tools/*" ], @@ -29,6 +30,7 @@ "@changesets/cli": "~2.30.0", "@eslint/js": "~9.39.2", "@rocket.chat/prettier-config": "workspace:~", + "@tamagui/vite-plugin": "2.0.0-rc.31", "eslint": "~9.39.2", "eslint-import-resolver-typescript": "~4.4.4", "eslint-plugin-import-x": "~4.16.2", diff --git a/packages/fuselage-box/README.md b/packages/fuselage-box/README.md new file mode 100644 index 0000000000..7466fdb9ab --- /dev/null +++ b/packages/fuselage-box/README.md @@ -0,0 +1,89 @@ + + +

+ + Rocket.Chat + +

+ +# `@rocket.chat/fuselage-box` + +> Box component and styling utilities extracted from @rocket.chat/fuselage + +--- + +[![npm@latest](https://img.shields.io/npm/v/@rocket.chat/fuselage-box/latest?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-box/v/latest) [![npm@next](https://img.shields.io/npm/v/@rocket.chat/fuselage-box/next?style=flat-square)](https://www.npmjs.com/package/@rocket.chat/fuselage-box/v/next) ![react version](https://img.shields.io/npm/dependency-version/@rocket.chat/fuselage-box/peer/react?style=flat-square) ![npm downloads](https://img.shields.io/npm/dw/@rocket.chat/fuselage-box?style=flat-square) ![License: MIT](https://img.shields.io/npm/l/@rocket.chat/fuselage-box?style=flat-square) + +![deps](https://img.shields.io/librariesio/release/npm/@rocket.chat/fuselage-box?style=flat-square) ![npm bundle size](https://img.shields.io/bundlephobia/min/@rocket.chat/fuselage-box?style=flat-square) + + + +## Install + + + +Firstly, install the peer dependencies (prerequisites): + +```sh +npm i react + +# or, if you are using yarn: + +yarn add react +``` + +Add `@rocket.chat/fuselage-box` as a dependency: + +```sh +npm i @rocket.chat/fuselage-box + +# or, if you are using yarn: + +yarn add @rocket.chat/fuselage-box +``` + + + +## Contributing + + + +Contributions, issues, and feature requests are welcome!
+Feel free to check the [issues](https://github.com/RocketChat/fuselage/issues). + + + +### Building + +As this package dependends on others in this monorepo, before anything run the following at the root directory: + + + +```sh +yarn build +``` + + + +### Linting + +To ensure the source is matching our coding style, we perform [linting](). +Before commiting, check if your code fits our style by running: + + + +```sh +yarn lint +``` + + + +Some linter warnings and errors can be automatically fixed: + + + +```sh +yarn lint-and-fix +``` + + diff --git a/packages/fuselage-box/package.json b/packages/fuselage-box/package.json new file mode 100644 index 0000000000..727117de1e --- /dev/null +++ b/packages/fuselage-box/package.json @@ -0,0 +1,54 @@ +{ + "name": "@rocket.chat/fuselage-box", + "version": "0.1.0", + "description": "Box component and styling utilities extracted from @rocket.chat/fuselage", + "homepage": "https://github.com/RocketChat/fuselage#readme", + "bugs": { + "url": "https://github.com/RocketChat/fuselage/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/fuselage.git", + "directory": "packages/fuselage-box" + }, + "license": "MIT", + "author": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "tsc", + "lint": "lint", + "lint-and-fix": "lint-and-fix" + }, + "dependencies": { + "@rocket.chat/css-in-js": "workspace:~", + "@rocket.chat/fuselage-tokens": "workspace:~", + "@rocket.chat/memo": "workspace:~", + "invariant": "^2.2.4" + }, + "devDependencies": { + "@types/invariant": "^2.2.37", + "@types/react": "~18.3.27", + "eslint": "~9.39.2", + "lint-all": "workspace:~", + "prettier": "~3.6.2", + "typescript": "~5.9.3" + }, + "peerDependencies": { + "react": "*" + }, + "volta": { + "extends": "../../package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/fuselage-box/src/Theme.ts b/packages/fuselage-box/src/Theme.ts new file mode 100644 index 0000000000..0c882cd60f --- /dev/null +++ b/packages/fuselage-box/src/Theme.ts @@ -0,0 +1,261 @@ +import tokenColors from '@rocket.chat/fuselage-tokens/colors.json'; + +import { getPaletteColor } from './getPaletteColor'; +import { toCSSColorValue } from './helpers/toCSSValue'; + +export class Var { + private name: string; + + private value: string; + + constructor(name: string, value: string) { + this.name = name; + this.value = value; + } + + toString() { + return toCSSColorValue(this.name, this.value); + } + + theme(name: string) { + return new Var(name, this.toString()); + } +} + +const white = new Var('white', '#ffffff'); + +export let throwErrorOnInvalidToken = false; +export const __setThrowErrorOnInvalidToken__ = (value: boolean) => { + throwErrorOnInvalidToken = value; +}; + +export const neutral = { + 100: new Var('neutral-100', tokenColors.n100), + 200: new Var('neutral-200', tokenColors.n200), + 250: new Var('neutral-250', tokenColors.n250), + 300: new Var('neutral-300', tokenColors.n300), + 400: new Var('neutral-400', tokenColors.n400), + 450: new Var('neutral-450', tokenColors.n450), + 500: new Var('neutral-500', tokenColors.n500), + 600: new Var('neutral-600', tokenColors.n600), + 700: new Var('neutral-700', tokenColors.n700), + 800: new Var('neutral-800', tokenColors.n800), + 900: new Var('neutral-900', tokenColors.n900), +}; + +const blue = { + 100: new Var('primary-100', tokenColors.b100), + 200: new Var('primary-200', tokenColors.b200), + 300: new Var('primary-300', tokenColors.b300), + 400: new Var('primary-400', tokenColors.b400), + 500: new Var('primary-500', tokenColors.b500), + 600: new Var('primary-600', tokenColors.b600), + 700: new Var('primary-700', tokenColors.b700), + 800: new Var('primary-800', tokenColors.b800), + 900: new Var('primary-900', tokenColors.b900), +}; + +const green = { + 100: new Var('success-100', tokenColors.g100), + 200: new Var('success-200', tokenColors.g200), + 300: new Var('success-300', tokenColors.g300), + 400: new Var('success-400', tokenColors.g400), + 500: new Var('success-500', tokenColors.g500), + 600: new Var('success-600', tokenColors.g600), + 700: new Var('success-700', tokenColors.g700), + 800: new Var('success-800', tokenColors.g800), + 900: new Var('success-900', tokenColors.g900), +}; + +const yellow = { + 100: new Var('warning-100', tokenColors.y100), + 200: new Var('warning-200', tokenColors.y200), + 300: new Var('warning-300', tokenColors.y300), + 400: new Var('warning-400', tokenColors.y400), + 500: new Var('warning-500', tokenColors.y500), + 600: new Var('warning-600', tokenColors.y600), + 700: new Var('warning-700', tokenColors.y700), + 800: new Var('warning-800', tokenColors.y800), + 900: new Var('warning-900', tokenColors.y900), +}; + +const red = { + 100: new Var('danger-100', tokenColors.r100), + 200: new Var('danger-200', tokenColors.r200), + 300: new Var('danger-300', tokenColors.r300), + 400: new Var('danger-400', tokenColors.r400), + 500: new Var('danger-500', tokenColors.r500), + 600: new Var('danger-600', tokenColors.r600), + 700: new Var('danger-700', tokenColors.r700), + 800: new Var('danger-800', tokenColors.r800), + 900: new Var('danger-900', tokenColors.r900), +}; + +const orange = { + 100: new Var('service-1-100', tokenColors.o100), + 200: new Var('service-1-200', tokenColors.o200), + 300: new Var('service-1-300', tokenColors.o300), + 400: new Var('service-1-400', tokenColors.o400), + 500: new Var('service-1-500', tokenColors.o500), + 600: new Var('service-1-600', tokenColors.o600), + 700: new Var('service-1-700', tokenColors.o700), + 800: new Var('service-1-800', tokenColors.o800), + 900: new Var('service-1-900', tokenColors.o900), +}; + +const purple = { + 100: new Var('service-2-100', tokenColors.p100), + 200: new Var('service-2-200', tokenColors.p200), + 300: new Var('service-2-300', tokenColors.p300), + 400: new Var('service-2-400', tokenColors.p400), + 500: new Var('service-2-500', tokenColors.p500), + 600: new Var('service-2-600', tokenColors.p600), + 700: new Var('service-2-700', tokenColors.p700), + 800: new Var('service-2-800', tokenColors.p800), + 900: new Var('service-2-900', tokenColors.p900), +}; + +export const surfaceColors = { + 'surface-light': white.theme('surface-light'), + 'surface-tint': neutral[100].theme('surface-tint'), + 'surface-room': white.theme('surface-room'), + 'surface-neutral': neutral[400].theme('surface-neutral'), + 'surface-disabled': neutral[100].theme('surface-disabled'), + 'surface-hover': neutral[200].theme('surface-hover'), + 'surface-selected': neutral[450].theme('surface-selected'), + 'surface-dark': neutral[800].theme('surface-dark'), + 'surface-featured': purple['700'].theme('surface-featured'), + 'surface-featured-hover': purple['800'].theme('surface-featured-hover'), + 'surface-overlay': neutral[800].theme('surface-overlay'), + 'surface-transparent': 'transparent', + 'surface-sidebar': neutral[400].theme('surface-sidebar'), +}; + +type SurfaceColors = keyof typeof surfaceColors; + +export const strokeColors = { + 'stroke-extra-light': neutral[250].theme('stroke-extra-light'), + 'stroke-light': neutral[500].theme('stroke-light'), + 'stroke-medium': neutral[600].theme('stroke-medium'), + 'stroke-dark': neutral[700].theme('stroke-dark'), + 'stroke-extra-dark': neutral[800].theme('stroke-extra-dark'), + 'stroke-extra-light-highlight': blue[200].theme( + 'stroke-extra-light-highlight', + ), + 'stroke-highlight': blue[500].theme('stroke-highlight'), + 'stroke-extra-light-error': red[200].theme('stroke-extra-light-error'), + 'stroke-error': red[500].theme('stroke-error'), +}; + +type StrokeColor = keyof typeof strokeColors; + +export const textIconColors = { + 'font-white': white.theme('font-white'), + 'font-disabled': neutral[500].theme('font-disabled'), + 'font-annotation': neutral[600].theme('font-annotation'), + 'font-hint': neutral[700].theme('font-hint'), + 'font-secondary-info': neutral[700].theme('font-secondary-info'), + 'font-default': neutral[800].theme('font-default'), + 'font-titles-labels': neutral[900].theme('font-titles-labels'), + 'font-info': blue[600].theme('font-info'), + 'font-danger': red[600].theme('font-danger'), + 'font-pure-black': neutral[800].theme('font-pure-black'), + 'font-pure-white': white.theme('font-pure-white'), +}; + +type TextIconColors = keyof typeof textIconColors; + +export const statusBackgroundColors = { + 'status-background-info': blue[200].theme('status-background-info'), + 'status-background-success': green[200].theme('status-background-success'), + 'status-background-danger': red[200].theme('status-background-danger'), + 'status-background-warning': yellow[200].theme('status-background-warning'), + 'status-background-warning-2': yellow[100].theme( + 'status-background-warning-2', + ), + 'status-background-service-1': orange[200].theme( + 'status-background-service-1', + ), + 'status-background-service-2': purple[200].theme( + 'status-background-service-2', + ), +}; + +type StatusBackgroundColors = keyof typeof statusBackgroundColors; + +export const statusColors = { + 'status-font-on-info': blue[600].theme('status-font-on-info'), + 'status-font-on-success': green[800].theme('status-font-on-success'), + 'status-font-on-warning': yellow[800].theme('status-font-on-warning'), + 'status-font-on-warning-2': neutral[800].theme('status-font-on-warning-2'), + 'status-font-on-danger': red[800].theme('status-font-on-danger'), + 'status-font-on-service-1': orange[800].theme('status-font-on-service-1'), + 'status-font-on-service-2': purple[600].theme('status-font-on-service-2'), +}; + +type StatusColors = keyof typeof statusColors; + +export const badgeBackgroundColors = { + 'badge-background-level-0': neutral[400].theme('badge-background-level-0'), + 'badge-background-level-1': neutral[600].theme('badge-background-level-1'), + 'badge-background-level-2': blue[500].theme('badge-background-level-2'), + 'badge-background-level-3': orange[500].theme('badge-background-level-3'), + 'badge-background-level-4': red[500].theme('badge-background-level-4'), +}; + +type BadgeBackgroundColors = keyof typeof badgeBackgroundColors; + +export const shadowColors = { + 'shadow-elevation-border': strokeColors['stroke-extra-light'].theme( + 'shadow-elevation-border', + ), + 'shadow-elevation-1': new Var( + 'shadow-elevation-1', + getPaletteColor('neutral', 800, 0.1)[1], + ), + 'shadow-elevation-2x': new Var( + 'shadow-elevation-2x', + getPaletteColor('neutral', 800, 0.08)[1], + ), + 'shadow-elevation-2y': new Var( + 'shadow-elevation-2y', + getPaletteColor('neutral', 800, 0.12)[1], + ), + 'shadow-highlight': blue[200].theme('shadow-highlight'), + 'shadow-danger': red[100].theme('shadow-danger'), +}; + +type ShadowColors = keyof typeof shadowColors; + +export const isSurfaceColor = (color: unknown): color is SurfaceColors => + typeof color === 'string' && color in surfaceColors; + +export const isStrokeColor = (color: unknown): color is StrokeColor => + typeof color === 'string' && color in strokeColors; + +export const isTextIconColor = (color: unknown): color is TextIconColors => + typeof color === 'string' && color in textIconColors; + +export const isBadgeColor = (color: unknown): color is BadgeBackgroundColors => + typeof color === 'string' && color in badgeBackgroundColors; + +export const isStatusBackgroundColor = ( + color: unknown, +): color is StatusBackgroundColors => + typeof color === 'string' && color in statusBackgroundColors; + +export const isStatusColor = (color: unknown): color is StatusColors => + typeof color === 'string' && color in statusColors; + +export const isShadowColor = (color: unknown): color is ShadowColors => + typeof color === 'string' && color in shadowColors; + +export const Palette = { + surface: surfaceColors, + status: statusBackgroundColors, + statusColor: statusColors, + badge: badgeBackgroundColors, + text: textIconColors, + stroke: strokeColors, + shadow: shadowColors, +}; diff --git a/packages/fuselage-box/src/components/Box/Box.tsx b/packages/fuselage-box/src/components/Box/Box.tsx new file mode 100644 index 0000000000..2c42d267a9 --- /dev/null +++ b/packages/fuselage-box/src/components/Box/Box.tsx @@ -0,0 +1,68 @@ +import type { cssFn } from '@rocket.chat/css-in-js'; +import type { + AllHTMLAttributes, + ElementType, + RefAttributes, + SVGAttributes, +} from 'react'; +import { createElement, forwardRef, memo } from 'react'; + +import { useArrayLikeClassNameProp } from '../../hooks/useArrayLikeClassNameProp'; +import { useBoxOnlyProps } from '../../hooks/useBoxOnlyProps'; +import type { Falsy } from '../../types/Falsy'; + +import { useBoxTransform, BoxTransforms } from './BoxTransforms'; +import type { StylingProps } from './stylingProps'; +import { useStylingProps } from './useStylingProps'; + +export interface BoxProps + extends Partial, + Omit< + AllHTMLAttributes, + 'ref' | 'is' | 'className' | 'size' | 'elevation' | keyof StylingProps + >, + Omit< + SVGAttributes, + keyof AllHTMLAttributes | 'elevation' | keyof StylingProps + > { + /** + * The `is` prop is used to render the Box as a different HTML tag. It can also be used to render a different fuselage component. + */ + is?: ElementType; + className?: string | cssFn | (string | cssFn | Falsy)[]; + animated?: boolean; + withRichContent?: boolean | 'inlineWithoutBreaks'; + htmlSize?: AllHTMLAttributes['size']; + focusable?: boolean; +} + +export const Box = forwardRef(function Box( + { is = 'div', children, ...props }, + ref, +) { + const propsWithRef: BoxProps & RefAttributes = props; + + if (ref) { + propsWithRef.ref = ref; + } + + let propsWithStringClassName = useArrayLikeClassNameProp(propsWithRef); + + const transformFn = useBoxTransform(); + if (transformFn) { + propsWithStringClassName = transformFn(propsWithStringClassName); + } + + const propsWithoutStylingProps = useStylingProps(propsWithStringClassName); + const propsWithoutBoxOnlyProps = useBoxOnlyProps(propsWithoutStylingProps); + + const element = createElement(is, propsWithoutBoxOnlyProps, children); + + if (transformFn) { + return ; + } + + return element; +}); + +export default memo(Box); diff --git a/packages/fuselage-box/src/components/Box/BoxTransforms.ts b/packages/fuselage-box/src/components/Box/BoxTransforms.ts new file mode 100644 index 0000000000..2d6d2c782d --- /dev/null +++ b/packages/fuselage-box/src/components/Box/BoxTransforms.ts @@ -0,0 +1,21 @@ +import { createContext, useContext, useMemo } from 'react'; + +export const BoxTransforms = createContext any)>(null); + +export const useBoxTransform = () => useContext(BoxTransforms); + +export const useComposedBoxTransform = (fn: (props: any) => any) => { + const parentFn = useContext(BoxTransforms); + + return useMemo(() => { + if (!parentFn) { + return fn; + } + + if (!fn) { + return parentFn; + } + + return (props: any) => fn(parentFn(props)); + }, [fn, parentFn]); +}; diff --git a/packages/fuselage-box/src/components/Box/StylingBox.tsx b/packages/fuselage-box/src/components/Box/StylingBox.tsx new file mode 100644 index 0000000000..a11eacd46e --- /dev/null +++ b/packages/fuselage-box/src/components/Box/StylingBox.tsx @@ -0,0 +1,35 @@ +import type { cssFn } from '@rocket.chat/css-in-js'; +import type { ReactElement } from 'react'; +import { cloneElement } from 'react'; + +import { useArrayLikeClassNameProp } from '../../hooks/useArrayLikeClassNameProp'; +import type { Falsy } from '../../types/Falsy'; + +import type { StylingProps } from './stylingProps'; +import { useStylingProps } from './useStylingProps'; + +export type StylingBoxProps = { + children: ReactElement<{ className?: string }>; + className?: string | cssFn | (string | cssFn | Falsy)[]; +} & Partial; + +/** + * Merges its `StylingProps` props into a `className` prop passed to a child element. + * + * This is intended to be used as a wrapper for components that accept styling props but don't need the weight of the + * `Box` component prop handling internally. + */ +export const StylingBox = ({ children, ...props }: StylingBoxProps) => { + const propsWithStringClassName = useArrayLikeClassNameProp(props); + propsWithStringClassName.className = [ + children.props.className, + propsWithStringClassName.className, + ] + .filter(Boolean) + .join(' '); + const propsWithoutStylingProps = useStylingProps(propsWithStringClassName); + + return cloneElement(children, propsWithoutStylingProps); +}; + +export default StylingBox; diff --git a/packages/fuselage-box/src/components/Box/index.ts b/packages/fuselage-box/src/components/Box/index.ts new file mode 100644 index 0000000000..a6dbc80676 --- /dev/null +++ b/packages/fuselage-box/src/components/Box/index.ts @@ -0,0 +1,8 @@ +export { default as Box, type BoxProps } from './Box'; +export { default as StylingBox, type StylingBoxProps } from './StylingBox'; +export { withBoxStyling } from './withBoxStyling'; +export { + BoxTransforms, + useBoxTransform, + useComposedBoxTransform, +} from './BoxTransforms'; diff --git a/packages/fuselage-box/src/components/Box/stylingProps.ts b/packages/fuselage-box/src/components/Box/stylingProps.ts new file mode 100644 index 0000000000..1d5f7173be --- /dev/null +++ b/packages/fuselage-box/src/components/Box/stylingProps.ts @@ -0,0 +1,566 @@ +import type { cssFn } from '@rocket.chat/css-in-js'; +import { css } from '@rocket.chat/css-in-js'; +import type { CSSProperties } from 'react'; + +import type { Var } from '../../Theme'; +import { Palette } from '../../Theme'; +import { fromCamelToKebab } from '../../helpers/fromCamelToKebab'; +import { + borderRadius, + borderWidth, + backgroundColor, + fontColor, + fontFamily, + fontScale, + spacing, + size, + strokeColor, +} from '../../styleTokens'; + +type FontScale = + | 'hero' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'p1' + | 'p1m' + | 'p1b' + | 'p2' + | 'p2m' + | 'p2b' + | 'c1' + | 'c2' + | 'micro'; + +export type StylingProps = { + border: CSSProperties['border']; + borderBlock: CSSProperties['borderBlock']; + borderBlockStart: CSSProperties['borderBlockStart']; + borderBlockEnd: CSSProperties['borderBlockEnd']; + borderInline: CSSProperties['borderInline']; + borderInlineStart: CSSProperties['borderInlineStart']; + borderInlineEnd: CSSProperties['borderInlineEnd']; + borderWidth: CSSProperties['borderWidth']; + borderBlockWidth: CSSProperties['borderBlockWidth']; + borderBlockStartWidth: CSSProperties['borderBlockStartWidth']; + borderBlockEndWidth: CSSProperties['borderBlockEndWidth']; + borderInlineWidth: CSSProperties['borderInlineWidth']; + borderInlineStartWidth: CSSProperties['borderInlineStartWidth']; + borderInlineEndWidth: CSSProperties['borderInlineEndWidth']; + borderStyle: CSSProperties['borderStyle']; + borderBlockStyle: CSSProperties['borderBlockStyle']; + borderBlockStartStyle: CSSProperties['borderBlockStartStyle']; + borderBlockEndStyle: CSSProperties['borderBlockEndStyle']; + borderInlineStyle: CSSProperties['borderInlineStyle']; + borderInlineStartStyle: CSSProperties['borderInlineStartStyle']; + borderInlineEndStyle: CSSProperties['borderInlineEndStyle']; + borderColor: CSSProperties['borderColor']; + borderBlockColor: CSSProperties['borderBlockColor']; + borderBlockStartColor: CSSProperties['borderBlockStartColor']; + borderBlockEndColor: CSSProperties['borderBlockEndColor']; + borderInlineColor: CSSProperties['borderInlineColor']; + borderInlineStartColor: CSSProperties['borderInlineStartColor']; + borderInlineEndColor: CSSProperties['borderInlineEndColor']; + borderRadius: CSSProperties['borderRadius']; + borderStartStartRadius: CSSProperties['borderStartStartRadius']; + borderStartEndRadius: CSSProperties['borderStartEndRadius']; + borderEndStartRadius: CSSProperties['borderEndStartRadius']; + borderEndEndRadius: CSSProperties['borderEndEndRadius']; + + color: CSSProperties['color'] | Var; + backgroundColor: CSSProperties['backgroundColor'] | Var; + bg: CSSProperties['backgroundColor'] | Var; + opacity: CSSProperties['opacity']; + + alignItems: CSSProperties['alignItems']; + alignContent: CSSProperties['alignContent']; + justifyItems: CSSProperties['justifyItems']; + justifyContent: CSSProperties['justifyContent']; + flexWrap: CSSProperties['flexWrap']; + flexDirection: CSSProperties['flexDirection']; + flexGrow: CSSProperties['flexGrow']; + flexShrink: CSSProperties['flexShrink']; + flexBasis: CSSProperties['flexBasis']; + justifySelf: CSSProperties['justifySelf']; + alignSelf: CSSProperties['alignSelf']; + order: CSSProperties['order']; + gap: CSSProperties['gap']; + rowGap: CSSProperties['rowGap']; + columnGap: CSSProperties['columnGap']; + + w: CSSProperties['width']; + width: CSSProperties['width']; + minWidth: CSSProperties['minWidth']; + maxWidth: CSSProperties['maxWidth']; + h: CSSProperties['height']; + height: CSSProperties['height']; + minHeight: CSSProperties['minHeight']; + maxHeight: CSSProperties['maxHeight']; + display: CSSProperties['display']; + verticalAlign: CSSProperties['verticalAlign']; + overflow: CSSProperties['overflow']; + overflowX: CSSProperties['overflowX']; + overflowY: CSSProperties['overflowY']; + + position: CSSProperties['position']; + zIndex: CSSProperties['zIndex']; + inset: CSSProperties['inset']; + insetBlock: CSSProperties['insetBlock']; + insetBlockStart: CSSProperties['insetBlockStart']; + insetBlockEnd: CSSProperties['insetBlockEnd']; + insetInline: CSSProperties['insetInline']; + insetInlineStart: CSSProperties['insetInlineStart']; + insetInlineEnd: CSSProperties['insetInlineEnd']; + + m: CSSProperties['margin']; + margin: CSSProperties['margin']; + mb: CSSProperties['marginBlock']; + marginBlock: CSSProperties['marginBlock']; + mbs: CSSProperties['marginBlockStart']; + marginBlockStart: CSSProperties['marginBlockStart']; + mbe: CSSProperties['marginBlockEnd']; + marginBlockEnd: CSSProperties['marginBlockEnd']; + mi: CSSProperties['marginInline']; + marginInline: CSSProperties['marginInline']; + mis: CSSProperties['marginInlineStart']; + marginInlineStart: CSSProperties['marginInlineStart']; + mie: CSSProperties['marginInlineEnd']; + marginInlineEnd: CSSProperties['marginInlineEnd']; + p: CSSProperties['padding']; + padding: CSSProperties['padding']; + pb: CSSProperties['paddingBlock']; + paddingBlock: CSSProperties['paddingBlock']; + pbs: CSSProperties['paddingBlockStart']; + paddingBlockStart: CSSProperties['paddingBlockStart']; + pbe: CSSProperties['paddingBlockEnd']; + paddingBlockEnd: CSSProperties['paddingBlockEnd']; + pi: CSSProperties['paddingInline']; + paddingInline: CSSProperties['paddingInline']; + pis: CSSProperties['paddingInlineStart']; + paddingInlineStart: CSSProperties['paddingInlineStart']; + pie: CSSProperties['paddingInlineEnd']; + paddingInlineEnd: CSSProperties['paddingInlineEnd']; + + fontFamily: CSSProperties['fontFamily'] | FontScale; + fontSize: CSSProperties['fontSize'] | FontScale; + fontStyle: CSSProperties['fontStyle']; + fontWeight: CSSProperties['fontWeight'] | FontScale; + letterSpacing: CSSProperties['letterSpacing'] | FontScale; + lineHeight: CSSProperties['lineHeight'] | FontScale; + textAlign: CSSProperties['textAlign']; + textTransform: CSSProperties['textTransform']; + textDecorationLine: CSSProperties['textDecorationLine']; + wordBreak: CSSProperties['wordBreak']; + + elevation: '0' | '1' | '2' | '1nb' | '2nb'; + invisible: boolean; + withTruncatedText: boolean; + size: CSSProperties['blockSize']; + minSize: CSSProperties['blockSize']; + maxSize: CSSProperties['blockSize']; + fontScale: FontScale; +}; + +type PropDefinition = + | { + toCSSValue: (value: unknown) => string | undefined; + } + | { aliasOf: keyof StylingProps } + | { + toStyle: (value: unknown) => cssFn | undefined; + }; + +const stringProp: PropDefinition = { + toCSSValue: (value) => (typeof value === 'string' ? value : undefined), +}; + +const numberOrStringProp: PropDefinition = { + toCSSValue: (value) => { + if (typeof value === 'number' || typeof value === 'string') { + return String(value); + } + + return undefined; + }, +}; + +const borderWidthProp: PropDefinition = { + toCSSValue: borderWidth, +}; + +const borderRadiusProp: PropDefinition = { + toCSSValue: borderRadius, +}; + +const backgroundColorProp: PropDefinition = { + toCSSValue: backgroundColor, +}; + +const fontColorProp: PropDefinition = { + toCSSValue: fontColor, +}; + +const strokeColorProp: PropDefinition = { + toCSSValue: strokeColor, +}; + +const sizeProp: PropDefinition = { + toCSSValue: size, +}; + +const insetProp: PropDefinition = { + toCSSValue: spacing, +}; + +const marginProp: PropDefinition = { + toCSSValue: spacing, +}; + +const paddingProp: PropDefinition = { + toCSSValue: spacing, +}; + +const gapProp: PropDefinition = { + toCSSValue: spacing, +}; + +const fontFamilyProp: PropDefinition = { + toCSSValue: fontFamily, +}; + +const fontSizeProp: PropDefinition = { + toCSSValue: (value) => fontScale(value)?.fontSize || size(value), +}; + +const fontWeightProp: PropDefinition = { + toCSSValue: (value) => + value ? String(fontScale(value)?.fontWeight || value) : undefined, +}; + +const lineHeightProp: PropDefinition = { + toCSSValue: (value) => fontScale(value)?.lineHeight || size(value), +}; + +const letterSpacingProp: PropDefinition = { + toCSSValue: (value) => + value ? String(fontScale(value)?.letterSpacing || value) : undefined, +}; + +const aliasOf = (propName: keyof StylingProps): PropDefinition => ({ + aliasOf: propName, +}); + +export const propDefs: Record = { + border: stringProp, + borderBlock: stringProp, + borderBlockStart: stringProp, + borderBlockEnd: stringProp, + borderInline: stringProp, + borderInlineStart: stringProp, + borderInlineEnd: stringProp, + borderWidth: borderWidthProp, + borderBlockWidth: borderWidthProp, + borderBlockStartWidth: borderWidthProp, + borderBlockEndWidth: borderWidthProp, + borderInlineWidth: borderWidthProp, + borderInlineStartWidth: borderWidthProp, + borderInlineEndWidth: borderWidthProp, + borderStyle: stringProp, + borderBlockStyle: stringProp, + borderBlockStartStyle: stringProp, + borderBlockEndStyle: stringProp, + borderInlineStyle: stringProp, + borderInlineStartStyle: stringProp, + borderInlineEndStyle: stringProp, + borderColor: strokeColorProp, + borderBlockColor: strokeColorProp, + borderBlockStartColor: strokeColorProp, + borderBlockEndColor: strokeColorProp, + borderInlineColor: strokeColorProp, + borderInlineStartColor: strokeColorProp, + borderInlineEndColor: strokeColorProp, + borderRadius: borderRadiusProp, + borderStartStartRadius: borderRadiusProp, + borderStartEndRadius: borderRadiusProp, + borderEndStartRadius: borderRadiusProp, + borderEndEndRadius: borderRadiusProp, + + color: fontColorProp, + backgroundColor: backgroundColorProp, + bg: aliasOf('backgroundColor'), + opacity: numberOrStringProp, + + alignItems: stringProp, + alignContent: stringProp, + justifyItems: stringProp, + justifyContent: stringProp, + flexWrap: stringProp, + flexDirection: stringProp, + flexGrow: numberOrStringProp, + flexShrink: numberOrStringProp, + flexBasis: stringProp, + justifySelf: stringProp, + alignSelf: stringProp, + order: numberOrStringProp, + gap: gapProp, + rowGap: gapProp, + columnGap: gapProp, + + w: aliasOf('width'), + width: sizeProp, + minWidth: sizeProp, + maxWidth: sizeProp, + h: aliasOf('height'), + height: sizeProp, + minHeight: sizeProp, + maxHeight: sizeProp, + display: stringProp, + verticalAlign: stringProp, + overflow: stringProp, + overflowX: stringProp, + overflowY: stringProp, + + position: stringProp, + zIndex: numberOrStringProp, + inset: insetProp, + insetBlock: insetProp, + insetBlockStart: insetProp, + insetBlockEnd: insetProp, + insetInline: insetProp, + insetInlineStart: insetProp, + insetInlineEnd: insetProp, + + m: aliasOf('margin'), + margin: marginProp, + mb: aliasOf('marginBlock'), + marginBlock: marginProp, + mbs: aliasOf('marginBlockStart'), + marginBlockStart: marginProp, + mbe: aliasOf('marginBlockEnd'), + marginBlockEnd: marginProp, + mi: aliasOf('marginInline'), + marginInline: marginProp, + mis: aliasOf('marginInlineStart'), + marginInlineStart: marginProp, + mie: aliasOf('marginInlineEnd'), + marginInlineEnd: marginProp, + p: aliasOf('padding'), + padding: paddingProp, + pb: aliasOf('paddingBlock'), + paddingBlock: paddingProp, + pbs: aliasOf('paddingBlockStart'), + paddingBlockStart: paddingProp, + pbe: aliasOf('paddingBlockEnd'), + paddingBlockEnd: paddingProp, + pi: aliasOf('paddingInline'), + paddingInline: paddingProp, + pis: aliasOf('paddingInlineStart'), + paddingInlineStart: paddingProp, + pie: aliasOf('paddingInlineEnd'), + paddingInlineEnd: paddingProp, + + fontFamily: fontFamilyProp, + fontSize: fontSizeProp, + fontStyle: stringProp, + fontWeight: fontWeightProp, + letterSpacing: letterSpacingProp, + lineHeight: lineHeightProp, + textAlign: stringProp, + textTransform: stringProp, + textDecorationLine: stringProp, + wordBreak: stringProp, + + elevation: { + toStyle: (value) => { + if (value === '0') { + return css` + box-shadow: none; + `; + } + + if (value === '1') { + return css` + box-shadow: 0px 0px 12px 0px ${Palette.shadow['shadow-elevation-1']}; + border: 1px solid ${Palette.shadow['shadow-elevation-border']}; + `; + } + + if (value === '1nb') { + return css` + box-shadow: 0px 0px 12px 0px ${Palette.shadow['shadow-elevation-1']}; + `; + } + + if (value === '2') { + return css` + box-shadow: + 0px 0px 2px 0px ${Palette.shadow['shadow-elevation-2x']}, + 0px 0px 12px 0px ${Palette.shadow['shadow-elevation-2y']}; + border: 1px solid ${Palette.shadow['shadow-elevation-border']}; + `; + } + + if (value === '2nb') { + return css` + box-shadow: + 0px 0px 2px 0px ${Palette.shadow['shadow-elevation-2x']}, + 0px 0px 12px 0px ${Palette.shadow['shadow-elevation-2y']}; + `; + } + + return undefined; + }, + }, + invisible: { + toStyle: (value) => + value + ? css` + visibility: hidden; + opacity: 0; + ` + : undefined, + }, + withTruncatedText: { + toStyle: (value) => + value + ? css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + ` + : undefined, + }, + size: { + toStyle: (value) => + size(value) + ? css` + width: ${size(value)} !important; + height: ${size(value)} !important; + ` + : undefined, + }, + minSize: { + toStyle: (value) => + size(value) + ? css` + min-width: ${size(value)} !important; + min-height: ${size(value)} !important; + ` + : undefined, + }, + maxSize: { + toStyle: (value) => + size(value) + ? css` + max-width: ${size(value)} !important; + max-height: ${size(value)} !important; + ` + : undefined, + }, + fontScale: { + toStyle: (value) => css` + font-size: ${fontScale(value)?.fontSize} !important; + font-weight: ${fontScale(value)?.fontWeight} !important; + letter-spacing: ${fontScale(value)?.letterSpacing} !important; + line-height: ${fontScale(value)?.lineHeight} !important; + `, + }, +}; + +const compiledPropDefs = new Map( + (Object.entries(propDefs) as [keyof StylingProps, PropDefinition][]).map( + ([propName, propDef]): [ + propName: string, + inject: ( + value: unknown, + stylingProps: Map, + ) => void, + ] => { + if ('aliasOf' in propDef) { + const { aliasOf: effectivePropName } = propDef; + + return [ + propName, + (value, stylingProps) => { + if (stylingProps.has(effectivePropName)) { + return; + } + + const inject = compiledPropDefs.get(effectivePropName); + + inject?.(value, stylingProps); + }, + ]; + } + + if ('toCSSValue' in propDef) { + const cssProperty = fromCamelToKebab(propName); + const { toCSSValue } = propDef; + return [ + propName, + (value, stylingProps) => { + const cssValue = toCSSValue(value); + + if (cssValue === undefined) { + return; + } + + stylingProps.set( + propName, + css` + ${cssProperty}: ${cssValue} !important; + `, + ); + }, + ]; + } + + const { toStyle } = propDef; + + return [ + propName, + (value, stylingProps) => { + const style = toStyle(value); + + if (style === undefined) { + return; + } + + stylingProps.set(propName, style); + }, + ]; + }, + ), +); + +export const extractStylingProps = >( + props: TProps & Partial, +): [props: TProps, styles: cssFn | undefined] => { + const stylingProps = new Map(); + const newProps: Record = {}; + + for (const [propName, value] of Object.entries(props)) { + const inject = compiledPropDefs.get(propName); + + if (!inject) { + newProps[propName] = value; + continue; + } + + if (value === undefined) { + continue; + } + + inject(value, stylingProps); + } + + const styles = stylingProps.size + ? css` + ${Array.from(stylingProps.values())} + ` + : undefined; + + return [newProps as TProps, styles]; +}; diff --git a/packages/fuselage-box/src/components/Box/useStylingProps.ts b/packages/fuselage-box/src/components/Box/useStylingProps.ts new file mode 100644 index 0000000000..e93aa33820 --- /dev/null +++ b/packages/fuselage-box/src/components/Box/useStylingProps.ts @@ -0,0 +1,21 @@ +import { appendClassName } from '../../helpers/appendClassName'; +import { useStyle } from '../../hooks/useStyle'; + +import type { StylingProps } from './stylingProps'; +import { extractStylingProps } from './stylingProps'; + +export const useStylingProps = ( + originalProps: TProps, +): Omit => { + const [props, styles] = extractStylingProps(originalProps); + + const newClassName = useStyle(styles, undefined); + + if (newClassName) { + props.className = props.className + ? appendClassName(props.className, newClassName) + : newClassName; + } + + return props; +}; diff --git a/packages/fuselage-box/src/components/Box/withBoxStyling.tsx b/packages/fuselage-box/src/components/Box/withBoxStyling.tsx new file mode 100644 index 0000000000..e252a5b17e --- /dev/null +++ b/packages/fuselage-box/src/components/Box/withBoxStyling.tsx @@ -0,0 +1,25 @@ +import type { ComponentPropsWithoutRef, ComponentType } from 'react'; + +import type { StylingProps } from './stylingProps'; +import { useStylingProps } from './useStylingProps'; + +export const withBoxStyling = < + TComponent extends ComponentType<{ + className?: string; + }>, +>( + Component: TComponent, +) => { + const WithBoxStyling = ( + props: ComponentPropsWithoutRef & Partial, + ) => { + const propsWithoutStylingProps = useStylingProps(props); + return ; + }; + + WithBoxStyling.displayName = `WithBoxStyling(${ + Component.displayName || Component.name || 'Component' + })`; + + return WithBoxStyling; +}; diff --git a/packages/fuselage-box/src/getPaletteColor.ts b/packages/fuselage-box/src/getPaletteColor.ts new file mode 100644 index 0000000000..3c9670019b --- /dev/null +++ b/packages/fuselage-box/src/getPaletteColor.ts @@ -0,0 +1,44 @@ +import tokenColors from '@rocket.chat/fuselage-tokens/colors.json'; +import invariant from 'invariant'; + +const isPaletteColorRef = (ref: unknown): ref is keyof typeof tokenColors => + typeof ref === 'string' && ref in tokenColors; + +const mapTypeToPrefix = { + neutral: 'n', + blue: 'b', + green: 'g', + yellow: 'y', + red: 'r', + orange: 'o', + purple: 'p', +} as const; + +export const getPaletteColor = ( + type: keyof typeof mapTypeToPrefix, + grade: 100 | 200 | 250 | 300 | 400 | 450 | 500 | 600 | 700 | 800 | 900 | 1000, + alpha?: number, +): [customPropertyName: string, value: string] => { + const ref = `${mapTypeToPrefix[type]}${grade}`; + invariant(isPaletteColorRef(ref), 'invalid color reference'); + + const baseColor = tokenColors[ref]; + + const matches = /^#([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})$/.exec( + baseColor, + ); + + invariant(!!matches, 'invalid color token format'); + + if (alpha !== undefined) { + const [, r, g, b] = matches; + return [ + `--rcx-color-${type}-${grade}-${(alpha * 100).toFixed(0)}`, + `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${ + alpha * 100 + }%)`, + ]; + } + + return [`--rcx-color-${type}-${grade}`, baseColor]; +}; diff --git a/packages/fuselage-box/src/helpers/appendClassName.ts b/packages/fuselage-box/src/helpers/appendClassName.ts new file mode 100644 index 0000000000..25335db689 --- /dev/null +++ b/packages/fuselage-box/src/helpers/appendClassName.ts @@ -0,0 +1,16 @@ +type R = T extends void ? string : T extends string ? string : string[]; + +export const appendClassName = ( + currentClassName: T, + newClassName: string, +): R => { + if (currentClassName === undefined || currentClassName === '') { + return newClassName as R; + } + + if (Array.isArray(currentClassName)) { + return [...currentClassName, newClassName] as R; + } + + return `${currentClassName} ${newClassName}` as R; +}; diff --git a/packages/fuselage-box/src/helpers/fromCamelToKebab.ts b/packages/fuselage-box/src/helpers/fromCamelToKebab.ts new file mode 100644 index 0000000000..2a43947ab5 --- /dev/null +++ b/packages/fuselage-box/src/helpers/fromCamelToKebab.ts @@ -0,0 +1,2 @@ +export const fromCamelToKebab = (string: string) => + string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); diff --git a/packages/fuselage-box/src/helpers/prependClassName.ts b/packages/fuselage-box/src/helpers/prependClassName.ts new file mode 100644 index 0000000000..2e59593466 --- /dev/null +++ b/packages/fuselage-box/src/helpers/prependClassName.ts @@ -0,0 +1,21 @@ +const isStringArray = ( + value: string | string[] | undefined, +): value is string[] => Array.isArray(value); + +export const prependClassName: { + (currentClassName: string[] | undefined, newClassName: string): string[]; + (currentClassName: string | undefined, newClassName: string): string; +} = ( + currentClassName: string | string[] | undefined, + newClassName: string, +): any => { + if (isStringArray(currentClassName)) { + return [newClassName, ...currentClassName]; + } + + if (currentClassName) { + return `${newClassName} ${currentClassName}`; + } + + return newClassName; +}; diff --git a/packages/fuselage-box/src/helpers/toCSSValue.ts b/packages/fuselage-box/src/helpers/toCSSValue.ts new file mode 100644 index 0000000000..3599cbc25c --- /dev/null +++ b/packages/fuselage-box/src/helpers/toCSSValue.ts @@ -0,0 +1,11 @@ +type cssToValueType = string }>( + label: string, + value: T, +) => string; +export const toCSSValue: cssToValueType = (label, value) => + `var(${label}, ${value})`; + +export const toCSSFontValue = ((label: string, value: string) => + toCSSValue(`--rcx-font-family-${label}`, value)) as cssToValueType; +export const toCSSColorValue = ((label: string, value: string) => + toCSSValue(`--rcx-color-${label}`, value)) as cssToValueType; diff --git a/packages/fuselage-box/src/hooks/useArrayLikeClassNameProp.ts b/packages/fuselage-box/src/hooks/useArrayLikeClassNameProp.ts new file mode 100644 index 0000000000..b75185c788 --- /dev/null +++ b/packages/fuselage-box/src/hooks/useArrayLikeClassNameProp.ts @@ -0,0 +1,40 @@ +import type { cssFn } from '@rocket.chat/css-in-js'; +import { css } from '@rocket.chat/css-in-js'; + +import { appendClassName } from '../helpers/appendClassName'; +import type { Falsy } from '../types/Falsy'; + +import { useStyle } from './useStyle'; + +export const useArrayLikeClassNameProp = < + T extends { + className?: string | cssFn | (string | cssFn | Falsy)[]; + }, +>( + props: T, +): T & { className: string } => { + const classNames = props.className + ? ([] as (string | cssFn | Falsy)[]).concat(props.className) + : []; + + const cssFns = classNames.filter( + (value): value is cssFn => typeof value === 'function', + ); + const stylesClassName = useStyle( + css` + ${cssFns} + `, + props, + ); + + const strings = classNames.filter( + (value): value is string => typeof value === 'string', + ); + + const className = strings.reduce( + (className, string) => appendClassName(className, string), + stylesClassName || '', + ); + + return Object.assign(props, { className }); +}; diff --git a/packages/fuselage-box/src/hooks/useBoxOnlyProps.ts b/packages/fuselage-box/src/hooks/useBoxOnlyProps.ts new file mode 100644 index 0000000000..ef0ddf5d79 --- /dev/null +++ b/packages/fuselage-box/src/hooks/useBoxOnlyProps.ts @@ -0,0 +1,72 @@ +import type { AllHTMLAttributes } from 'react'; + +import { prependClassName } from '../helpers/prependClassName'; + +export const useBoxOnlyProps = < + T extends { + className: string; + }, +>( + props: T & { + animated?: boolean; + withRichContent?: boolean | 'inlineWithoutBreaks'; + htmlSize?: AllHTMLAttributes['size']; + size?: AllHTMLAttributes['size']; + focusable?: boolean; + }, +): T => { + Object.entries(props).forEach(([key, value]) => { + if (key.slice(0, 4) === 'rcx-') { + try { + if (!value) { + return; + } + + const newClassName = value === true ? key : `${key}-${value}`; + props.className = prependClassName(props.className, newClassName); + } finally { + delete (props as Record)[key]; + } + } + }); + + if (props.animated) { + props.className = prependClassName(props.className, 'rcx-box--animated'); + delete props.animated; + } + + if (props.withRichContent) { + if (props.withRichContent === 'inlineWithoutBreaks') { + props.className = prependClassName( + props.className, + 'rcx-box--with-inline-elements', + ); + } else { + props.className = prependClassName( + props.className, + 'rcx-box--with-inline-elements', + ); + + props.className = prependClassName( + props.className, + 'rcx-box--with-block-elements', + ); + } + } + + if (props.htmlSize) { + props.size = props.htmlSize; + delete props.htmlSize; + } + + if (props.focusable) { + props.className = prependClassName(props.className, 'rcx-box--focusable'); + delete props.focusable; + } + + delete props.withRichContent; + + props.className = prependClassName(props.className, 'rcx-box rcx-box--full'); + + return props; +}; diff --git a/packages/fuselage-box/src/hooks/useStyle.ts b/packages/fuselage-box/src/hooks/useStyle.ts new file mode 100644 index 0000000000..03285d18eb --- /dev/null +++ b/packages/fuselage-box/src/hooks/useStyle.ts @@ -0,0 +1,38 @@ +import type { cssFn } from '@rocket.chat/css-in-js'; +import { + createClassName, + escapeName, + transpile, + attachRules, +} from '@rocket.chat/css-in-js'; +import { useDebugValue, useInsertionEffect, useMemo } from 'react'; + +export const useStyle = (cssFn: cssFn | undefined, arg: unknown) => { + const content = useMemo(() => (cssFn ? cssFn(arg) : undefined), [arg, cssFn]); + + const className = useMemo(() => { + if (!content) { + return; + } + + return content ? createClassName(content) : undefined; + }, [content]); + + useDebugValue(className); + + useInsertionEffect(() => { + if (!content || !className) { + return; + } + + const escapedClassName = escapeName(className); + const transpiledContent = transpile(`.${escapedClassName}`, content); + const detach = attachRules(transpiledContent); + + return () => { + setTimeout(detach, 1000); + }; + }, [className, content]); + + return className; +}; diff --git a/packages/fuselage-box/src/index.ts b/packages/fuselage-box/src/index.ts new file mode 100644 index 0000000000..f700407ea5 --- /dev/null +++ b/packages/fuselage-box/src/index.ts @@ -0,0 +1,32 @@ +export { + Box, + StylingBox, + withBoxStyling, + BoxTransforms, + useBoxTransform, + useComposedBoxTransform, +} from './components/Box'; +export type { BoxProps, StylingBoxProps } from './components/Box'; +export * from './styleTokens'; +export { + Var, + Palette, + __setThrowErrorOnInvalidToken__, + throwErrorOnInvalidToken, + neutral, + surfaceColors, + strokeColors, + textIconColors, + statusBackgroundColors, + statusColors, + badgeBackgroundColors, + shadowColors, + isSurfaceColor, + isStrokeColor, + isTextIconColor, + isBadgeColor, + isStatusBackgroundColor, + isStatusColor, + isShadowColor, +} from './Theme'; +export { useArrayLikeClassNameProp } from './hooks/useArrayLikeClassNameProp'; diff --git a/packages/fuselage-box/src/styleTokens.ts b/packages/fuselage-box/src/styleTokens.ts new file mode 100644 index 0000000000..fec9858f4e --- /dev/null +++ b/packages/fuselage-box/src/styleTokens.ts @@ -0,0 +1,332 @@ +import tokenTypography from '@rocket.chat/fuselage-tokens/typography.json'; +import { memoize } from '@rocket.chat/memo'; +import invariant from 'invariant'; + +import { + isStatusBackgroundColor, + isStatusColor, + isStrokeColor, + isSurfaceColor, + isTextIconColor, + neutral, + statusBackgroundColors, + strokeColors, + surfaceColors, + textIconColors, + statusColors, + throwErrorOnInvalidToken, + isBadgeColor, + badgeBackgroundColors, +} from './Theme'; +import { getPaletteColor } from './getPaletteColor'; +import { + toCSSColorValue, + toCSSFontValue, + toCSSValue, +} from './helpers/toCSSValue'; + +const measure = ( + computeSpecialValue?: (value: string) => null | undefined | string, +) => + memoize((value) => { + if (typeof value === 'number') { + return `${value}px`; + } + + if (typeof value !== 'string') { + return undefined; + } + + const xRegExp = /^(neg-|-)?x(\d+)$/; + const matches = xRegExp.exec(value); + if (matches) { + const [, negativeMark, measureInPixelsAsString] = matches; + const measureInPixels = + (negativeMark ? -1 : 1) * parseInt(measureInPixelsAsString, 10); + return `${measureInPixels / 16}rem`; + } + + if (computeSpecialValue) { + return computeSpecialValue(value) || value; + } + + return value; + }); + +export const borderWidth = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + if (value === 'default') { + return borderWidth('x1'); + } + + return undefined; +}); + +export const borderRadius = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + + if (value === 'full') { + return '9999px'; + } + + return undefined; +}); + +const mapTypeToPrefix = { + neutral: 'n', + blue: 'b', + green: 'g', + yellow: 'y', + red: 'r', + orange: 'o', + purple: 'p', +} as const; + +const isPaletteColorType = ( + type: unknown, +): type is keyof typeof mapTypeToPrefix => + typeof type === 'string' && type in mapTypeToPrefix; + +const isPaletteColorGrade = ( + grade: unknown, +): grade is 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 => + typeof grade === 'number' && + grade % 100 === 0 && + grade / 100 >= 1 && + grade / 100 <= 9; + +const isPaletteColorAlpha = (alpha: unknown): alpha is number | undefined => + alpha === undefined || + (typeof alpha === 'number' && alpha >= 0 && alpha <= 1); + +const paletteColorRegex = + /^(neutral|blue|green|yellow|red|orange|purple)-(\d+)(-(\d+))?$/; + +export const strokeColor = memoize((value) => { + const colorName = `stroke-${value}`; + if (isStrokeColor(colorName)) { + return strokeColors[colorName].toString(); + } + return color(value); +}); + +export const backgroundColor = memoize((value) => { + const colorName = `surface-${value}`; + + if (isSurfaceColor(value)) { + return surfaceColors[value].toString(); + } + + if (isSurfaceColor(colorName)) { + return surfaceColors[colorName].toString(); + } + + if (isStatusBackgroundColor(value)) { + return statusBackgroundColors[value].toString(); + } + + if (isStatusColor(value)) { + if ( + process.env['NODE_ENV'] !== 'production' && + process.env['NODE_ENV'] !== 'test' + ) { + console.warn(`${value} shouldn't be used as a backgroundColor.`); + } + return statusColors[value].toString(); + } + + if (isBadgeColor(value)) { + return badgeBackgroundColors[value].toString(); + } + + return color(value); +}); + +export const fontColor = memoize((value) => { + const colorName = `font-${value}`; + if (isTextIconColor(colorName)) { + return textIconColors[colorName].toString(); + } + if (isStatusColor(value)) { + return statusColors[value].toString(); + } + return color(value); +}); + +/** @deprecated **/ +export const color = memoize((value) => { + if (typeof value !== 'string') { + return; + } + if ( + process.env['NODE_ENV'] !== 'production' && + process.env['NODE_ENV'] !== 'test' + ) { + console.warn(`invalid color: ${value}`, new Error().stack); + } + if (throwErrorOnInvalidToken) { + throw new Error( + `The color token "${value}" is deprecated. Please use the new color tokens instead.`, + ); + } + + if (isSurfaceColor(value)) { + return surfaceColors[value].toString(); + } + + if (isStatusBackgroundColor(value)) { + return statusBackgroundColors[value].toString(); + } + + if (isStrokeColor(value)) { + return strokeColors[value].toString(); + } + if (isTextIconColor(value)) { + return textIconColors[value].toString(); + } + + if (value === 'surface' || value === 'surface-light') { + return surfaceColors['surface-light'].toString(); + } + + if (value === 'surface-tint') { + return toCSSColorValue(value, neutral[100]); + } + + if (value === 'secondary-info') { + return toCSSColorValue(value, neutral[700]); + } + + if (value === 'surface-neutral') { + return toCSSColorValue(value, neutral[400]); + } + + const paletteMatches = paletteColorRegex.exec(String(value)); + if ( + typeof paletteMatches?.length === 'number' && + paletteMatches?.length >= 5 + ) { + const [, type, gradeString, , alphaString] = paletteMatches; + const grade = parseInt(gradeString, 10); + const alpha = + alphaString !== undefined ? parseInt(alphaString, 10) / 100 : undefined; + + invariant(isPaletteColorType(type), 'invalid color type'); + invariant(isPaletteColorGrade(grade), 'invalid color grade'); + invariant(isPaletteColorAlpha(alpha), 'invalid color alpha'); + + const [customProperty, color] = getPaletteColor(type, grade, alpha); + + if (customProperty) { + return toCSSValue(customProperty, color); + } + + return color; + } + return value; +}); + +export const size = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + + if (value === 'full') { + return '100%'; + } + + if (value === 'sw') { + return '100vw'; + } + + if (value === 'sh') { + return '100vh'; + } + + return undefined; +}); + +export const spacing = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + + return undefined; +}); + +export const inset = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + + return undefined; +}); + +export const margin = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + + return undefined; +}); + +export const padding = measure((value: unknown) => { + if (value === 'none') { + return '0px'; + } + + return undefined; +}); + +type FontFamily = keyof typeof tokenTypography.fontFamilies; + +const isFontFamily = (value: unknown): value is FontFamily => + typeof value === 'string' && value in tokenTypography.fontFamilies; + +export const fontFamily = memoize((value: unknown): string | undefined => { + if (!isFontFamily(value)) { + return undefined; + } + + const fontFamily = tokenTypography.fontFamilies[value] + .map((fontFace) => (fontFace.includes(' ') ? `'${fontFace}'` : fontFace)) + .join(', '); + + return toCSSFontValue(value, fontFamily); +}); + +type FontScale = keyof typeof tokenTypography.fontScales; + +const isFontScale = (value: unknown): value is FontScale => + typeof value === 'string' && value in tokenTypography.fontScales; + +export const fontScale = memoize( + ( + value: unknown, + ): + | { + fontSize: string; + fontWeight: number; + lineHeight: string; + letterSpacing: string; + } + | undefined => { + if (!isFontScale(value)) { + return undefined; + } + + const { fontSize, fontWeight, lineHeight, letterSpacing } = + tokenTypography.fontScales[value]; + + return { + fontSize: `${fontSize / 16}rem`, + fontWeight, + lineHeight: `${lineHeight / 16}rem`, + letterSpacing: `${letterSpacing / 16}rem`, + }; + }, +); diff --git a/packages/fuselage-box/src/types/Falsy.ts b/packages/fuselage-box/src/types/Falsy.ts new file mode 100644 index 0000000000..03ec62d6b3 --- /dev/null +++ b/packages/fuselage-box/src/types/Falsy.ts @@ -0,0 +1 @@ +export type Falsy = false | 0 | '' | null | undefined; diff --git a/packages/fuselage-box/tsconfig.json b/packages/fuselage-box/tsconfig.json new file mode 100644 index 0000000000..be747d0dd3 --- /dev/null +++ b/packages/fuselage-box/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "esnext", + "moduleResolution": "bundler" + }, + "include": ["src"] +} diff --git a/packages/fuselage-forms/.storybook/preview.tsx b/packages/fuselage-forms/.storybook/preview.tsx index 2d26f6b0e1..8b61968964 100644 --- a/packages/fuselage-forms/.storybook/preview.tsx +++ b/packages/fuselage-forms/.storybook/preview.tsx @@ -1,4 +1,4 @@ -import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import { FuselageProvider, PaletteStyleTag } from '@rocket.chat/fuselage'; import breakpointTokens from '@rocket.chat/fuselage-tokens/breakpoints.json'; import surface from '@rocket.chat/fuselage-tokens/dist/surface.json'; import { useDarkMode } from '@rocket.chat/storybook-dark-mode'; @@ -79,11 +79,13 @@ export default { (Story) => { const dark = useDarkMode(); + const theme = dark ? 'dark' : 'light'; + return ( - <> - + + - + ); }, ], diff --git a/packages/fuselage-forms/jest.config.ts b/packages/fuselage-forms/jest.config.ts index 403aacfe6c..d52e5bcebc 100644 --- a/packages/fuselage-forms/jest.config.ts +++ b/packages/fuselage-forms/jest.config.ts @@ -10,4 +10,7 @@ export default { moduleNameMapper: { '\\.scss$': 'testing-utils/lazySingletonStyleTagModule', }, + transformIgnorePatterns: [ + 'node_modules/(?!(@rocket\\.chat/fuselage-box|@rocket\\.chat/fuselage|tamagui|@tamagui)/)', + ], } satisfies Config; diff --git a/packages/fuselage-forms/package.json b/packages/fuselage-forms/package.json index 3658fb093e..ed96b7a4d1 100644 --- a/packages/fuselage-forms/package.json +++ b/packages/fuselage-forms/package.json @@ -26,10 +26,10 @@ ".:build:clean": "rimraf dist", ".:build:cjs": "tsc -p tsconfig.cjs.json", ".:build:esm": "tsc -p tsconfig.esm.json", - "storybook": "storybook dev -p 6006 --no-version-updates", + "storybook": "yarn workspace @rocket.chat/storybook-dark-mode run build && storybook dev -p 6006 --no-version-updates", "build": "run .:build:clean && run .:build:esm && run .:build:cjs", "clean": "rimraf dist", - "docs": "cross-env NODE_ENV=production storybook build -o ../../static/fuselage-forms", + "docs": "yarn workspace @rocket.chat/storybook-dark-mode run build && cross-env NODE_ENV=production storybook build -o ../../static/fuselage-forms", "lint": "lint", "lint-and-fix": "lint-and-fix", "test": "jest --runInBand" diff --git a/packages/fuselage-forms/tsconfig.json b/packages/fuselage-forms/tsconfig.json index dfe533c346..e5b7e3a487 100644 --- a/packages/fuselage-forms/tsconfig.json +++ b/packages/fuselage-forms/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist/esm", - "skipLibCheck": true + "skipLibCheck": true, + "module": "esnext", + "moduleResolution": "bundler" }, "typedocOptions": { "entryPoints": ["src/index.ts"], diff --git a/packages/fuselage-toastbar/.storybook/preview.tsx b/packages/fuselage-toastbar/.storybook/preview.tsx index 905c6c4cac..8e2e75b8c2 100644 --- a/packages/fuselage-toastbar/.storybook/preview.tsx +++ b/packages/fuselage-toastbar/.storybook/preview.tsx @@ -1,3 +1,4 @@ +import { FuselageProvider } from '@rocket.chat/fuselage'; import surface from '@rocket.chat/fuselage-tokens/dist/surface.json'; import { DarkModeProvider } from '@rocket.chat/layout'; import { useDarkMode } from '@rocket.chat/storybook-dark-mode'; @@ -55,14 +56,18 @@ export default { (Story) => { const dark = useDarkMode(); + const theme = dark ? 'dark' : 'light'; + return ( - - - - - - - + + + + + + + + + ); }, ], diff --git a/packages/fuselage-toastbar/jest.config.ts b/packages/fuselage-toastbar/jest.config.ts index e6300c5323..d15d1a551d 100644 --- a/packages/fuselage-toastbar/jest.config.ts +++ b/packages/fuselage-toastbar/jest.config.ts @@ -7,4 +7,7 @@ export default { '/jest-setup.ts', 'testing-utils/setup/noErrorsLogged', ], + transformIgnorePatterns: [ + 'node_modules/(?!(@rocket\\.chat/fuselage-box|@rocket\\.chat/fuselage|tamagui|@tamagui)/)', + ], } satisfies Config; diff --git a/packages/fuselage-toastbar/package.json b/packages/fuselage-toastbar/package.json index c81feddcd6..3a3bf313d0 100644 --- a/packages/fuselage-toastbar/package.json +++ b/packages/fuselage-toastbar/package.json @@ -38,7 +38,7 @@ "lint-and-fix": "lint-and-fix", "test": "jest --runInBand", "docs": "typedoc", - "storybook": "storybook dev -p 6006", + "storybook": "yarn workspace @rocket.chat/storybook-dark-mode run build && storybook dev -p 6006", "build-storybook": "storybook build" }, "dependencies": { diff --git a/packages/fuselage-toastbar/tsconfig.json b/packages/fuselage-toastbar/tsconfig.json index 835265b97b..e9ef0c417e 100644 --- a/packages/fuselage-toastbar/tsconfig.json +++ b/packages/fuselage-toastbar/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "rootDirs": ["./src", "./.storybook"], "outDir": "./dist", - "skipLibCheck": true + "skipLibCheck": true, + "module": "esnext", + "moduleResolution": "bundler" }, "include": [ "./src", diff --git a/packages/fuselage/.storybook/main.ts b/packages/fuselage/.storybook/main.ts index 56ba1ee845..014c7c66fe 100644 --- a/packages/fuselage/.storybook/main.ts +++ b/packages/fuselage/.storybook/main.ts @@ -1,16 +1,9 @@ import { dirname, join } from 'path'; import type { StorybookConfig } from '@storybook/react-webpack5'; +import webpack from 'webpack'; const config: StorybookConfig = { - webpackFinal: async (config) => { - config.module?.rules?.push({ - test: /\.woff2$/, - type: 'asset/resource', - }); - - return config; - }, addons: [ getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@rocket.chat/storybook-dark-mode'), @@ -95,8 +88,21 @@ const config: StorybookConfig = { typescript: { reactDocgen: 'react-docgen-typescript', }, -}; + webpackFinal: async (config) => { + config.plugins.push( + new webpack.ProvidePlugin({ + process: 'process/browser', + }), + ); + config.module?.rules?.push({ + test: /\.woff2$/, + type: 'asset/resource', + }); + + return config; + }, +} satisfies StorybookConfig; export default config; function getAbsolutePath(value: string): any { diff --git a/packages/fuselage/.storybook/preview.tsx b/packages/fuselage/.storybook/preview.tsx index dfd5d65d6c..642c7e86a8 100644 --- a/packages/fuselage/.storybook/preview.tsx +++ b/packages/fuselage/.storybook/preview.tsx @@ -5,7 +5,7 @@ import type { Preview } from '@storybook/react-webpack5'; import { themes } from 'storybook/theming'; import manifest from '../package.json'; -import { PaletteStyleTag } from '../src'; +import { FuselageProvider, PaletteStyleTag } from '../src'; import DocsContainer from './DocsContainer'; import logo from './logo.svg'; @@ -78,12 +78,13 @@ export default { decorators: [ (Story) => { const dark = useDarkMode(); + const theme = dark ? 'dark' : 'light'; return ( - <> - + + - + ); }, ], diff --git a/packages/fuselage/jest.config.ts b/packages/fuselage/jest.config.ts index 403aacfe6c..8519bf9d49 100644 --- a/packages/fuselage/jest.config.ts +++ b/packages/fuselage/jest.config.ts @@ -10,4 +10,7 @@ export default { moduleNameMapper: { '\\.scss$': 'testing-utils/lazySingletonStyleTagModule', }, + transformIgnorePatterns: [ + 'node_modules/(?!(@rocket\\.chat/fuselage-box|tamagui|@tamagui)/)', + ], } satisfies Config; diff --git a/packages/fuselage/package.json b/packages/fuselage/package.json index 31c71415ff..261ffdd347 100644 --- a/packages/fuselage/package.json +++ b/packages/fuselage/package.json @@ -24,24 +24,27 @@ ], "scripts": { "start": "webpack --watch --mode development", - "storybook": "storybook dev -p 6006 --no-version-updates", - "build": "run-s .:build:clean .:build:dev .:build:prod", + "storybook": "yarn workspace @rocket.chat/storybook-dark-mode run build && storybook dev -p 6006 --no-version-updates", + "build": "run-s .:build:clean .:build:dev .:build:prod .:build:types", ".:build:clean": "rimraf dist", ".:build:prod": "webpack --mode production", ".:build:dev": "webpack --mode development", + ".:build:types": "tsc -p tsconfig.decl.json --noCheck", "lint": "lint", "lint-and-fix": "lint-and-fix", "test": "jest --runInBand", "watch": "jest --watch", - "docs": "cross-env NODE_ENV=production storybook build -o ../../static/fuselage", + "docs": "yarn workspace @rocket.chat/storybook-dark-mode run build && cross-env NODE_ENV=production storybook build -o ../../static/fuselage", "build-storybook": "cross-env NODE_ENV=production storybook build" }, "dependencies": { "@rocket.chat/css-in-js": "workspace:~", "@rocket.chat/css-supports": "workspace:~", + "@rocket.chat/fuselage-box": "workspace:~", "@rocket.chat/fuselage-tokens": "workspace:~", "@rocket.chat/memo": "workspace:~", "@rocket.chat/styled": "workspace:~", + "@tamagui/core": "2.0.0-rc.31", "invariant": "^2.2.4", "react-aria": "~3.37.0", "react-keyed-flatten-children": "^1.3.0", @@ -61,6 +64,7 @@ "@storybook/addon-styling-webpack": "~2.0.0", "@storybook/addon-webpack5-compiler-swc": "~4.0.3", "@storybook/react-webpack5": "~9.1.17", + "@tamagui/static": "2.0.0-rc.31", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", @@ -92,6 +96,7 @@ "prettier": "~3.6.2", "react": "~18.3.1", "react-dom": "~18.3.1", + "react-native-web": "~0.21.2", "react-virtuoso": "~4.18.3", "resolve-url-loader": "~5.0.0", "rimraf": "~6.0.1", diff --git a/packages/fuselage/src/Theme.ts b/packages/fuselage/src/Theme.ts index 0c882cd60f..8187437ebe 100644 --- a/packages/fuselage/src/Theme.ts +++ b/packages/fuselage/src/Theme.ts @@ -1,261 +1,21 @@ -import tokenColors from '@rocket.chat/fuselage-tokens/colors.json'; - -import { getPaletteColor } from './getPaletteColor'; -import { toCSSColorValue } from './helpers/toCSSValue'; - -export class Var { - private name: string; - - private value: string; - - constructor(name: string, value: string) { - this.name = name; - this.value = value; - } - - toString() { - return toCSSColorValue(this.name, this.value); - } - - theme(name: string) { - return new Var(name, this.toString()); - } -} - -const white = new Var('white', '#ffffff'); - -export let throwErrorOnInvalidToken = false; -export const __setThrowErrorOnInvalidToken__ = (value: boolean) => { - throwErrorOnInvalidToken = value; -}; - -export const neutral = { - 100: new Var('neutral-100', tokenColors.n100), - 200: new Var('neutral-200', tokenColors.n200), - 250: new Var('neutral-250', tokenColors.n250), - 300: new Var('neutral-300', tokenColors.n300), - 400: new Var('neutral-400', tokenColors.n400), - 450: new Var('neutral-450', tokenColors.n450), - 500: new Var('neutral-500', tokenColors.n500), - 600: new Var('neutral-600', tokenColors.n600), - 700: new Var('neutral-700', tokenColors.n700), - 800: new Var('neutral-800', tokenColors.n800), - 900: new Var('neutral-900', tokenColors.n900), -}; - -const blue = { - 100: new Var('primary-100', tokenColors.b100), - 200: new Var('primary-200', tokenColors.b200), - 300: new Var('primary-300', tokenColors.b300), - 400: new Var('primary-400', tokenColors.b400), - 500: new Var('primary-500', tokenColors.b500), - 600: new Var('primary-600', tokenColors.b600), - 700: new Var('primary-700', tokenColors.b700), - 800: new Var('primary-800', tokenColors.b800), - 900: new Var('primary-900', tokenColors.b900), -}; - -const green = { - 100: new Var('success-100', tokenColors.g100), - 200: new Var('success-200', tokenColors.g200), - 300: new Var('success-300', tokenColors.g300), - 400: new Var('success-400', tokenColors.g400), - 500: new Var('success-500', tokenColors.g500), - 600: new Var('success-600', tokenColors.g600), - 700: new Var('success-700', tokenColors.g700), - 800: new Var('success-800', tokenColors.g800), - 900: new Var('success-900', tokenColors.g900), -}; - -const yellow = { - 100: new Var('warning-100', tokenColors.y100), - 200: new Var('warning-200', tokenColors.y200), - 300: new Var('warning-300', tokenColors.y300), - 400: new Var('warning-400', tokenColors.y400), - 500: new Var('warning-500', tokenColors.y500), - 600: new Var('warning-600', tokenColors.y600), - 700: new Var('warning-700', tokenColors.y700), - 800: new Var('warning-800', tokenColors.y800), - 900: new Var('warning-900', tokenColors.y900), -}; - -const red = { - 100: new Var('danger-100', tokenColors.r100), - 200: new Var('danger-200', tokenColors.r200), - 300: new Var('danger-300', tokenColors.r300), - 400: new Var('danger-400', tokenColors.r400), - 500: new Var('danger-500', tokenColors.r500), - 600: new Var('danger-600', tokenColors.r600), - 700: new Var('danger-700', tokenColors.r700), - 800: new Var('danger-800', tokenColors.r800), - 900: new Var('danger-900', tokenColors.r900), -}; - -const orange = { - 100: new Var('service-1-100', tokenColors.o100), - 200: new Var('service-1-200', tokenColors.o200), - 300: new Var('service-1-300', tokenColors.o300), - 400: new Var('service-1-400', tokenColors.o400), - 500: new Var('service-1-500', tokenColors.o500), - 600: new Var('service-1-600', tokenColors.o600), - 700: new Var('service-1-700', tokenColors.o700), - 800: new Var('service-1-800', tokenColors.o800), - 900: new Var('service-1-900', tokenColors.o900), -}; - -const purple = { - 100: new Var('service-2-100', tokenColors.p100), - 200: new Var('service-2-200', tokenColors.p200), - 300: new Var('service-2-300', tokenColors.p300), - 400: new Var('service-2-400', tokenColors.p400), - 500: new Var('service-2-500', tokenColors.p500), - 600: new Var('service-2-600', tokenColors.p600), - 700: new Var('service-2-700', tokenColors.p700), - 800: new Var('service-2-800', tokenColors.p800), - 900: new Var('service-2-900', tokenColors.p900), -}; - -export const surfaceColors = { - 'surface-light': white.theme('surface-light'), - 'surface-tint': neutral[100].theme('surface-tint'), - 'surface-room': white.theme('surface-room'), - 'surface-neutral': neutral[400].theme('surface-neutral'), - 'surface-disabled': neutral[100].theme('surface-disabled'), - 'surface-hover': neutral[200].theme('surface-hover'), - 'surface-selected': neutral[450].theme('surface-selected'), - 'surface-dark': neutral[800].theme('surface-dark'), - 'surface-featured': purple['700'].theme('surface-featured'), - 'surface-featured-hover': purple['800'].theme('surface-featured-hover'), - 'surface-overlay': neutral[800].theme('surface-overlay'), - 'surface-transparent': 'transparent', - 'surface-sidebar': neutral[400].theme('surface-sidebar'), -}; - -type SurfaceColors = keyof typeof surfaceColors; - -export const strokeColors = { - 'stroke-extra-light': neutral[250].theme('stroke-extra-light'), - 'stroke-light': neutral[500].theme('stroke-light'), - 'stroke-medium': neutral[600].theme('stroke-medium'), - 'stroke-dark': neutral[700].theme('stroke-dark'), - 'stroke-extra-dark': neutral[800].theme('stroke-extra-dark'), - 'stroke-extra-light-highlight': blue[200].theme( - 'stroke-extra-light-highlight', - ), - 'stroke-highlight': blue[500].theme('stroke-highlight'), - 'stroke-extra-light-error': red[200].theme('stroke-extra-light-error'), - 'stroke-error': red[500].theme('stroke-error'), -}; - -type StrokeColor = keyof typeof strokeColors; - -export const textIconColors = { - 'font-white': white.theme('font-white'), - 'font-disabled': neutral[500].theme('font-disabled'), - 'font-annotation': neutral[600].theme('font-annotation'), - 'font-hint': neutral[700].theme('font-hint'), - 'font-secondary-info': neutral[700].theme('font-secondary-info'), - 'font-default': neutral[800].theme('font-default'), - 'font-titles-labels': neutral[900].theme('font-titles-labels'), - 'font-info': blue[600].theme('font-info'), - 'font-danger': red[600].theme('font-danger'), - 'font-pure-black': neutral[800].theme('font-pure-black'), - 'font-pure-white': white.theme('font-pure-white'), -}; - -type TextIconColors = keyof typeof textIconColors; - -export const statusBackgroundColors = { - 'status-background-info': blue[200].theme('status-background-info'), - 'status-background-success': green[200].theme('status-background-success'), - 'status-background-danger': red[200].theme('status-background-danger'), - 'status-background-warning': yellow[200].theme('status-background-warning'), - 'status-background-warning-2': yellow[100].theme( - 'status-background-warning-2', - ), - 'status-background-service-1': orange[200].theme( - 'status-background-service-1', - ), - 'status-background-service-2': purple[200].theme( - 'status-background-service-2', - ), -}; - -type StatusBackgroundColors = keyof typeof statusBackgroundColors; - -export const statusColors = { - 'status-font-on-info': blue[600].theme('status-font-on-info'), - 'status-font-on-success': green[800].theme('status-font-on-success'), - 'status-font-on-warning': yellow[800].theme('status-font-on-warning'), - 'status-font-on-warning-2': neutral[800].theme('status-font-on-warning-2'), - 'status-font-on-danger': red[800].theme('status-font-on-danger'), - 'status-font-on-service-1': orange[800].theme('status-font-on-service-1'), - 'status-font-on-service-2': purple[600].theme('status-font-on-service-2'), -}; - -type StatusColors = keyof typeof statusColors; - -export const badgeBackgroundColors = { - 'badge-background-level-0': neutral[400].theme('badge-background-level-0'), - 'badge-background-level-1': neutral[600].theme('badge-background-level-1'), - 'badge-background-level-2': blue[500].theme('badge-background-level-2'), - 'badge-background-level-3': orange[500].theme('badge-background-level-3'), - 'badge-background-level-4': red[500].theme('badge-background-level-4'), -}; - -type BadgeBackgroundColors = keyof typeof badgeBackgroundColors; - -export const shadowColors = { - 'shadow-elevation-border': strokeColors['stroke-extra-light'].theme( - 'shadow-elevation-border', - ), - 'shadow-elevation-1': new Var( - 'shadow-elevation-1', - getPaletteColor('neutral', 800, 0.1)[1], - ), - 'shadow-elevation-2x': new Var( - 'shadow-elevation-2x', - getPaletteColor('neutral', 800, 0.08)[1], - ), - 'shadow-elevation-2y': new Var( - 'shadow-elevation-2y', - getPaletteColor('neutral', 800, 0.12)[1], - ), - 'shadow-highlight': blue[200].theme('shadow-highlight'), - 'shadow-danger': red[100].theme('shadow-danger'), -}; - -type ShadowColors = keyof typeof shadowColors; - -export const isSurfaceColor = (color: unknown): color is SurfaceColors => - typeof color === 'string' && color in surfaceColors; - -export const isStrokeColor = (color: unknown): color is StrokeColor => - typeof color === 'string' && color in strokeColors; - -export const isTextIconColor = (color: unknown): color is TextIconColors => - typeof color === 'string' && color in textIconColors; - -export const isBadgeColor = (color: unknown): color is BadgeBackgroundColors => - typeof color === 'string' && color in badgeBackgroundColors; - -export const isStatusBackgroundColor = ( - color: unknown, -): color is StatusBackgroundColors => - typeof color === 'string' && color in statusBackgroundColors; - -export const isStatusColor = (color: unknown): color is StatusColors => - typeof color === 'string' && color in statusColors; - -export const isShadowColor = (color: unknown): color is ShadowColors => - typeof color === 'string' && color in shadowColors; - -export const Palette = { - surface: surfaceColors, - status: statusBackgroundColors, - statusColor: statusColors, - badge: badgeBackgroundColors, - text: textIconColors, - stroke: strokeColors, - shadow: shadowColors, -}; +export { + Var, + throwErrorOnInvalidToken, + __setThrowErrorOnInvalidToken__, + neutral, + surfaceColors, + strokeColors, + textIconColors, + statusBackgroundColors, + statusColors, + badgeBackgroundColors, + shadowColors, + isSurfaceColor, + isStrokeColor, + isTextIconColor, + isBadgeColor, + isStatusBackgroundColor, + isStatusColor, + isShadowColor, + Palette, +} from '@rocket.chat/fuselage-box'; diff --git a/packages/fuselage/src/components/Accordion/Accordion.styles.scss b/packages/fuselage/src/components/Accordion/Accordion.styles.scss deleted file mode 100644 index 962eedaeff..0000000000 --- a/packages/fuselage/src/components/Accordion/Accordion.styles.scss +++ /dev/null @@ -1,80 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -.rcx-accordion { - display: flex; - flex-flow: column nowrap; - border-block-end-color: colors.stroke(extra-light); - border-block-end-width: lengths.border-width(default); -} - -.rcx-accordion-item { - display: flex; - flex-flow: column nowrap; -} - -.rcx-accordion-item__bar { - display: flex; - flex-flow: row nowrap; - - min-height: lengths.size(2 * 32 + 24); - padding: (lengths.padding(32) - lengths.border-width(default, rem)) - (lengths.padding(8) - lengths.border-width(default, rem)); - - text-align: start; - - color: colors.font(titles-labels); - - border-width: lengths.border-width(default); - border-color: colors.stroke(extra-light) transparent transparent; - - &[tabindex] { - @include clickable; - - &.hover, - &:hover { - background-color: colors.surface(tint); - } - - &.focus, - &:focus { - border-color: colors.stroke(highlight); - @include use-focus-shadow( - $outer-color: colors.stroke(extra-light-highlight) - ); - } - } - - &--disabled { - cursor: not-allowed; - - color: colors.font(disabled); - background-color: colors.surface(disabled); - } -} - -.rcx-accordion-item__title { - flex: 1 1 lengths.size(none); - - @include typography.use-text-ellipsis; - white-space: nowrap; - - @include typography.use-font-scale(h4); -} - -.rcx-accordion-item__panel { - visibility: hidden; - - overflow: hidden; - - height: lengths.size(none); - padding: lengths.padding(none) lengths.padding(8); - - &--expanded { - visibility: visible; - - height: auto; - padding: lengths.padding(32) lengths.padding(8); - } -} diff --git a/packages/fuselage/src/components/Accordion/Accordion.tsx b/packages/fuselage/src/components/Accordion/Accordion.tsx index cd0402279a..4db17a7f49 100644 --- a/packages/fuselage/src/components/Accordion/Accordion.tsx +++ b/packages/fuselage/src/components/Accordion/Accordion.tsx @@ -1,22 +1,26 @@ import type { ReactNode } from 'react'; +import { styled } from '@tamagui/core'; -import { cx, cxx } from '../../helpers/composeClassNames'; -import { StylingBox } from '../Box'; -import type { StylingProps } from '../Box/stylingProps'; +import { RcxView } from '../../primitives'; + +const AccordionFrame = styled(RcxView, { + name: 'Accordion', + display: 'flex', + flexDirection: 'column', + flexWrap: 'nowrap', + borderBlockEndColor: '$strokeExtraLight', + borderBlockEndWidth: 1, +}); export type AccordionProps = { children: ReactNode; -} & Partial; +}; /** * An `Accordion` allows users to toggle the display of sections of content. */ const Accordion = ({ children, ...props }: AccordionProps) => ( - -
- {children} -
-
+ {children} ); export default Accordion; diff --git a/packages/fuselage/src/components/Accordion/AccordionItem.tsx b/packages/fuselage/src/components/Accordion/AccordionItem.tsx index 0723df1a79..2ea47160dc 100644 --- a/packages/fuselage/src/components/Accordion/AccordionItem.tsx +++ b/packages/fuselage/src/components/Accordion/AccordionItem.tsx @@ -5,11 +5,102 @@ import { type MouseEvent, type ReactNode, } from 'react'; +import { styled } from '@tamagui/core'; -import { cx, cxx } from '../../helpers/composeClassNames'; -import { StylingBox } from '../Box'; +import { RcxInteractive, RcxText, RcxView } from '../../primitives'; import { Chevron } from '../Chevron'; +const AccordionItemFrame = styled(RcxView, { + name: 'AccordionItem', + display: 'flex', + flexDirection: 'column', + flexWrap: 'nowrap', +}); + +const AccordionItemBar = styled(RcxInteractive, { + name: 'AccordionItemBar', + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + role: 'button', + + // min-height: 2 * 32 + 24 = 88px + minHeight: 88, + // padding: (32 - 1px border) (8 - 1px border) => 31px 7px + paddingBlock: 31, + paddingInline: 7, + + textAlign: 'left', + + color: '$fontTitlesLabels', + + borderWidth: 1, + borderStyle: 'solid', + borderBlockStartColor: '$strokeExtraLight', + borderBlockEndColor: 'transparent', + borderInlineColor: 'transparent', + + hoverStyle: { + backgroundColor: '$surfaceTint', + }, + + focusVisibleStyle: { + borderColor: '$strokeHighlight', + boxShadow: '0 0 0 2px var(--strokeExtraLightHighlight)', + }, + + variants: { + isDisabled: { + true: { + cursor: 'not-allowed', + color: '$fontDisabled', + backgroundColor: '$surfaceDisabled', + hoverStyle: { + backgroundColor: '$surfaceDisabled', + }, + }, + }, + } as const, +}); + +const AccordionItemTitle = styled(RcxText, { + name: 'AccordionItemTitle', + display: 'block', + flexGrow: 1, + + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + + fontFamily: '$body', + fontSize: '$h4', + fontWeight: '$h4', + lineHeight: '$h4', + letterSpacing: '$h4', + + color: 'inherit', +}); + +const AccordionItemPanel = styled(RcxView, { + name: 'AccordionItemPanel', + visibility: 'hidden', + overflow: 'hidden', + height: 0, + paddingBlock: 0, + paddingInline: '$x8', + + variants: { + expanded: { + true: { + visibility: 'visible', + height: 'auto', + paddingBlock: '$x32', + paddingInline: '$x8', + }, + }, + } as const, +}); + export type AccordionItemProps = { children?: ReactNode; className?: string; @@ -82,40 +173,25 @@ const AccordionItem = ({ const barProps = noncollapsible ? nonCollapsibleProps : collapsibleProps; return ( - -
- {title && ( -
-

- {title} -

- {!noncollapsible && } -
- )} -
+ {title && ( + - {children} -
-
-
+ + {title} + + {!noncollapsible && } + + )} + + {children} + + ); }; diff --git a/packages/fuselage/src/components/AutoComplete/AutoComplete.styles.scss b/packages/fuselage/src/components/AutoComplete/AutoComplete.styles.scss deleted file mode 100644 index 5629b50719..0000000000 --- a/packages/fuselage/src/components/AutoComplete/AutoComplete.styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.rcx-autocomplete { - @extend .rcx-select; -} diff --git a/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx b/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx index 324e33a7a6..a5e588fbfe 100644 --- a/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx +++ b/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx @@ -11,7 +11,9 @@ import type { ReactNode, } from 'react'; import { useEffect, useRef, useMemo, useState } from 'react'; +import { styled } from '@tamagui/core'; +import { RcxView } from '../../primitives'; import { AnimatedVisibility } from '../AnimatedVisibility'; import { Box } from '../Box'; import { Chip } from '../Chip'; @@ -55,6 +57,94 @@ export type AutoCompleteProps = Omit< value?: string | string[]; }; +// .rcx-autocomplete — extends .rcx-select which extends %rcx-input-box / %input +const AutoCompleteFrame = styled(RcxView, { + name: 'AutoCompleteFrame', + + position: 'relative', + + display: 'inline-flex', + flexDirection: 'row', + flexWrap: 'nowrap', + flexGrow: 1, + + alignItems: 'center', + + minWidth: 144, + minHeight: '$x40', + + paddingBlock: '$x8', + paddingInline: 15, // 16px - 1px border + + borderWidth: 1, + borderStyle: 'solid', + borderColor: '$strokeLight', + borderRadius: '$x4', + backgroundColor: '$surfaceLight', + boxShadow: 'none', + + hoverStyle: { + borderColor: '$strokeLight', + }, + + focusVisibleStyle: { + borderColor: '$strokeHighlight', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + + pressStyle: { + borderColor: '$strokeMedium', + boxShadow: 'none', + }, + + variants: { + isInvalid: { + true: { + borderColor: '$strokeError', + + hoverStyle: { + borderColor: '$strokeError', + }, + + focusVisibleStyle: { + borderColor: '$strokeError', + boxShadow: '0 0 0 2px var(--shadowDanger)', + }, + + pressStyle: { + borderColor: '$strokeMedium', + boxShadow: 'none', + }, + }, + }, + + isDisabled: { + true: { + cursor: 'not-allowed', + pointerEvents: 'none', + borderColor: '$strokeLight', + backgroundColor: '$surfaceDisabled', + }, + }, + } as const, +}); + +// .rcx-autocomplete__addon — extends .rcx-input-box__addon +const AutoCompleteAddon = styled(RcxView, { + name: 'AutoCompleteAddon', + + cursor: 'pointer', + + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + flexGrow: 0, + flexShrink: 0, + flexBasis: 'auto', + + alignItems: 'flex-start', +}); + const getSelected = ( value: string | string[] | undefined, options: AutoCompleteOption[], @@ -180,15 +270,11 @@ function AutoComplete({ useEffect(reset, [filter, reset]); return ( - ref.current?.focus())} - flexGrow={1} - className={useMemo( - () => [error && 'invalid', disabled && 'disabled'], - [error, disabled], - )} + isInvalid={error || undefined} + isDisabled={disabled || undefined} > ({ )} - + ({ size='x20' color='default' /> - + ({ options={memoizedOptions} /> - + ); } diff --git a/packages/fuselage/src/components/Avatar/Avatar.styles.scss b/packages/fuselage/src/components/Avatar/Avatar.styles.scss deleted file mode 100644 index a96a8feb31..0000000000 --- a/packages/fuselage/src/components/Avatar/Avatar.styles.scss +++ /dev/null @@ -1,76 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/functions.scss'; - -$avatar-stack-background-color: theme( - 'avatar-background-color', - colors.surface(light) -); - -$sizes: 16, 18, 20, 24, 28, 32, 36, 40, 48, 124, 200, 332; - -.rcx-avatar { - display: inline-flex; - - vertical-align: middle; - - @each $size in $sizes { - &--x#{$size} { - @include square(functions.to-rem($size)); - } - } - - &__element { - position: relative; - - width: 100%; - height: 100%; - @each $size in $sizes { - &--x#{$size} { - @if $size <= 18 { - border-radius: theme( - 'avatar-border-radius-#{$size}', - lengths.border-radius(small) - ); - } @else if $size == 332 { - border-radius: theme( - 'avatar-border-radius-#{$size}', - lengths.border-radius(large) - ); - } @else { - border-radius: theme( - 'avatar-border-radius-#{$size}', - lengths.border-radius(medium) - ); - } - } - } - - &--object-fit { - object-fit: contain; - } - - &--rounded { - border-radius: theme( - 'avatar-border-radius-rounded', - lengths.border-radius(full) - ); - } - } - - &-stack { - display: flex; - flex-direction: row-reverse; - justify-content: center; - - background-color: #{$avatar-stack-background-color}; - - & > .rcx-avatar { - margin: auto lengths.margin(-2); - - & > .rcx-avatar__element { - border: lengths.border-width(default) solid transparent; - } - } - } -} diff --git a/packages/fuselage/src/components/Avatar/Avatar.tsx b/packages/fuselage/src/components/Avatar/Avatar.tsx index 82187abc02..6a4f77b47a 100644 --- a/packages/fuselage/src/components/Avatar/Avatar.tsx +++ b/packages/fuselage/src/components/Avatar/Avatar.tsx @@ -1,4 +1,5 @@ -import type { AllHTMLAttributes } from 'react'; +import type { AllHTMLAttributes, CSSProperties } from 'react'; +import { useMemo } from 'react'; import AvatarContainer, { type AvatarContainerProps } from './AvatarContainer'; @@ -8,6 +9,21 @@ export type AvatarProps = AvatarContainerProps & { url: string; } & Omit, 'size'>; +const borderRadiusMap: Record = { + x16: '2px', // small + x18: '2px', // small + x20: '4px', // medium + x24: '4px', + x28: '4px', + x32: '4px', + x36: '4px', + x40: '4px', + x48: '4px', + x124: '4px', + x200: '4px', + x332: '8px', // large +}; + const Avatar = ({ size = 'x36', rounded = false, @@ -15,20 +31,33 @@ const Avatar = ({ url, className, alt, + style: styleProp, ...props }: AvatarProps) => { - const innerClass = [ - 'rcx-avatar__element', - objectFit && 'rcx-avatar__element--object-fit', - size && `rcx-avatar__element--${size}`, - rounded && 'rcx-avatar__element--rounded', - ] - .filter(Boolean) - .join(' '); + const imgStyle = useMemo(() => { + const base: CSSProperties = { + position: 'relative', + width: '100%', + height: '100%', + borderRadius: rounded ? '9999px' : borderRadiusMap[size] || '4px', + }; + + if (objectFit) { + base.objectFit = 'contain'; + } + + return { ...base, ...styleProp }; + }, [size, rounded, objectFit, styleProp]); return ( - {alt + {alt ); }; diff --git a/packages/fuselage/src/components/Avatar/AvatarContainer.tsx b/packages/fuselage/src/components/Avatar/AvatarContainer.tsx index 76db544940..c31120a76a 100644 --- a/packages/fuselage/src/components/Avatar/AvatarContainer.tsx +++ b/packages/fuselage/src/components/Avatar/AvatarContainer.tsx @@ -1,6 +1,35 @@ import type { HTMLAttributes } from 'react'; +import { styled } from '@tamagui/core'; -import { prependClassName } from '../../helpers/prependClassName'; +import { RcxView } from '../../primitives'; + +const StyledAvatarContainer = styled(RcxView, { + name: 'AvatarContainer', + + display: 'inline-flex', + verticalAlign: 'middle', + + variants: { + size: { + x16: { width: 16, height: 16 }, + x18: { width: 18, height: 18 }, + x20: { width: 20, height: 20 }, + x24: { width: 24, height: 24 }, + x28: { width: 28, height: 28 }, + x32: { width: 32, height: 32 }, + x36: { width: 36, height: 36 }, + x40: { width: 40, height: 40 }, + x48: { width: 48, height: 48 }, + x124: { width: 124, height: 124 }, + x200: { width: 200, height: 200 }, + x332: { width: 332, height: 332 }, + }, + } as const, + + defaultVariants: { + size: 'x36', + }, +}); export type AvatarContainerProps = { size?: @@ -23,14 +52,11 @@ const AvatarContainer = ({ children, ...props }: AvatarContainerProps) => { - props.className = prependClassName( - props.className, - ['rcx-box rcx-box--full rcx-avatar', size && `rcx-avatar--${size}`] - .filter(Boolean) - .join(' '), + return ( + + {children} + ); - - return
{children}
; }; export default AvatarContainer; diff --git a/packages/fuselage/src/components/Avatar/AvatarStack.tsx b/packages/fuselage/src/components/Avatar/AvatarStack.tsx index 4d3fa8d0f4..d301fcf594 100644 --- a/packages/fuselage/src/components/Avatar/AvatarStack.tsx +++ b/packages/fuselage/src/components/Avatar/AvatarStack.tsx @@ -1,7 +1,25 @@ import type { DetailedHTMLProps, HTMLAttributes } from 'react'; import flattenChildren from 'react-keyed-flatten-children'; +import { styled } from '@tamagui/core'; -import { prependClassName } from '../../helpers/prependClassName'; +import { RcxView } from '../../primitives'; + +const StyledAvatarStack = styled(RcxView, { + name: 'AvatarStack', + + display: 'flex', + flexDirection: 'row-reverse', + justifyContent: 'center', + + backgroundColor: '$surfaceLight', +}); + +const AvatarStackItem = styled(RcxView, { + name: 'AvatarStackItem', + + marginBlock: 'auto', + marginInline: -2, +}); export type AvatarStackProps = DetailedHTMLProps< HTMLAttributes, @@ -9,8 +27,15 @@ export type AvatarStackProps = DetailedHTMLProps< >; const AvatarStack = ({ children, ...props }: AvatarStackProps) => { - props.className = prependClassName(props.className, 'rcx-avatar-stack'); - return
{flattenChildren(children).reverse()}
; + const reversed = flattenChildren(children).reverse(); + + return ( + + {reversed.map((child, index) => ( + {child} + ))} + + ); }; export default AvatarStack; diff --git a/packages/fuselage/src/components/Badge/Badge.styles.scss b/packages/fuselage/src/components/Badge/Badge.styles.scss deleted file mode 100644 index b23a72cf8b..0000000000 --- a/packages/fuselage/src/components/Badge/Badge.styles.scss +++ /dev/null @@ -1,117 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -$badge-colors-primary-color: theme( - 'badge-colors-primary-color', - colors.font(pure-white) -); -$badge-colors-primary-background-color: theme( - 'badge-colors-primary-background-color', - colors.badge(level-2) -); - -$badge-colors-secondary-color: theme( - 'badge-colors-secondary-color', - colors.font(pure-white) -); -$badge-colors-secondary-background-color: theme( - 'badge-colors-secondary-background-color', - colors.badge(level-1) -); - -$badge-colors-warning-color: theme( - 'badge-colors-warning-color', - colors.font(pure-white) -); -$badge-colors-warning-background-color: theme( - 'badge-colors-warning-background-color', - colors.badge(level-3) -); - -$badge-colors-danger-color: theme( - 'badge-colors-danger-color', - colors.font(pure-white) -); -$badge-colors-danger-background-color: theme( - 'badge-colors-danger-background-color', - colors.badge(level-4) -); - -$badge-colors-ghost-color: theme( - 'badge-colors-ghost-color', - colors.font(pure-white) -); -$badge-colors-ghost-background-color: theme( - 'badge-colors-ghost-background-color', - colors.stroke(dark) -); - -$badge-colors-disabled-color: theme( - 'badge-colors-disabled-color', - colors.font(secondary-info) -); -$badge-colors-disabled-background-color: theme( - 'badge-colors-disabled-background-color', - colors.surface(neutral) -); - -.rcx-badge { - display: flex; - overflow: hidden; - justify-content: center; - - width: fit-content; - min-width: lengths.size(16); - min-height: lengths.size(16); - - padding: lengths.padding(2) lengths.padding(4); - - text-align: center; - - white-space: nowrap; - - text-decoration: none; - text-overflow: ellipsis; - - word-break: keep-all; - - border-radius: theme('badge-border-radius', lengths.border-radius(full)); - - @include typography.use-font-scale(micro); - - &--primary { - color: $badge-colors-primary-color; - background-color: $badge-colors-primary-background-color; - } - - &--secondary { - color: $badge-colors-secondary-color; - background-color: $badge-colors-secondary-background-color; - } - - &--warning { - color: $badge-colors-warning-color; - background-color: $badge-colors-warning-background-color; - } - - &--danger { - color: $badge-colors-danger-color; - background-color: $badge-colors-danger-background-color; - } - - &--ghost { - color: $badge-colors-ghost-color; - background-color: $badge-colors-ghost-background-color; - } - - &--disabled { - color: $badge-colors-disabled-color; - background-color: $badge-colors-disabled-background-color; - } - - &--small { - min-width: lengths.size(8); - min-height: lengths.size(8); - } -} diff --git a/packages/fuselage/src/components/Badge/Badge.tsx b/packages/fuselage/src/components/Badge/Badge.tsx index 44cefcea84..e4d0e27059 100644 --- a/packages/fuselage/src/components/Badge/Badge.tsx +++ b/packages/fuselage/src/components/Badge/Badge.tsx @@ -1,6 +1,78 @@ -import type { ElementType, HTMLAttributes, ReactNode } from 'react'; +import type { ElementType, HTMLAttributes, ReactNode, Ref } from 'react'; +import { styled } from '@tamagui/core'; -import { prependClassName } from '../../helpers/prependClassName'; +import { RcxText } from '../../primitives'; + +export const BadgeFrame = styled(RcxText, { + name: 'Badge', + + display: 'flex', + overflow: 'hidden', + justifyContent: 'center', + + width: 'fit-content', + minWidth: '$x16', + minHeight: '$x16', + + paddingBlock: '$x2', + paddingInline: '$x4', + + textAlign: 'center', + whiteSpace: 'nowrap', + textDecorationLine: 'none', + textOverflow: 'ellipsis', + + borderRadius: '$full', + + fontFamily: '$body', + fontSize: '$micro', + lineHeight: '$micro', + fontWeight: '$micro', + letterSpacing: '$micro', + + variants: { + variant: { + primary: { + color: '$fontPureWhite', + backgroundColor: '$badgeLevel2', + }, + secondary: { + color: '$fontPureWhite', + backgroundColor: '$badgeLevel1', + }, + danger: { + color: '$fontPureWhite', + backgroundColor: '$badgeLevel4', + }, + warning: { + color: '$fontPureWhite', + backgroundColor: '$badgeLevel3', + }, + ghost: { + color: '$fontPureWhite', + backgroundColor: '$strokeDark', + }, + }, + + small: { + true: { + minWidth: '$x8', + minHeight: '$x8', + }, + }, + + disabled: { + true: { + color: '$fontSecondaryInfo', + backgroundColor: '$surfaceNeutral', + }, + }, + } as const, + + defaultVariants: { + variant: 'secondary', + }, +}); export type BadgeProps = { is?: ElementType>; @@ -13,30 +85,34 @@ export type BadgeProps = { } & HTMLAttributes; /** - * Communicates notification’s amount and types. + * Communicates notification's amount and types. + * Uses .styleable() so Tamagui compiler can optimize it at build time. */ -function Badge({ - is: Tag = 'span', - variant = 'secondary', - small, - className, - disabled, - ...props -}: BadgeProps) { - const modifiers = [variant, small && 'small', disabled && 'disabled'] - .filter(Boolean) - .map((modifier) => `rcx-badge--${modifier}`) - .join(' '); - - return ( - ( + ( + { + is, + variant = 'secondary', + small, + disabled, + children, + title, + ...props + }: BadgeProps, + ref: Ref, + ) => ( + - ); -} + > + {children} + + ), +); export default Badge; diff --git a/packages/fuselage/src/components/Badge/index.ts b/packages/fuselage/src/components/Badge/index.ts index e742dd773a..490c5b183e 100644 --- a/packages/fuselage/src/components/Badge/index.ts +++ b/packages/fuselage/src/components/Badge/index.ts @@ -1 +1 @@ -export { default as Badge, type BadgeProps } from './Badge'; +export { default as Badge, BadgeFrame, type BadgeProps } from './Badge'; diff --git a/packages/fuselage/src/components/Banner/Banner.styles.scss b/packages/fuselage/src/components/Banner/Banner.styles.scss deleted file mode 100644 index 74dc17f5f8..0000000000 --- a/packages/fuselage/src/components/Banner/Banner.styles.scss +++ /dev/null @@ -1,155 +0,0 @@ -@use 'sass:map'; -@use '../../styles/functions'; -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; -@use '../../styles/mixins/elevation.scss'; - -// neutral -$banner-colors-neutral-color: functions.theme( - 'banner-colors-neutral-color', - colors.font(default) -); -$banner-colors-neutral-background-color: functions.theme( - 'banner-colors-neutral-background-color', - colors.surface(tint) -); - -// info -$banner-colors-info-color: functions.theme( - 'banner-colors-info-color', - colors.status-font(on-info) -); - -// success -$banner-colors-success-color: functions.theme( - 'banner-colors-success-color', - colors.status-font(on-success) -); - -// warning -$banner-colors-warning-color: functions.theme( - 'banner-colors-warning-color', - colors.status-font(on-warning) -); - -// danger -$banner-colors-danger-color: functions.theme( - 'banner-colors-danger-color', - colors.status-font(on-danger) -); - -.rcx-banner { - display: flex; - flex-flow: row nowrap; - justify-content: space-between; - align-items: flex-start; - flex: 0 1 auto; - - box-sizing: border-box; - - padding-block: 14px; - padding-inline: 16px; - - color: $banner-colors-neutral-color; - border-top-width: lengths.border-width(4); - border-top-style: solid; - border-bottom: lengths.border-width(default) solid colors.stroke(extra-light); - - background-color: $banner-colors-neutral-background-color; - - font-family: typography.font-family('sans'); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - &--inline { - padding-block: 12px; - } - - &--actionable { - cursor: pointer; - } - - &--neutral { - border-top-color: transparent; - } - - &--info { - border-top-color: $banner-colors-info-color; - } - - &--warning { - border-top-color: $banner-colors-warning-color; - } - - &--danger { - border-top-color: $banner-colors-danger-color; - } - - &--success { - border-top-color: $banner-colors-success-color; - } - - &__icon { - padding-block: 8px; - padding-inline-end: 12px; - - &--info { - color: $banner-colors-info-color; - } - - &--warning { - color: $banner-colors-warning-color; - } - - &--danger { - color: $banner-colors-danger-color; - } - - &--success { - color: $banner-colors-success-color; - } - - &--inline { - margin-block: -2px; - padding-block: 0; - } - } - - &__content { - flex-grow: 1; - align-self: center; - - @include typography.use-font-scale(p2); - - &--inline { - @include typography.use-with-truncated-text; - } - } - - &__title { - margin: 0; - padding: 0; - @include typography.use-font-scale(h5); - - &--inline { - display: inline; - - padding-inline-end: 8px; - } - } - - &__close-button { - padding-block: 6px; - padding-inline: 8px; - - &--inline { - margin-block: -4px; - padding-block: 0; - } - } - - &__link { - padding-left: 10px; - } -} diff --git a/packages/fuselage/src/components/Banner/Banner.tsx b/packages/fuselage/src/components/Banner/Banner.tsx index 8c84d6e940..f99a14b6b0 100644 --- a/packages/fuselage/src/components/Banner/Banner.tsx +++ b/packages/fuselage/src/components/Banner/Banner.tsx @@ -9,8 +9,9 @@ import type { MouseEvent, } from 'react'; import { useRef, useCallback, useMemo } from 'react'; +import { styled } from '@tamagui/core'; -import { composeClassNames as cx } from '../../helpers/composeClassNames'; +import { RcxView, RcxText } from '../../primitives'; import { IconButton } from '../Button'; type VariantType = 'neutral' | 'info' | 'success' | 'warning' | 'danger'; @@ -23,6 +24,184 @@ const variants: VariantType[] = [ 'danger', ]; +const BannerFrame = styled(RcxText, { + name: 'BannerFrame', + + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'space-between', + alignItems: 'flex-start', + flexShrink: 1, + + paddingBlock: 14, + paddingInline: 16, + + color: '$fontDefault', + borderTopWidth: 4, + borderTopStyle: 'solid', + borderBottomWidth: 1, + borderBottomStyle: 'solid', + borderBottomColor: '$strokeExtraLight', + + backgroundColor: '$surfaceTint', + + fontFamily: '$body', + + overflowWrap: 'normal', + + variants: { + variant: { + neutral: { + borderTopColor: 'transparent', + }, + info: { + borderTopColor: '$statusFontOnInfo', + }, + success: { + borderTopColor: '$statusFontOnSuccess', + }, + warning: { + borderTopColor: '$statusFontOnWarning', + }, + danger: { + borderTopColor: '$statusFontOnDanger', + }, + }, + + inline: { + true: { + paddingBlock: 12, + }, + }, + + actionable: { + true: { + cursor: 'pointer', + }, + }, + } as const, + + defaultVariants: { + variant: 'neutral', + }, +}); + +const BannerIcon = styled(RcxView, { + name: 'BannerIcon', + + paddingBlock: 8, + paddingInlineEnd: 12, + + variants: { + variant: { + neutral: {}, + info: { + color: '$statusFontOnInfo', + }, + success: { + color: '$statusFontOnSuccess', + }, + warning: { + color: '$statusFontOnWarning', + }, + danger: { + color: '$statusFontOnDanger', + }, + }, + + inline: { + true: { + marginBlock: -2, + paddingBlock: 0, + }, + }, + } as const, +}); + +const BannerContainer = styled(RcxText, { + name: 'BannerContainer', + + flexGrow: 1, + alignSelf: 'center', + display: 'flex', + flexDirection: 'column', + gap: '$x4', + + fontFamily: '$body', + fontSize: '$p2', + fontWeight: '$p2', + lineHeight: '$p2', + letterSpacing: '$p2', + + color: 'inherit', + overflowWrap: 'normal', + + variants: { + inline: { + true: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + }, + } as const, +}); + +const BannerTitle = styled(RcxText, { + name: 'BannerTitle', + + margin: 0, + padding: 0, + + fontFamily: '$body', + fontSize: '$h5', + fontWeight: '$h5', + lineHeight: '$h5', + letterSpacing: '$h5', + + color: 'inherit', + overflowWrap: 'normal', + + variants: { + inline: { + true: { + display: 'inline' as any, + paddingInlineEnd: 8, + }, + }, + } as const, +}); + +const BannerContent = styled(RcxView, { + name: 'BannerContent', + + display: 'flex', + flexDirection: 'row', + gap: '$x4', +}); + +const BannerCloseButton = styled(RcxView, { + name: 'BannerCloseButton', + + paddingBlock: 6, + paddingInline: 8, + + variants: { + inline: { + true: { + marginBlock: -4, + paddingBlock: 0, + }, + }, + } as const, +}); + +// Functional component — renders real since styled(Text, { tag: 'a' }) doesn't work +const BannerLink = ({ children, ...props }: { children?: React.ReactNode; href?: string; target?: string }) => ( + {children} +); + export type BannerProps = { actionable?: boolean; closeable?: boolean; @@ -82,45 +261,40 @@ const Banner = ({ const buttonProps = useButtonPattern(handleBannerClick); return ( -
{icon && isIconVisible && ( -
+ {icon} -
+ )} -
+ {title && ( -
{title}
+ {title} )} - {children} - {link && ( - - {linkText} - - )} -
+ + {children} + + {link && ( + + {linkText} + + )} + + {closeable && ( -
+ -
+ )} -
+ ); }; diff --git a/packages/fuselage/src/components/Box/Box.styles.scss b/packages/fuselage/src/components/Box/Box.styles.scss deleted file mode 100644 index 05da250728..0000000000 --- a/packages/fuselage/src/components/Box/Box.styles.scss +++ /dev/null @@ -1,34 +0,0 @@ -.rcx-box { - @extend %box; - - &--animated { - @extend %box--animated; - - &::before, - &::after { - @extend %box--animated; - } - } - - &--full { - @extend %box--full; - - &::before, - &::after { - @extend %box; - @extend %box--full; - } - } - - &--with-inline-elements { - @extend %--with-inline-elements; - } - - &--with-block-elements { - @extend %--with-block-elements; - } - - &--focusable { - @include focus-state(); - } -} diff --git a/packages/fuselage/src/components/Box/BoxTransforms.ts b/packages/fuselage/src/components/Box/BoxTransforms.ts index 2d6d2c782d..6b56849ee2 100644 --- a/packages/fuselage/src/components/Box/BoxTransforms.ts +++ b/packages/fuselage/src/components/Box/BoxTransforms.ts @@ -1,21 +1 @@ -import { createContext, useContext, useMemo } from 'react'; - -export const BoxTransforms = createContext any)>(null); - -export const useBoxTransform = () => useContext(BoxTransforms); - -export const useComposedBoxTransform = (fn: (props: any) => any) => { - const parentFn = useContext(BoxTransforms); - - return useMemo(() => { - if (!parentFn) { - return fn; - } - - if (!fn) { - return parentFn; - } - - return (props: any) => fn(parentFn(props)); - }, [fn, parentFn]); -}; +export { BoxTransforms, useBoxTransform, useComposedBoxTransform } from '@rocket.chat/fuselage-box'; diff --git a/packages/fuselage/src/components/Box/index.ts b/packages/fuselage/src/components/Box/index.ts index 48b8986c46..2da26cafc1 100644 --- a/packages/fuselage/src/components/Box/index.ts +++ b/packages/fuselage/src/components/Box/index.ts @@ -1,2 +1 @@ -export { default as Box, type BoxProps } from './Box'; -export { default as StylingBox, type StylingBoxProps } from './StylingBox'; +export { Box, type BoxProps, StylingBox, type StylingBoxProps } from '@rocket.chat/fuselage-box'; diff --git a/packages/fuselage/src/components/Bubble/Bubble.styles.scss b/packages/fuselage/src/components/Bubble/Bubble.styles.scss deleted file mode 100644 index ec93f4241f..0000000000 --- a/packages/fuselage/src/components/Bubble/Bubble.styles.scss +++ /dev/null @@ -1,84 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -@use '../../styles/variables/buttons.scss' as buttonColors; -@use '../../styles/primitives/button.scss'; -@use '../../styles/mixins/interactivity.scss'; - -.rcx-bubble { - display: flex; - - overflow: hidden; - - align-items: center; - - &__button { - &--primary { - @include button.kind-variant(buttonColors.$primary); - } - - &--secondary { - @include button.kind-variant(buttonColors.$secondary); - } - - @include clickable; - @include click-animation; - } - - &__item { - &--primary { - color: buttonColors.$button-primary-color; - background-color: buttonColors.$button-primary-background-color; - } - - &--secondary { - color: buttonColors.$button-secondary-color; - background-color: buttonColors.$button-secondary-background-color; - } - } - - &__button, - &__item { - @include typography.use-font-scale(c2); - display: flex; - justify-content: center; - align-items: center; - - height: lengths.size(28); - - padding-inline: lengths.padding(12); - padding-inline-end: lengths.padding(16); - - border-radius: lengths.border-radius(extra-large); - column-gap: lengths.padding(8); - - @include typography.use-with-truncated-text; - - > span { - @include typography.use-with-truncated-text; - } - } - - &:not(.rcx-bubble__group) &__item { - padding-inline: lengths.padding(8); - } - - &--small &__button, - &--small &__item { - @include typography.use-font-scale(micro); - height: lengths.size(20); - } - - &__group { - :first-child { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - :last-child { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - } -} diff --git a/packages/fuselage/src/components/Bubble/Bubble.tsx b/packages/fuselage/src/components/Bubble/Bubble.tsx index dc0b88339f..7cde93ed0f 100644 --- a/packages/fuselage/src/components/Bubble/Bubble.tsx +++ b/packages/fuselage/src/components/Bubble/Bubble.tsx @@ -1,8 +1,310 @@ import type { Keys as IconName } from '@rocket.chat/icons'; import type { AllHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'; +import { createStyledContext, styled } from '@tamagui/core'; -import { BubbleButton } from './BubbleButton'; -import { BubbleItem } from './BubbleItem'; +import { RcxInteractive, RcxText, RcxView } from '../../primitives'; +import { Icon } from '../Icon'; + +// --- Styled Context --- + +const BubbleContext = createStyledContext({ + small: false as boolean, + variant: 'primary' as string, +}); + +// --- Styled Components --- + +const BubbleFrame = styled(RcxView, { + name: 'Bubble', + context: BubbleContext, + + display: 'flex', + flexDirection: 'row', + overflow: 'hidden', + alignItems: 'center', +}); + +const BubbleButtonFrame = styled(RcxInteractive, { + name: 'BubbleButton', + context: BubbleContext, + + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + + height: '$x28', + + paddingInline: 12, + paddingInlineEnd: 16, + + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 20, + + columnGap: 8, + + variants: { + variant: { + primary: { + color: '$buttonPrimaryColor', + backgroundColor: '$buttonPrimaryBg', + borderColor: '$buttonPrimaryBorderColor', + + hoverStyle: { + backgroundColor: '$buttonPrimaryHoverBg', + borderColor: '$buttonPrimaryHoverBorderColor', + boxShadow: 'none', + }, + + pressStyle: { + backgroundColor: '$buttonPrimaryPressBg', + borderColor: '$buttonPrimaryPressBorderColor', + boxShadow: 'none', + }, + + focusVisibleStyle: { + backgroundColor: '$buttonPrimaryFocusBg', + borderColor: '$buttonPrimaryFocusBorderColor', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + + disabledStyle: { + backgroundColor: '$buttonPrimaryDisabledBg', + borderColor: '$buttonPrimaryDisabledBorderColor', + color: '$buttonPrimaryDisabledColor', + cursor: 'not-allowed', + }, + }, + secondary: { + color: '$buttonSecondaryColor', + backgroundColor: '$buttonSecondaryBg', + borderColor: '$buttonSecondaryBorderColor', + + hoverStyle: { + backgroundColor: '$buttonSecondaryHoverBg', + borderColor: '$buttonSecondaryHoverBorderColor', + boxShadow: 'none', + }, + + pressStyle: { + backgroundColor: '$buttonSecondaryPressBg', + borderColor: '$buttonSecondaryPressBorderColor', + boxShadow: 'none', + }, + + focusVisibleStyle: { + backgroundColor: '$buttonSecondaryFocusBg', + borderColor: '$buttonSecondaryFocusBorderColor', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + + disabledStyle: { + backgroundColor: '$buttonSecondaryDisabledBg', + borderColor: '$buttonSecondaryDisabledBorderColor', + color: '$buttonSecondaryDisabledColor', + cursor: 'not-allowed', + }, + }, + }, + small: { + true: { + height: '$x20', + }, + }, + } as const, + + defaultVariants: { + variant: 'primary', + }, +}); + +const BubbleButtonText = styled(RcxText, { + name: 'BubbleButtonText', + context: BubbleContext, + + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflowWrap: 'normal', + + color: 'inherit', + + fontFamily: '$body', + fontSize: '$c2', + fontWeight: '$c2', + lineHeight: '$c2', + letterSpacing: '$c2', + + variants: { + small: { + true: { + fontSize: '$micro', + fontWeight: '$micro', + lineHeight: '$micro', + letterSpacing: '$micro', + }, + }, + } as const, +}); + +const BubbleItemFrame = styled(RcxText, { + name: 'BubbleItem', + context: BubbleContext, + + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + + height: '$x28', + + paddingInline: 12, + paddingInlineEnd: 16, + + borderRadius: 20, + + columnGap: 8, + + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflowWrap: 'normal', + + fontFamily: '$body', + fontSize: '$c2', + fontWeight: '$c2', + lineHeight: '$c2', + letterSpacing: '$c2', + + variants: { + variant: { + primary: { + color: '$buttonPrimaryColor', + backgroundColor: '$buttonPrimaryBg', + }, + secondary: { + color: '$buttonSecondaryColor', + backgroundColor: '$buttonSecondaryBg', + }, + }, + small: { + true: { + height: '$x20', + fontSize: '$micro', + fontWeight: '$micro', + lineHeight: '$micro', + letterSpacing: '$micro', + }, + }, + inGroup: { + true: {}, + false: { + paddingInline: 8, + paddingInlineEnd: 8, + }, + }, + } as const, + + defaultVariants: { + variant: 'primary', + inGroup: false, + }, +}); + +const BubbleItemInnerText = styled(RcxText, { + name: 'BubbleItemInnerText', + context: BubbleContext, + + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflowWrap: 'normal', + + color: 'inherit', + + fontFamily: '$body', + fontSize: '$c2', + fontWeight: '$c2', + lineHeight: '$c2', + letterSpacing: '$c2', + + variants: { + small: { + true: { + fontSize: '$micro', + fontWeight: '$micro', + lineHeight: '$micro', + letterSpacing: '$micro', + }, + }, + } as const, +}); + +// --- Sub-components --- + +type BubbleButtonProps = { + onClick: () => void; + label?: ReactNode; + secondary?: boolean; + icon?: IconName; + isGroupFirst?: boolean; + isGroupLast?: boolean; +} & Omit, 'onClick'>; + +const BubbleButton = ({ + secondary, + label, + onClick, + icon, + isGroupFirst, + isGroupLast, + ...props +}: BubbleButtonProps) => ( + + {icon && } + {label && {label}} + +); + +type BubbleItemProps = { + label?: ReactNode; + secondary?: boolean; + icon?: IconName; + inGroup?: boolean; +}; + +const BubbleItem = ({ + secondary, + label, + icon, + inGroup, + ...props +}: BubbleItemProps) => ( + + {icon && } + {label && {label}} + +); + +// --- Main component --- export type BubbleProps = { secondary?: boolean; @@ -25,42 +327,40 @@ const Bubble = ({ contentProps, dismissProps, ...props -}: BubbleProps) => ( -
- {onClick ? ( - - ) : ( - - )} - {onDismiss && ( - - )} -
-); +}: BubbleProps) => { + const hasGroup = !!onDismiss; + + return ( + + {onClick ? ( + + ) : ( + + )} + {onDismiss && ( + + )} + + ); +}; export default Bubble; diff --git a/packages/fuselage/src/components/Bubble/BubbleButton.tsx b/packages/fuselage/src/components/Bubble/BubbleButton.tsx deleted file mode 100644 index f826189aa9..0000000000 --- a/packages/fuselage/src/components/Bubble/BubbleButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Keys as IconName } from '@rocket.chat/icons'; -import type { ButtonHTMLAttributes, ReactNode } from 'react'; - -import { Icon } from '../Icon'; - -type BubbleButtonProps = { - onClick: () => void; - label?: ReactNode; - secondary?: boolean; - icon?: IconName; -} & Omit, 'onClick'>; - -export const BubbleButton = ({ - secondary, - label, - onClick, - icon, - ...props -}: BubbleButtonProps) => ( - -); diff --git a/packages/fuselage/src/components/Bubble/BubbleItem.tsx b/packages/fuselage/src/components/Bubble/BubbleItem.tsx deleted file mode 100644 index 0f58598653..0000000000 --- a/packages/fuselage/src/components/Bubble/BubbleItem.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Keys as IconName } from '@rocket.chat/icons'; -import type { HTMLAttributes, ReactNode } from 'react'; - -import { Icon } from '../Icon'; - -type BubbleItemProps = { - label?: ReactNode; - secondary?: boolean; - icon?: IconName; -} & HTMLAttributes; - -export const BubbleItem = ({ - secondary, - label, - icon, - ...props -}: BubbleItemProps) => ( - - {icon && } - {label && {label}} - -); diff --git a/packages/fuselage/src/components/Button/Button.styles.scss b/packages/fuselage/src/components/Button/Button.styles.scss deleted file mode 100644 index d5401ee067..0000000000 --- a/packages/fuselage/src/components/Button/Button.styles.scss +++ /dev/null @@ -1,217 +0,0 @@ -@use 'sass:map'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; -@use '../../styles/variables/buttons.scss' as colors; -@use '../../styles/primitives/button.scss'; -@use '../../styles/mixins/size.scss'; -@use '../../styles/mixins/interactivity.scss'; -@import '../../styles/mixins/states.scss'; - -.rcx-button { - @mixin with-rectangular-size($height, $padding-x, $line-height) { - min-width: calc(lengths.size($height) * 2); - height: lengths.size($height); - padding: calc((lengths.padding($height) - $line-height) / 2 - 2px) - calc(lengths.padding($padding-x) - 2px); - padding-block: calc((lengths.padding($height) - $line-height) / 2 - 2px); - - padding-inline: calc(lengths.padding($padding-x) - 2px); - } - - @mixin with-squared-size($size) { - width: lengths.size($size); - min-width: lengths.size($size); - height: lengths.size($size); - padding: 0; - - &::before, - &::after { - display: inline-block; - - height: 100%; - - content: ''; - } - } - - display: inline-block; - - text-align: center; - white-space: nowrap; - text-decoration: none; - - @include click-animation($excludeRole: 'status'); - - .rcx-button--content { - display: inline-block; - - width: 100%; - - vertical-align: top; - - @include typography.use-with-truncated-text(); - } - - @include clickable; - @include typography.use-font-scale(p2m); - @include typography.use-text-ellipsis; - - @include with-rectangular-size( - $height: 40, - $padding-x: 16, - $line-height: typography.line-height(p2) - ); - - @include button.kind-variant(colors.$secondary); - - &--loading { - .rcx-icon--name-loading { - animation: spin-animation 0.8s linear infinite; - } - } - - &--small { - @include typography.use-font-scale(c2); - - @include with-rectangular-size( - $height: 28, - $padding-x: 8, - $line-height: typography.line-height(c1) - ); - } - - &--medium { - @include typography.use-font-scale(c2); - - @include with-rectangular-size( - $height: 32, - $padding-x: 12, - $line-height: typography.line-height(c1) - ); - } - - &--large { - @include typography.use-font-scale(p2); - - @include with-rectangular-size( - $height: 48, - $padding-x: 24, - $line-height: typography.line-height(p2) - ); - } - - &--square { - @include with-squared-size($size: 40); - display: flex; - justify-content: center; - align-items: center; - flex-shrink: 0; - } - - &--icon { - @include button.kind-variant(colors.$icon); - @include click-animation('status'); - - padding: 0; - - line-height: 0; - - &-secondary { - @include button.kind-variant(colors.$secondary); - } - - &-info { - @include button.kind-variant(colors.$icon-info); - } - - &-success { - @include button.kind-variant(colors.$icon-success); - } - - &-warning { - @include button.kind-variant(colors.$icon-warning); - } - - &-danger { - @include button.kind-variant(colors.$icon-danger); - } - - &-secondary-info { - @include button.kind-variant(colors.$primary); - } - - &-secondary-success { - @include button.kind-variant(colors.$success); - } - - &-secondary-warning { - @include button.kind-variant(colors.$warning); - } - - &-secondary-danger { - @include button.kind-variant(colors.$danger); - } - } - - &--mini-square { - @include with-squared-size($size: 20); - } - - &--tiny-square { - @include with-squared-size($size: 24); - } - - &--small-square { - @include with-squared-size($size: 28); - } - - &--medium-square { - @include with-squared-size($size: 32); - } - - &--large-square { - @include with-squared-size($size: 40); - } - - &--primary { - @include button.kind-variant(colors.$primary); - } - - &--secondary { - @include button.kind-variant(colors.$secondary); - } - - &--secondary-danger { - @include button.kind-variant(colors.$secondary-danger); - } - - &--danger { - @include button.kind-variant(colors.$danger); - } - - &--warning { - @include button.kind-variant(colors.$warning); - } - - &--secondary-warning { - @include button.kind-variant(colors.$secondary-warning); - } - - &--success { - @include button.kind-variant(colors.$success); - } - - &--secondary-success { - @include button.kind-variant(colors.$secondary-success); - } -} - -@keyframes spin-animation { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} diff --git a/packages/fuselage/src/components/Button/Button.tsx b/packages/fuselage/src/components/Button/Button.tsx index 37853eb4ee..c64979cd4a 100644 --- a/packages/fuselage/src/components/Button/Button.tsx +++ b/packages/fuselage/src/components/Button/Button.tsx @@ -1,9 +1,345 @@ import type { AllHTMLAttributes } from 'react'; import { forwardRef, useMemo } from 'react'; -import { Box, type BoxProps } from '../Box'; +import { styled, createStyledContext, Text } from '@tamagui/core'; + +import { RcxInteractive, RcxText } from '../../primitives'; +import type { BoxProps } from '../Box'; import { Icon, type IconProps } from '../Icon'; +// --- Context for compound component --- + +const ButtonContext = createStyledContext({ + size: 'default' as string, +}); + +// --- Frame: layout + size (no color) --- + +const ButtonFrame = styled(RcxInteractive, { + name: 'Button', + context: ButtonContext, + role: 'button', + + display: 'inline-flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + + textAlign: 'center', + whiteSpace: 'nowrap', + textDecoration: 'none', + + borderWidth: 1, + borderStyle: 'solid', + borderRadius: '$x4', + + // default (no color variant) = secondary + backgroundColor: '$buttonSecondaryBg', + borderColor: '$buttonSecondaryBg', + color: '$buttonSecondaryColor', + + hoverStyle: { + backgroundColor: '$buttonSecondaryHoverBg', + borderColor: '$buttonSecondaryHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSecondaryPressBg', + borderColor: '$buttonSecondaryPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSecondaryFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + disabledStyle: { + backgroundColor: '$buttonSecondaryDisabledBg', + borderColor: '$buttonSecondaryDisabledBg', + color: '$buttonSecondaryDisabledColor', + cursor: 'not-allowed', + }, + + variants: { + size: { + default: { + height: '$x40', + paddingInline: 14, // 16 - 2*1px border + minWidth: 80, // 40 * 2 + }, + small: { + height: '$x28', + paddingInline: 6, // 8 - 2*1px border + minWidth: 56, // 28 * 2 + }, + medium: { + height: '$x32', + paddingInline: 10, // 12 - 2*1px border + minWidth: 64, // 32 * 2 + }, + large: { + height: '$x48', + paddingInline: 22, // 24 - 2*1px border + minWidth: 96, // 48 * 2 + }, + }, + } as const, + + defaultVariants: { + size: 'default', + }, +}); + +// --- Color variant frames --- + +const PrimaryButton = styled(ButtonFrame, { + backgroundColor: '$buttonPrimaryBg', + borderColor: '$buttonPrimaryBg', + color: '$buttonPrimaryColor', + + hoverStyle: { + backgroundColor: '$buttonPrimaryHoverBg', + borderColor: '$buttonPrimaryHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonPrimaryPressBg', + borderColor: '$buttonPrimaryPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonPrimaryFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + disabledStyle: { + backgroundColor: '$buttonPrimaryDisabledBg', + borderColor: '$buttonPrimaryDisabledBg', + color: '$buttonPrimaryDisabledColor', + cursor: 'not-allowed', + }, +}); + +const DangerButton = styled(ButtonFrame, { + backgroundColor: '$buttonDangerBg', + borderColor: '$buttonDangerBg', + color: '$buttonDangerColor', + + hoverStyle: { + backgroundColor: '$buttonDangerHoverBg', + borderColor: '$buttonDangerHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonDangerPressBg', + borderColor: '$buttonDangerPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonDangerFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeExtraLightError)', + }, + disabledStyle: { + backgroundColor: '$buttonDangerDisabledBg', + borderColor: '$buttonDangerDisabledBg', + color: '$buttonDangerDisabledColor', + cursor: 'not-allowed', + }, +}); + +const WarningButton = styled(ButtonFrame, { + backgroundColor: '$buttonWarningBg', + borderColor: '$buttonWarningBg', + color: '$buttonWarningColor', + + hoverStyle: { + backgroundColor: '$buttonWarningHoverBg', + borderColor: '$buttonWarningHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonWarningPressBg', + borderColor: '$buttonWarningPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonWarningFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: '$buttonWarningDisabledBg', + borderColor: '$buttonWarningDisabledBg', + color: '$buttonWarningDisabledColor', + cursor: 'not-allowed', + }, +}); + +const SuccessButton = styled(ButtonFrame, { + backgroundColor: '$buttonSuccessBg', + borderColor: '$buttonSuccessBg', + color: '$buttonSuccessColor', + + hoverStyle: { + backgroundColor: '$buttonSuccessHoverBg', + borderColor: '$buttonSuccessHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSuccessPressBg', + borderColor: '$buttonSuccessPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSuccessFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: '$buttonSuccessDisabledBg', + borderColor: '$buttonSuccessDisabledBg', + color: '$buttonSuccessDisabledColor', + cursor: 'not-allowed', + }, +}); + +const SecondaryDangerButton = styled(ButtonFrame, { + backgroundColor: '$buttonSecondaryDangerBg', + borderColor: '$buttonSecondaryDangerBg', + color: '$buttonSecondaryDangerColor', + + hoverStyle: { + backgroundColor: '$buttonSecondaryDangerHoverBg', + borderColor: '$buttonSecondaryDangerHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSecondaryDangerPressBg', + borderColor: '$buttonSecondaryDangerPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSecondaryDangerFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeExtraLightError)', + }, + disabledStyle: { + backgroundColor: '$buttonSecondaryDangerDisabledBg', + borderColor: '$buttonSecondaryDangerDisabledBg', + color: '$buttonSecondaryDangerDisabledColor', + cursor: 'not-allowed', + }, +}); + +const SecondaryWarningButton = styled(ButtonFrame, { + backgroundColor: '$buttonSecondaryWarningBg', + borderColor: '$buttonSecondaryWarningBg', + color: '$buttonSecondaryWarningColor', + + hoverStyle: { + backgroundColor: '$buttonSecondaryWarningHoverBg', + borderColor: '$buttonSecondaryWarningHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSecondaryWarningPressBg', + borderColor: '$buttonSecondaryWarningPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSecondaryWarningFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: '$buttonSecondaryWarningDisabledBg', + borderColor: '$buttonSecondaryWarningDisabledBg', + color: '$buttonSecondaryWarningDisabledColor', + cursor: 'not-allowed', + }, +}); + +const SecondarySuccessButton = styled(ButtonFrame, { + backgroundColor: '$buttonSecondarySuccessBg', + borderColor: '$buttonSecondarySuccessBg', + color: '$buttonSecondarySuccessColor', + + hoverStyle: { + backgroundColor: '$buttonSecondarySuccessHoverBg', + borderColor: '$buttonSecondarySuccessHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSecondarySuccessPressBg', + borderColor: '$buttonSecondarySuccessPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSecondarySuccessFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: '$buttonSecondarySuccessDisabledBg', + borderColor: '$buttonSecondarySuccessDisabledBg', + color: '$buttonSecondarySuccessDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Text sub-component --- + +const ButtonText = styled(RcxText, { + name: 'ButtonText', + context: ButtonContext, + + display: 'inline-block', + width: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflowWrap: 'normal', + + color: 'inherit', + fontFamily: '$body', + + variants: { + size: { + default: { + fontSize: '$p2m', + fontWeight: '$p2m', + lineHeight: '$p2m', + letterSpacing: '$p2m', + }, + small: { + fontSize: '$c2', + fontWeight: '$c2', + lineHeight: '$c2', + letterSpacing: '$c2', + }, + medium: { + fontSize: '$c2', + fontWeight: '$c2', + lineHeight: '$c2', + letterSpacing: '$c2', + }, + large: { + fontSize: '$p2', + fontWeight: '$p2', + lineHeight: '$p2', + letterSpacing: '$p2', + }, + }, + } as const, + + defaultVariants: { + size: 'default', + }, +}); + +// --- Public props --- + export type ButtonProps = BoxProps & { primary?: boolean; secondary?: boolean; @@ -25,6 +361,24 @@ export type ButtonProps = BoxProps & { 'is' | 'className' | 'size' >; +// --- Spin animation keyframes (injected via CSS) --- +const spinKeyframes = ` +@keyframes rcx-spin-animation { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +`; + +// Inject keyframes once +let keyframesInjected = false; +const injectKeyframes = () => { + if (keyframesInjected || typeof document === 'undefined') return; + const style = document.createElement('style'); + style.textContent = spinKeyframes; + document.head.appendChild(style); + keyframesInjected = true; +}; + /** * Indicates an actionable user action. */ @@ -53,6 +407,10 @@ const Button = forwardRef( }, ref, ) { + if (loading) { + injectKeyframes(); + } + const extraProps = (is === 'a' && { rel: external ? 'noopener noreferrer' : undefined, @@ -63,53 +421,94 @@ const Button = forwardRef( }) || {}; - const kindAndVariantProps = useMemo(() => { - const variant = - (primary && 'primary') || - (secondary && success && 'secondary-success') || - (secondary && warning && 'secondary-warning') || - (secondary && danger && 'secondary-danger') || - (success && 'success') || - (warning && 'warning') || - (danger && 'danger') || - (secondary && 'secondary'); - - if (variant) { - return { - [`rcx-button--${[variant].filter(Boolean).join('-')}`]: true, - }; - } - - return {}; + // Determine color variant frame + const Frame = useMemo(() => { + if (primary) return PrimaryButton; + if (secondary && success) return SecondarySuccessButton; + if (secondary && warning) return SecondaryWarningButton; + if (secondary && danger) return SecondaryDangerButton; + if (success) return SuccessButton; + if (warning) return WarningButton; + if (danger) return DangerButton; + // secondary or default + return ButtonFrame; }, [primary, secondary, danger, warning, success]); + // Determine size + const size = small + ? 'small' + : medium + ? 'medium' + : large + ? 'large' + : 'default'; + + // Square sizes (for icon-only buttons used via Button square) + const squareStyles = square + ? { + width: + mini + ? 20 + : tiny + ? 24 + : small + ? 28 + : medium + ? 32 + : 40, + minWidth: + mini + ? 20 + : tiny + ? 24 + : small + ? 28 + : medium + ? 32 + : 40, + height: + mini + ? 20 + : tiny + ? 24 + : small + ? 28 + : medium + ? 32 + : 40, + paddingInline: 0, + paddingBlock: 0, + flexShrink: 0 as const, + } + : {}; + return ( - - - {icon && !loading && } - {loading && } + + {icon && !loading && ( + + )} + {loading && ( + + )} {children} - - + + ); }, ); diff --git a/packages/fuselage/src/components/Button/IconButton.tsx b/packages/fuselage/src/components/Button/IconButton.tsx index 15ec01fb5f..475a4d9397 100644 --- a/packages/fuselage/src/components/Button/IconButton.tsx +++ b/packages/fuselage/src/components/Button/IconButton.tsx @@ -2,9 +2,276 @@ import type { Keys as IconName } from '@rocket.chat/icons'; import type { ReactElement } from 'react'; import { isValidElement, useMemo, forwardRef } from 'react'; -import { Box, type BoxProps } from '../Box'; +import { styled } from '@tamagui/core'; + +import { RcxInteractive } from '../../primitives'; +import type { BoxProps } from '../Box'; import { Icon, type IconProps } from '../Icon'; +// --- IconButton base frame --- +// icon variant: transparent bg, secondary color, secondary hover/active bg + +const IconButtonFrame = styled(RcxInteractive, { + name: 'IconButton', + role: 'button', + + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'transparent', + borderRadius: '$x4', + + backgroundColor: 'transparent', + color: '$buttonSecondaryColor', + + hoverStyle: { + backgroundColor: '$buttonSecondaryHoverBg', + borderColor: '$buttonSecondaryHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSecondaryPressBg', + borderColor: '$buttonSecondaryPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + disabledStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '$buttonSecondaryDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Icon variant: info --- +const IconInfoButton = styled(IconButtonFrame, { + color: '$statusFontOnInfo', + disabledStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '$buttonPrimaryDisabledBg', + cursor: 'not-allowed', + }, +}); + +// --- Icon variant: success --- +const IconSuccessButton = styled(IconButtonFrame, { + color: '$statusFontOnSuccess', + focusVisibleStyle: { + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '$buttonSuccessDisabledBg', + cursor: 'not-allowed', + }, +}); + +// --- Icon variant: warning --- +const IconWarningButton = styled(IconButtonFrame, { + color: '$statusFontOnWarning', + focusVisibleStyle: { + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '$buttonWarningDisabledBg', + cursor: 'not-allowed', + }, +}); + +// --- Icon variant: danger --- +const IconDangerButton = styled(IconButtonFrame, { + color: '$statusFontOnDanger', + focusVisibleStyle: { + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeExtraLightError)', + }, + disabledStyle: { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: '$buttonDangerDisabledBg', + cursor: 'not-allowed', + }, +}); + +// --- Icon variant: secondary (filled secondary bg) --- +const IconSecondaryButton = styled(IconButtonFrame, { + backgroundColor: '$buttonSecondaryBg', + borderColor: '$buttonSecondaryBg', + color: '$buttonSecondaryColor', + + hoverStyle: { + backgroundColor: '$buttonSecondaryHoverBg', + borderColor: '$buttonSecondaryHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSecondaryPressBg', + borderColor: '$buttonSecondaryPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSecondaryFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + disabledStyle: { + backgroundColor: '$buttonSecondaryDisabledBg', + borderColor: '$buttonSecondaryDisabledBg', + color: '$buttonSecondaryDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Icon secondary-info = primary button colors --- +const IconSecondaryInfoButton = styled(IconButtonFrame, { + backgroundColor: '$buttonPrimaryBg', + borderColor: '$buttonPrimaryBg', + color: '$buttonPrimaryColor', + + hoverStyle: { + backgroundColor: '$buttonPrimaryHoverBg', + borderColor: '$buttonPrimaryHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonPrimaryPressBg', + borderColor: '$buttonPrimaryPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonPrimaryFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + disabledStyle: { + backgroundColor: '$buttonPrimaryDisabledBg', + borderColor: '$buttonPrimaryDisabledBg', + color: '$buttonPrimaryDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Icon secondary-success = success button colors --- +const IconSecondarySuccessButton = styled(IconButtonFrame, { + backgroundColor: '$buttonSuccessBg', + borderColor: '$buttonSuccessBg', + color: '$buttonSuccessColor', + + hoverStyle: { + backgroundColor: '$buttonSuccessHoverBg', + borderColor: '$buttonSuccessHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonSuccessPressBg', + borderColor: '$buttonSuccessPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonSuccessFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: '$buttonSuccessDisabledBg', + borderColor: '$buttonSuccessDisabledBg', + color: '$buttonSuccessDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Icon secondary-warning = warning button colors --- +const IconSecondaryWarningButton = styled(IconButtonFrame, { + backgroundColor: '$buttonWarningBg', + borderColor: '$buttonWarningBg', + color: '$buttonWarningColor', + + hoverStyle: { + backgroundColor: '$buttonWarningHoverBg', + borderColor: '$buttonWarningHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonWarningPressBg', + borderColor: '$buttonWarningPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonWarningFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeLight)', + }, + disabledStyle: { + backgroundColor: '$buttonWarningDisabledBg', + borderColor: '$buttonWarningDisabledBg', + color: '$buttonWarningDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Icon secondary-danger = danger button colors --- +const IconSecondaryDangerButton = styled(IconButtonFrame, { + backgroundColor: '$buttonDangerBg', + borderColor: '$buttonDangerBg', + color: '$buttonDangerColor', + + hoverStyle: { + backgroundColor: '$buttonDangerHoverBg', + borderColor: '$buttonDangerHoverBg', + boxShadow: 'none', + }, + pressStyle: { + backgroundColor: '$buttonDangerPressBg', + borderColor: '$buttonDangerPressBg', + boxShadow: 'none', + }, + focusVisibleStyle: { + backgroundColor: '$buttonDangerFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--strokeExtraLightError)', + }, + disabledStyle: { + backgroundColor: '$buttonDangerDisabledBg', + borderColor: '$buttonDangerDisabledBg', + color: '$buttonDangerDisabledColor', + cursor: 'not-allowed', + }, +}); + +// --- Size map --- + +const SQUARE_SIZES = { + mini: 20, + tiny: 24, + small: 28, + medium: 32, + large: 40, +} as const; + +const ICON_SIZES = { + mini: 'x12', + tiny: 'x16', + small: 'x20', + medium: 'x24', + large: 'x28', +} as const; + +// --- Public types --- + type ButtonSize = { large?: boolean; medium?: boolean; @@ -25,23 +292,6 @@ export type IconButtonProps = { } & ButtonSize & BoxProps; -const getVariantClass = (variant: string) => { - if (variant) { - const variantClass = [ - `rcx-button--icon-${[variant].filter(Boolean).join('-')}`, - ]; - return variantClass; - } - return ['']; -}; - -const getPressedClass = (variant: string) => { - const variantClass = [ - `rcx-button--icon-${[variant].filter(Boolean).join('-')}-pressed`, - ]; - return variantClass; -}; - const IconButton = forwardRef( ( { @@ -63,74 +313,58 @@ const IconButton = forwardRef( }, ref, ) => { - const variant = useMemo( - () => - (secondary && danger && 'secondary-danger') || - (secondary && warning && 'secondary-warning') || - (secondary && success && 'secondary-success') || - (secondary && info && 'secondary-info') || - (info && 'info') || - (success && 'success') || - (warning && 'warning') || - (danger && 'danger') || - (primary && 'secondary-info') || - (secondary && 'secondary') || - '', - [danger, info, primary, secondary, success, warning], - ); + const Frame = useMemo(() => { + if (secondary && danger) return IconSecondaryDangerButton; + if (secondary && warning) return IconSecondaryWarningButton; + if (secondary && success) return IconSecondarySuccessButton; + if (secondary && info) return IconSecondaryInfoButton; + if (info) return IconInfoButton; + if (success) return IconSuccessButton; + if (warning) return IconWarningButton; + if (danger) return IconDangerButton; + if (primary) return IconSecondaryInfoButton; + if (secondary) return IconSecondaryButton; + return IconButtonFrame; + }, [danger, info, primary, secondary, success, warning]); - const kindAndVariantProps = useMemo(() => { - const variantProp = {} as any; - if (variant) { - variantProp[`${getVariantClass(variant)}`] = true; - } - if (pressed) { - variantProp[`${getPressedClass(variant)}`] = true; - } - return variantProp; - }, [variant, pressed]); - - const size = useMemo( - () => - (mini && 'mini') || - (tiny && 'tiny') || - (small && 'small') || - (medium && 'medium') || - (large && 'large') || - 'large', - [medium, mini, small, tiny, large], - ); + const sizeName = mini + ? 'mini' + : tiny + ? 'tiny' + : small + ? 'small' + : medium + ? 'medium' + : 'large'; - const getSizeClass = () => ({ [`rcx-button--${size}-square`]: true }); + const squareSize = SQUARE_SIZES[sizeName]; + const iconSize = ICON_SIZES[sizeName]; - const getIconSize = () => - (large && 'x28') || - (medium && 'x24') || - (small && 'x20') || - (tiny && 'x16') || - (mini && 'x12') || - 'x28'; + // Pressed state: apply active bg/border + const pressedStyles = pressed + ? { + backgroundColor: '$buttonSecondaryPressBg', + borderColor: '$buttonSecondaryPressBg', + } + : {}; return ( - {isValidElement(icon) ? ( icon ) : ( - + )} {children} - + ); }, ); diff --git a/packages/fuselage/src/components/ButtonGroup/ButtonGroup.styles.scss b/packages/fuselage/src/components/ButtonGroup/ButtonGroup.styles.scss deleted file mode 100644 index dba2a830fb..0000000000 --- a/packages/fuselage/src/components/ButtonGroup/ButtonGroup.styles.scss +++ /dev/null @@ -1,50 +0,0 @@ -@use '../../styles/lengths.scss'; - -.rcx-button-group { - display: flex; - - flex-flow: row nowrap; - justify-content: flex-start; - - align-items: center; - - gap: lengths.margin(8); - - &--small { - gap: lengths.margin(4); - } - - &--large { - gap: lengths.margin(16); - } - - &--wrap { - flex-wrap: wrap; - } - - &--stretch { - align-items: stretch; - - flex-grow: 1; - - > * { - flex-grow: 1; - } - } - - &--vertical { - flex-direction: column; - } - - &--align-start { - justify-content: flex-start; - } - - &--align-center { - justify-content: center; - } - - &--align-end { - justify-content: flex-end; - } -} diff --git a/packages/fuselage/src/components/ButtonGroup/ButtonGroup.tsx b/packages/fuselage/src/components/ButtonGroup/ButtonGroup.tsx index 30ae4bddf3..3081537415 100644 --- a/packages/fuselage/src/components/ButtonGroup/ButtonGroup.tsx +++ b/packages/fuselage/src/components/ButtonGroup/ButtonGroup.tsx @@ -1,5 +1,71 @@ -import type { HTMLAttributes } from 'react'; -import { forwardRef } from 'react'; +import type { ReactNode } from 'react'; +import { Children, forwardRef } from 'react'; +import { styled, createStyledContext, type GetProps } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; + +const ButtonGroupContext = createStyledContext({ + stretch: false as boolean, +}); + +const ButtonGroupFrame = styled(RcxView, { + name: 'ButtonGroup', + context: ButtonGroupContext, + role: 'group', + + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'flex-start', + alignItems: 'center', + gap: '$x8', + + variants: { + wrap: { + true: { + flexWrap: 'wrap', + }, + }, + + stretch: { + true: { + justifyContent: 'stretch', + alignItems: 'stretch', + flexGrow: 1, + }, + }, + + vertical: { + true: { + flexDirection: 'column', + }, + }, + + align: { + start: { + justifyContent: 'flex-start', + }, + center: { + justifyContent: 'center', + }, + end: { + justifyContent: 'flex-end', + }, + }, + + small: { + true: { + gap: '$x4', + }, + }, + + large: { + true: { + gap: '$x16', + }, + }, + } as const, +}); export type ButtonGroupProps = { align?: 'start' | 'center' | 'end'; @@ -8,7 +74,12 @@ export type ButtonGroupProps = { vertical?: boolean; small?: boolean; large?: boolean; -} & HTMLAttributes; + children?: ReactNode; + className?: string; +} & Omit< + GetProps, + 'align' | 'stretch' | 'wrap' | 'vertical' | 'small' | 'large' +>; /** * A container for grouping buttons that semantically share a common action context. @@ -23,31 +94,23 @@ const ButtonGroup = forwardRef( wrap, small, large, - className, ...props }, ref, ) { return ( -
{children} -
+ ); }, ); diff --git a/packages/fuselage/src/components/Callout/Callout.styles.scss b/packages/fuselage/src/components/Callout/Callout.styles.scss deleted file mode 100644 index e842ad1870..0000000000 --- a/packages/fuselage/src/components/Callout/Callout.styles.scss +++ /dev/null @@ -1,124 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -$callout-background-color: theme( - 'callout-background-color', - colors.surface(light) -); -$callout-default-color: theme( - 'callout-default-color', - colors.font(secondary-info) -); -$callout-info-color: theme('callout-info-color', colors.status-font(on-info)); -$callout-success-color: theme( - 'callout-success-color', - colors.status-font(on-success) -); -$callout-warning-color: theme( - 'callout-warning-color', - colors.status-font(on-warning) -); -$callout-danger-color: theme( - 'callout-danger-color', - colors.status-font(on-danger) -); -$callout-text-color: theme('callout-text-color', colors.font(default)); - -.rcx-callout { - display: flex; - - padding: lengths.padding(12); - - color: $callout-text-color; - - border-width: lengths.border-width(default); - border-style: solid; - border-color: $callout-default-color; - - border-radius: theme('callout-border-radius', lengths.border-radius(medium)); - - background-color: $callout-background-color; - - &--info { - border-color: $callout-info-color; - - .rcx-callout__icon { - color: $callout-info-color; - } - } - - &--success { - border-color: $callout-success-color; - - .rcx-callout__icon { - color: $callout-success-color; - } - } - - &--warning { - border-color: $callout-warning-color; - - .rcx-callout__icon { - color: $callout-warning-color; - } - } - - &--danger { - border-color: $callout-danger-color; - - .rcx-callout__icon { - color: $callout-danger-color; - } - } - - &__wrapper { - overflow: hidden; - - justify-content: space-between; - - flex: 1 1 0; - - margin-inline-start: lengths.margin(12); - - > :nth-child(2) { - margin-block-start: lengths.margin(12); - } - - &--large { - display: flex; - - overflow: hidden; - flex-direction: row; - align-items: center; - - > :nth-child(2) { - margin-block-start: lengths.margin(0); - } - } - } - - &__wrapper-content { - display: flex; - - overflow: hidden; - flex-flow: column nowrap; - - > :nth-child(2) { - margin-block-start: lengths.margin(4); - } - } - - &__title { - white-space: nowrap; - - @include typography.use-font-scale(p2b); - @include typography.use-text-ellipsis; - } - - &__content { - display: block; - - @include typography.use-font-scale(p2); - } -} diff --git a/packages/fuselage/src/components/Callout/Callout.tsx b/packages/fuselage/src/components/Callout/Callout.tsx index 9d776eea57..d7fe2b8f93 100644 --- a/packages/fuselage/src/components/Callout/Callout.tsx +++ b/packages/fuselage/src/components/Callout/Callout.tsx @@ -1,15 +1,129 @@ import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; import type { ReactElement, ReactNode } from 'react'; +import { styled, Text } from '@tamagui/core'; -import { Box, type BoxProps } from '../Box'; +import { RcxView, RcxText } from '../../primitives'; import { Icon, type IconProps } from '../Icon'; -export type CalloutProps = Omit & { +// Outer section — RcxView for layout +const CalloutBase = styled(RcxView, { + name: 'CalloutBase', + + display: 'flex', + flexDirection: 'row', + + padding: '$x12', + + borderWidth: 1, + borderStyle: 'solid', + // default border: colors.font(secondary-info) + borderColor: '$fontSecondaryInfo', + borderRadius: '$x4', + + // bg: colors.surface(light) + backgroundColor: '$surfaceLight', + + variants: { + type: { + info: { borderColor: '$statusFontOnInfo' }, + success: { borderColor: '$statusFontOnSuccess' }, + warning: { borderColor: '$statusFontOnWarning' }, + danger: { borderColor: '$statusFontOnDanger' }, + }, + } as const, +}); + +// Icon wrapper — color follows type +const CalloutIcon = styled(RcxView, { + name: 'CalloutIcon', + + variants: { + type: { + info: { color: '$statusFontOnInfo' }, + success: { color: '$statusFontOnSuccess' }, + warning: { color: '$statusFontOnWarning' }, + danger: { color: '$statusFontOnDanger' }, + }, + } as const, +}); + +// Wrapper — flex:1 1 0, overflow hidden, margin-inline-start 12 +const CalloutWrapper = styled(RcxView, { + name: 'CalloutWrapper', + + display: 'block', + overflow: 'hidden', + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + + marginInlineStart: '$x12', + + variants: { + large: { + true: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + overflow: 'hidden', + }, + }, + } as const, +}); + +// Wrapper content — flex column +const CalloutWrapperContent = styled(RcxView, { + name: 'CalloutWrapperContent', + + display: 'flex', + overflow: 'hidden', + flexDirection: 'column', + gap: '$x4', +}); + +// Title — p2b font scale, text ellipsis +const CalloutTitle = styled(RcxText, { + name: 'CalloutTitle', + + display: 'block', + width: '100%' as any, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + + fontFamily: '$body', + fontSize: '$p2b', + fontWeight: '$p2b', + lineHeight: '$p2b', + letterSpacing: '$p2b', + + // text color: colors.font(default) + color: '$fontDefault', +}); + +// Content — p2 font scale, display block +const CalloutContent = styled(RcxText, { + name: 'CalloutContent', + + display: 'block', + + fontFamily: '$body', + fontSize: '$p2', + fontWeight: '$p2', + lineHeight: '$p2', + letterSpacing: '$p2', + + // text color: colors.font(default) + color: '$fontDefault', +}); + +export type CalloutProps = { type?: 'info' | 'success' | 'warning' | 'danger'; title?: ReactNode; children?: ReactNode; icon?: IconProps['name']; actions?: ReactElement; + [key: string]: any; }; const WRAPPER_LIMIT_SIZE = 420; @@ -22,7 +136,6 @@ const Callout = ({ title, children, icon, - className, actions, ...props }: CalloutProps) => { @@ -38,27 +151,18 @@ const Callout = ({ 'info-circled'; return ( - - - - - {title && {title}} - {children && {children}} - - {actions && {actions}} - - + + + + + + + {title && {title}} + {children && {children}} + + {actions} + + ); }; diff --git a/packages/fuselage/src/components/Card/Card.styles.scss b/packages/fuselage/src/components/Card/Card.styles.scss deleted file mode 100644 index a8916301be..0000000000 --- a/packages/fuselage/src/components/Card/Card.styles.scss +++ /dev/null @@ -1,86 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/functions'; -@use '../../styles/lengths.scss'; - -$card-spacing: lengths.margin(8); -$card-vertical-padding: lengths.padding(20); -$card-vertical-gap: lengths.margin(24); -$card-horizontal-padding: lengths.padding(12); -$card-horizontal-gap: lengths.padding(16); -$card-horizontal-row-gap: lengths.padding(4); -$card-hero-padding: lengths.padding(28); - -.rcx-card { - display: flex; - - color: functions.theme('card-color', colors.font(default)); - border-radius: lengths.border-radius(large); - - background-color: functions.theme( - 'card-background-color', - colors.surface(light) - ); - - &__clickable { - &:hover, - &:focus { - cursor: pointer; - - outline: 0; - background-color: colors.surface(hover); - } - } - - &__header, - &__title, - &__controls, - &__body, - &__row, - &__col { - gap: $card-spacing; - } - - &__col { - display: flex; - flex-direction: column; - } - - &__row { - flex-grow: 1; - flex-shrink: 1; - } - - &__horizontal { - align-items: center; - - padding: $card-horizontal-padding; - gap: $card-horizontal-gap; - - &--wrap { - flex-wrap: wrap; - } - } - - &__horizontal &__col { - row-gap: $card-horizontal-row-gap; - } - - &__vertical { - flex-direction: column; - - padding: $card-vertical-padding; - gap: $card-vertical-gap; - } - - &__hero { - padding: $card-hero-padding; - } - - &__title, - &__row, - &__header, - &__controls { - display: flex; - align-items: center; - } -} diff --git a/packages/fuselage/src/components/Card/Card.tsx b/packages/fuselage/src/components/Card/Card.tsx index f0bf412224..52e2c13113 100644 --- a/packages/fuselage/src/components/Card/Card.tsx +++ b/packages/fuselage/src/components/Card/Card.tsx @@ -1,7 +1,103 @@ import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { AllHTMLAttributes } from 'react'; +import { styled, createStyledContext } from '@tamagui/core'; -import { Box } from '../Box'; +import { RcxInteractive, RcxView } from '../../primitives'; + +export const CardContext = createStyledContext({ + horizontal: false as boolean, +}); + +const CardFrame = styled(RcxView, { + name: 'Card', + context: CardContext, + display: 'flex', + color: '$fontDefault', + borderRadius: '$x8', + backgroundColor: '$surfaceLight', + + variants: { + horizontal: { + true: { + flexDirection: 'row', + alignItems: 'center', + padding: '$x12', + gap: '$x16', + }, + false: { + flexDirection: 'column', + padding: '$x20', + gap: '$x24', + }, + }, + + hero: { + true: { + padding: '$x28', + }, + }, + + wrap: { + true: { + flexWrap: 'wrap', + }, + }, + } as const, + + defaultVariants: { + horizontal: false, + }, +}); + +const CardClickableFrame = styled(RcxInteractive, { + name: 'CardClickable', + context: CardContext, + display: 'flex', + color: '$fontDefault', + borderRadius: '$x8', + backgroundColor: '$surfaceLight', + + hoverStyle: { + backgroundColor: '$surfaceHover', + }, + + focusStyle: { + backgroundColor: '$surfaceHover', + outlineWidth: 0, + }, + + variants: { + horizontal: { + true: { + alignItems: 'center', + flexDirection: 'row', + padding: '$x12', + gap: '$x16', + }, + false: { + flexDirection: 'column', + padding: '$x20', + gap: '$x24', + }, + }, + + hero: { + true: { + padding: '$x28', + }, + }, + + wrap: { + true: { + flexWrap: 'wrap', + }, + }, + } as const, + + defaultVariants: { + horizontal: false, + }, +}); export type CardProps = { horizontal?: boolean; @@ -13,14 +109,13 @@ const Card = ({ horizontal, hero, clickable, ...props }: CardProps) => { const breakpoints = useBreakpoints(); const isMobile = !breakpoints.includes('sm'); + const Frame = clickable ? CardClickableFrame : CardFrame; + return ( - ); diff --git a/packages/fuselage/src/components/Card/CardBody.tsx b/packages/fuselage/src/components/Card/CardBody.tsx index 3ff14a6132..c06ad9a4d1 100644 --- a/packages/fuselage/src/components/Card/CardBody.tsx +++ b/packages/fuselage/src/components/Card/CardBody.tsx @@ -1,10 +1,34 @@ import type { AllHTMLAttributes, CSSProperties, ReactNode } from 'react'; +import { styled } from '@tamagui/core'; -import { Box, type BoxProps } from '../Box'; +import { RcxText, RcxView } from '../../primitives'; + +const CardBodyFrameContent = styled(RcxView, { + name: 'CardBodyContent', + display: 'flex', + flexGrow: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + flexShrink: 1, +}); + +const CardBodyFrameText = styled(RcxText, { + name: 'CardBodyText', + display: 'flex', + gap: '$x8', + fontFamily: '$body', + fontSize: '$p2m', + fontWeight: '$p2m', + lineHeight: '$p2m', + letterSpacing: '$p2m', + color: 'inherit', + overflowWrap: 'normal', +}); export type CardBodyProps = { flexDirection?: CSSProperties['flexDirection']; - height?: BoxProps['height']; + height?: string | number; children: ReactNode; } & Omit, 'is'>; @@ -14,17 +38,13 @@ const CardBody = ({ height, ...props }: CardBodyProps) => ( - - {children} - + {children} + ); export default CardBody; diff --git a/packages/fuselage/src/components/Card/CardCol.tsx b/packages/fuselage/src/components/Card/CardCol.tsx index 7e064ad441..a61344e121 100644 --- a/packages/fuselage/src/components/Card/CardCol.tsx +++ b/packages/fuselage/src/components/Card/CardCol.tsx @@ -1,13 +1,34 @@ import type { AllHTMLAttributes, ReactNode } from 'react'; +import { styled } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; + +import { CardContext } from './Card'; + +const CardColFrame = styled(RcxView, { + name: 'CardCol', + context: CardContext, + display: 'flex', + flexDirection: 'column', + flexShrink: 1, + gap: '$x8', + + variants: { + horizontal: { + true: { + rowGap: '$x4', + }, + false: {}, + }, + } as const, +}); export type CardColProps = { children: ReactNode; } & AllHTMLAttributes; const CardCol = ({ children, ...props }: CardColProps) => ( -
- {children} -
+ {children} ); export default CardCol; diff --git a/packages/fuselage/src/components/Card/CardControls.tsx b/packages/fuselage/src/components/Card/CardControls.tsx index a5de212f5b..b379eb75e7 100644 --- a/packages/fuselage/src/components/Card/CardControls.tsx +++ b/packages/fuselage/src/components/Card/CardControls.tsx @@ -1,9 +1,20 @@ import type { HTMLAttributes } from 'react'; +import { styled } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; + +const CardControlsFrame = styled(RcxView, { + name: 'CardControls', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '$x8', +}); export type CardControlsProps = HTMLAttributes; const CardControls = ({ ...props }: CardControlsProps) => ( -
+ ); export default CardControls; diff --git a/packages/fuselage/src/components/Card/CardHeader.tsx b/packages/fuselage/src/components/Card/CardHeader.tsx index 432b102e52..5d9f276031 100644 --- a/packages/fuselage/src/components/Card/CardHeader.tsx +++ b/packages/fuselage/src/components/Card/CardHeader.tsx @@ -1,13 +1,22 @@ import type { AllHTMLAttributes, ReactNode } from 'react'; +import { styled } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; + +const CardHeaderFrame = styled(RcxView, { + name: 'CardHeader', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '$x8', +}); export type CardHeaderProps = { children: ReactNode; } & AllHTMLAttributes; const CardHeader = ({ children, ...props }: CardHeaderProps) => ( -
- {children} -
+ {children} ); export default CardHeader; diff --git a/packages/fuselage/src/components/Card/CardRow.tsx b/packages/fuselage/src/components/Card/CardRow.tsx index bf9b8424d9..eb13d5f796 100644 --- a/packages/fuselage/src/components/Card/CardRow.tsx +++ b/packages/fuselage/src/components/Card/CardRow.tsx @@ -1,13 +1,24 @@ import type { AllHTMLAttributes, ReactNode } from 'react'; +import { styled } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; + +const CardRowFrame = styled(RcxView, { + name: 'CardRow', + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + gap: '$x8', + flexGrow: 1, + flexShrink: 1, +}); export type CardRowProps = { children: ReactNode; } & AllHTMLAttributes; const CardRow = ({ children, ...props }: CardRowProps) => ( -
- {children} -
+ {children} ); export default CardRow; diff --git a/packages/fuselage/src/components/Card/CardTitle.tsx b/packages/fuselage/src/components/Card/CardTitle.tsx index aeb8c1756d..dc89ba6f49 100644 --- a/packages/fuselage/src/components/Card/CardTitle.tsx +++ b/packages/fuselage/src/components/Card/CardTitle.tsx @@ -1,8 +1,46 @@ import type { AllHTMLAttributes, ReactNode } from 'react'; +import { styled } from '@tamagui/core'; -import { Box } from '../Box'; +import { RcxText } from '../../primitives'; import { LabelInfo } from '../Label/LabelInfo'; +const CardTitleFrame = styled(RcxText, { + name: 'CardTitle', + display: 'flex', + alignItems: 'center', + gap: '$x8', + fontFamily: '$body', + color: 'inherit', + overflowWrap: 'normal', + + variants: { + variant: { + h3: { + fontSize: '$h3', + fontWeight: '$h3', + lineHeight: '$h3', + letterSpacing: '$h3', + }, + h4: { + fontSize: '$h4', + fontWeight: '$h4', + lineHeight: '$h4', + letterSpacing: '$h4', + }, + h5: { + fontSize: '$h5', + fontWeight: '$h5', + lineHeight: '$h5', + letterSpacing: '$h5', + }, + }, + } as const, + + defaultVariants: { + variant: 'h4', + }, +}); + export type CardTitleProps = { children: ReactNode; info?: string; @@ -15,10 +53,15 @@ const CardTitle = ({ variant = 'h4', ...props }: CardTitleProps) => ( - + {children} {info && } - + ); export default CardTitle; diff --git a/packages/fuselage/src/components/CardGroup/CardGroup.styles.scss b/packages/fuselage/src/components/CardGroup/CardGroup.styles.scss deleted file mode 100644 index 25aa3c7c27..0000000000 --- a/packages/fuselage/src/components/CardGroup/CardGroup.styles.scss +++ /dev/null @@ -1,71 +0,0 @@ -@use '../../styles/lengths.scss'; - -.rcx-card-group { - display: flex; - - flex-flow: row nowrap; - justify-content: flex-start; - - align-items: center; - - &--wrap { - flex-wrap: wrap; - - margin-block-end: lengths.margin(-16); - } - - &--stretch { - justify-content: stretch; - align-items: stretch; - } - - &--vertical { - flex-direction: column; - } - - &--align-start { - justify-content: flex-start; - } - - &--align-center { - justify-content: center; - } - - &--align-end { - justify-content: flex-end; - } -} - -.rcx-card-group__item { - margin-inline: lengths.margin(8); - - &:first-of-type { - margin-inline-start: lengths.margin(none); - } - - &:last-of-type { - margin-inline-end: lengths.margin(none); - } - - .rcx-card-group--wrap > & { - margin-block-end: lengths.margin(16); - margin-inline: lengths.margin(8) lengths.margin(8); - } - - .rcx-card-group--stretch > & { - flex-grow: 1; - } - - .rcx-card-group--vertical & { - margin-block: lengths.margin(4); - margin-inline: lengths.margin(none); - - &:first-child { - margin-block-start: lengths.margin(none); - } - - &:last-child { - margin-block-end: lengths.margin(none); - } - } -} diff --git a/packages/fuselage/src/components/CardGroup/CardGroup.tsx b/packages/fuselage/src/components/CardGroup/CardGroup.tsx index a09252b073..5616d0e175 100644 --- a/packages/fuselage/src/components/CardGroup/CardGroup.tsx +++ b/packages/fuselage/src/components/CardGroup/CardGroup.tsx @@ -1,8 +1,76 @@ -import type { AllHTMLAttributes, ReactNode } from 'react'; +import type { ReactNode } from 'react'; +import { Children, isValidElement } from 'react'; +import { styled, createStyledContext } from '@tamagui/core'; -import { appendClassName } from '../../helpers/appendClassName'; -import { patchChildren } from '../../helpers/patchChildren'; -import { Box } from '../Box'; +import { RcxView } from '../../primitives'; + +const CardGroupContext = createStyledContext({ + stretch: false as boolean, +}); + +const CardGroupFrame = styled(RcxView, { + name: 'CardGroup', + context: CardGroupContext, + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'flex-start', + alignItems: 'center', + gap: '$x16', + + variants: { + wrap: { + true: { + flexWrap: 'wrap', + }, + }, + + stretch: { + true: { + justifyContent: 'stretch', + alignItems: 'stretch', + }, + false: {}, + }, + + vertical: { + true: { + flexDirection: 'column', + gap: '$x8', + }, + }, + + align: { + start: { + justifyContent: 'flex-start', + }, + center: { + justifyContent: 'center', + }, + end: { + justifyContent: 'flex-end', + }, + }, + } as const, + + defaultVariants: { + stretch: false, + }, +}); + +const CardGroupItem = styled(RcxView, { + name: 'CardGroupItem', + context: CardGroupContext, + + variants: { + stretch: { + true: { + flexGrow: 1, + }, + false: {}, + }, + } as const, +}); export type CardGroupProps = { align?: 'start' | 'center' | 'end'; @@ -12,7 +80,7 @@ export type CardGroupProps = { small?: boolean; large?: boolean; children?: ReactNode; -} & Omit, 'is' | 'wrap'>; +} & Omit, 'is' | 'wrap'>; const CardGroup = ({ align = 'start', @@ -22,25 +90,21 @@ const CardGroup = ({ wrap, ...props }: CardGroupProps) => ( - - {patchChildren( - children, - (childProps: { className: string | string[] }) => ({ - className: appendClassName( - childProps.className, - 'rcx-card-group__item', - ), - }), - )} - + {Children.map(children, (child) => { + if (!isValidElement(child)) { + return child; + } + return {child}; + })} + ); export default CardGroup; diff --git a/packages/fuselage/src/components/CheckBox/CheckBox.styles.scss b/packages/fuselage/src/components/CheckBox/CheckBox.styles.scss deleted file mode 100644 index 3e2811183a..0000000000 --- a/packages/fuselage/src/components/CheckBox/CheckBox.styles.scss +++ /dev/null @@ -1,92 +0,0 @@ -@use 'sass:math'; -@use '../../styles/lengths.scss'; - -.rcx-check-box { - @include is-selection-button( - $checked: 'primary', - $unchecked: 'empty', - $indeterminate: 'primary' - ); - - &__input { - @extend %selection-button__input; - } - - $icon-smoothness: to-rem(1); - $icon-thickness: to-rem(2); - $icon-size: 0.6; - - &__fake { - @extend %selection-button__fake; - display: flex; - justify-content: center; - align-items: center; - - border-radius: theme( - 'check-box-border-radius', - lengths.border-radius(small) - ); - inline-size: lengths.size(20); - - &::before, - &::after { - position: absolute; - - display: block; - visibility: hidden; - - content: ''; - - opacity: 0; - - background-color: currentColor; - } - } - - &__input:indeterminate + &__fake::before { - visibility: visible; - - width: $icon-size * lengths.size(20); - height: $icon-thickness; - - opacity: 1; - - border-radius: $icon-smoothness; - } - - &__input:checked + &__fake { - &::before, - &::after { - visibility: visible; - - opacity: 1; - border-radius: $icon-smoothness; - } - - &::before { - width: $icon-size * lengths.size(20); - height: $icon-thickness; - - transform: translate( - $icon-size * math.div(lengths.size(20), -3), - $icon-size * math.div(lengths.size(20), 6) - ) - rotate(-45deg) - translate( - $icon-size * math.div(lengths.size(20), 2), - $icon-size * math.div(lengths.size(20), 6) - ); - } - - &::after { - width: $icon-thickness; - height: 0.5 * $icon-size * lengths.size(20); - - transform: translate( - $icon-size * math.div(lengths.size(20), -3), - $icon-size * math.div(lengths.size(20), 6) - ) - rotate(-45deg); - } - } -} diff --git a/packages/fuselage/src/components/CheckBox/CheckBox.tsx b/packages/fuselage/src/components/CheckBox/CheckBox.tsx index 30e2916a75..186f45ba9d 100644 --- a/packages/fuselage/src/components/CheckBox/CheckBox.tsx +++ b/packages/fuselage/src/components/CheckBox/CheckBox.tsx @@ -1,51 +1,354 @@ import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import type { FormEvent, AllHTMLAttributes, ReactNode } from 'react'; -import { forwardRef, useLayoutEffect, useRef, useCallback } from 'react'; +import type { CSSProperties, FormEvent, AllHTMLAttributes, ReactNode } from 'react'; +import { + forwardRef, + useLayoutEffect, + useMemo, + useRef, + useCallback, + useState, +} from 'react'; +import { styled } from '@tamagui/core'; -import { Box, type BoxProps } from '../Box'; +import { RcxView } from '../../primitives'; +import type { BoxCompatProps } from '../../utilities/boxCompat'; +import { extractBoxProps } from '../../utilities/boxCompat'; -export type CheckBoxProps = BoxProps & { +// --- Styled components --- + +const CheckBoxWrapper = styled(RcxView, { + name: 'CheckBox', + tag: 'label', + + position: 'relative', + display: 'inline-flex', + flexDirection: 'row', + verticalAlign: 'middle', + cursor: 'pointer', + + variants: { + isDisabled: { + true: { + cursor: 'not-allowed', + }, + }, + } as const, +}); + +const CheckBoxFake = styled(RcxView, { + name: 'CheckBoxFake', + + position: 'relative', + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + + width: 20, + height: 20, + + borderWidth: 1, + borderStyle: 'solid', + borderRadius: '$x2', + + // Default unchecked (empty) state + backgroundColor: '$surfaceLight', + borderColor: '$strokeDark', + color: '$fontWhite', + + variants: { + state: { + unchecked: { + backgroundColor: '$surfaceLight', + borderColor: '$strokeDark', + color: '$fontWhite', + }, + checked: { + backgroundColor: '$buttonPrimaryBg', + borderColor: '$buttonPrimaryBg', + color: '$buttonPrimaryColor', + }, + indeterminate: { + backgroundColor: '$buttonPrimaryBg', + borderColor: '$buttonPrimaryBg', + color: '$buttonPrimaryColor', + }, + }, + + isHovered: { + true: {}, + }, + + isActive: { + true: {}, + }, + + isFocused: { + true: {}, + }, + + isDisabled: { + true: {}, + }, + } as const, + + compoundVariants: [ + // Unchecked + hovered + { + state: 'unchecked', + isHovered: true, + backgroundColor: '$surfaceLight', + borderColor: '$strokeExtraDark', + boxShadow: 'none', + }, + // Unchecked + active + { + state: 'unchecked', + isActive: true, + backgroundColor: '$surfaceLight', + borderColor: '$strokeExtraDark', + boxShadow: 'none', + }, + // Unchecked + focused + { + state: 'unchecked', + isFocused: true, + backgroundColor: '$surfaceLight', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + // Unchecked + disabled + { + state: 'unchecked', + isDisabled: true, + backgroundColor: '$buttonSecondaryDisabledBg', + borderColor: '$strokeLight', + color: '$fontWhite', + cursor: 'not-allowed', + }, + + // Checked + hovered + { + state: 'checked', + isHovered: true, + backgroundColor: '$buttonPrimaryHoverBg', + borderColor: '$buttonPrimaryHoverBg', + boxShadow: 'none', + }, + // Checked + active + { + state: 'checked', + isActive: true, + backgroundColor: '$buttonPrimaryPressBg', + borderColor: '$buttonPrimaryPressBg', + boxShadow: 'none', + }, + // Checked + focused + { + state: 'checked', + isFocused: true, + backgroundColor: '$buttonPrimaryFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + // Checked + disabled + { + state: 'checked', + isDisabled: true, + backgroundColor: '$buttonPrimaryDisabledBg', + borderColor: '$buttonPrimaryDisabledBg', + color: '$buttonPrimaryDisabledColor', + cursor: 'not-allowed', + }, + + // Indeterminate + hovered + { + state: 'indeterminate', + isHovered: true, + backgroundColor: '$buttonPrimaryHoverBg', + borderColor: '$buttonPrimaryHoverBg', + boxShadow: 'none', + }, + // Indeterminate + active + { + state: 'indeterminate', + isActive: true, + backgroundColor: '$buttonPrimaryPressBg', + borderColor: '$buttonPrimaryPressBg', + boxShadow: 'none', + }, + // Indeterminate + focused + { + state: 'indeterminate', + isFocused: true, + backgroundColor: '$buttonPrimaryFocusBg', + borderColor: '$strokeExtraDark', + boxShadow: '0 0 0 2px var(--shadowHighlight)', + }, + // Indeterminate + disabled + { + state: 'indeterminate', + isDisabled: true, + backgroundColor: '$buttonPrimaryDisabledBg', + borderColor: '$buttonPrimaryDisabledBg', + color: '$buttonPrimaryDisabledColor', + cursor: 'not-allowed', + }, + ], +}); + +// Indeterminate indicator: horizontal bar +const IndeterminateIndicator = styled(RcxView, { + name: 'CheckBoxIndeterminate', + + position: 'absolute', + width: 12, // 0.6 * 20 + height: 2, + borderRadius: 1, + backgroundColor: 'currentColor', +}); + +// Check mark: two bars forming an L shape rotated -45deg +// Using a wrapper div with transform, containing the two bars +const CheckMarkWrapper = styled(RcxView, { + name: 'CheckMarkWrapper', + + position: 'absolute', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + // translate(-4px, 2px) rotate(-45deg) + transform: 'translate(-4px, 2px) rotate(-45deg)', +}); + +// Long bar of the checkmark (horizontal) +const CheckMarkLong = styled(RcxView, { + name: 'CheckMarkLong', + + width: 12, // 0.6 * 20 + height: 2, + borderRadius: 1, + backgroundColor: 'currentColor', + // translate(6px, 2px) relative to the wrapper + transform: 'translate(6px, 2px)', +}); + +// Short bar of the checkmark (vertical) +const CheckMarkShort = styled(RcxView, { + name: 'CheckMarkShort', + + width: 2, + height: 6, // 0.5 * 0.6 * 20 + borderRadius: 1, + backgroundColor: 'currentColor', +}); + +// --- Types --- + +export type CheckBoxProps = { indeterminate?: boolean; labelChildren?: ReactNode; -} & AllHTMLAttributes; - -const CheckBox = forwardRef(function CheckBox( - { indeterminate, onChange, className, labelChildren, ...props }, - ref, -) { - const innerRef = useRef(null); - const mergedRef = useMergedRefs(ref, innerRef); - - useLayoutEffect(() => { - if (innerRef && innerRef.current && indeterminate !== undefined) { - innerRef.current.indeterminate = indeterminate; - } - }, [innerRef, indeterminate]); - - const handleChange = useCallback( - (event: FormEvent) => { + className?: string; + style?: CSSProperties; +} & Omit, 'is'> + & Partial; + +// --- Component --- + +const CheckBox = forwardRef( + function CheckBox( + { indeterminate, onChange, className, labelChildren, checked, disabled, style: styleProp, ...props }, + ref, + ) { + const { style: boxStyle, rest: inputProps } = extractBoxProps(props as Record); + const wrapperStyle = useMemo(() => { + const hasBoxStyle = Object.keys(boxStyle).length > 0; + if (!hasBoxStyle && !styleProp) return undefined; + return { ...boxStyle, ...styleProp }; + }, [boxStyle, styleProp]); + const innerRef = useRef(null); + const mergedRef = useMergedRefs(ref, innerRef); + + const [isFocused, setIsFocused] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isActive, setIsActive] = useState(false); + + useLayoutEffect(() => { if (innerRef && innerRef.current && indeterminate !== undefined) { innerRef.current.indeterminate = indeterminate; } - onChange?.call(innerRef.current, event); - }, - [innerRef, indeterminate, onChange], - ); - - return ( - - {labelChildren} - - - ); -}); + }, [innerRef, indeterminate]); + + const handleChange = useCallback( + (event: FormEvent) => { + if (innerRef && innerRef.current && indeterminate !== undefined) { + innerRef.current.indeterminate = indeterminate; + } + onChange?.call(innerRef.current, event); + }, + [innerRef, indeterminate, onChange], + ); + + const state = indeterminate + ? 'indeterminate' + : checked + ? 'checked' + : 'unchecked'; + + return ( + setIsHovered(true)} + onMouseLeave={() => { + setIsHovered(false); + setIsActive(false); + }} + onMouseDown={() => setIsActive(true)} + onMouseUp={() => setIsActive(false)} + > + {labelChildren} + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + style={{ + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: 0, + }} + {...(inputProps as any)} + /> + + + ); + }, +); export default CheckBox; diff --git a/packages/fuselage/src/components/Chevron/Chevron.styles.scss b/packages/fuselage/src/components/Chevron/Chevron.styles.scss deleted file mode 100644 index e451942593..0000000000 --- a/packages/fuselage/src/components/Chevron/Chevron.styles.scss +++ /dev/null @@ -1,28 +0,0 @@ -.rcx-chevron { - display: inline-flex; - align-self: center; - - &--up { - transform: rotate(-180deg); - } - - &--down { - transform: rotate(0deg); - } - - &--right { - transform: rotate(-90deg); - - &:dir(rtl) { - transform: rotate(-270deg); - } - } - - &--left { - transform: rotate(-270deg); - - &:dir(rtl) { - transform: rotate(-90deg); - } - } -} diff --git a/packages/fuselage/src/components/Chevron/Chevron.tsx b/packages/fuselage/src/components/Chevron/Chevron.tsx index 5f0147701f..bd72360788 100644 --- a/packages/fuselage/src/components/Chevron/Chevron.tsx +++ b/packages/fuselage/src/components/Chevron/Chevron.tsx @@ -1,19 +1,37 @@ -import type { ReactElement } from 'react'; +import type { ComponentPropsWithoutRef, ReactElement } from 'react'; import { useMemo } from 'react'; +import { styled } from '@tamagui/core'; -import { Box, type BoxProps } from '../Box'; +import { RcxView } from '../../primitives'; import { Icon } from '../Icon'; -export type ChevronProps = Omit & { - size?: BoxProps['width']; +const ChevronFrame = styled(RcxView, { + name: 'Chevron', + display: 'inline-flex', + alignSelf: 'center', +}); + +export type ChevronProps = ComponentPropsWithoutRef & { + size?: ComponentPropsWithoutRef['size']; up?: boolean; right?: boolean; left?: boolean; down?: boolean; - top?: boolean; - bottom?: boolean; }; +function getRotation({ + up, + right, + down, + left, +}: Pick): string { + if (up) return '-180deg'; + if (right) return '-90deg'; + if (left) return '-270deg'; + if (down) return '0deg'; + return '0deg'; +} + function Chevron({ up, right, @@ -21,23 +39,21 @@ function Chevron({ left, size, ...props -}: ChevronProps): ReactElement { +}: ChevronProps): ReactElement { const children = useMemo( () => , [size], ); + const rotate = getRotation({ up, right, down, left }); + return ( - + style={{ transform: `rotate(${rotate})` }} + > + {children} + ); } diff --git a/packages/fuselage/src/components/Chip/Chip.styles.scss b/packages/fuselage/src/components/Chip/Chip.styles.scss deleted file mode 100644 index 1b358a63a8..0000000000 --- a/packages/fuselage/src/components/Chip/Chip.styles.scss +++ /dev/null @@ -1,107 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; -@use '../../styles/functions'; -@use '../../styles/primitives/button.scss'; - -// to do: replace button with stroke - -$chip-background-color: functions.theme( - 'chip-background-color', - colors.button(secondary-default) -); -$chip-border-color: functions.theme( - 'chip-border-color', - colors.button(secondary-default) -); -$chip-color: functions.theme('chip-color', colors.font(secondary-info)); -$chip-hover-background-color: functions.theme( - 'chip-hover-background-color', - colors.button(secondary-hover) -); -$chip-hover-border-color: functions.theme( - 'chip-hover-border-color', - colors.button(secondary-hover) -); -$chip-active-background-color: functions.theme( - 'chip-active-background-color', - colors.button(secondary-press) -); -$chip-active-border-color: functions.theme( - 'chip-active-border-color', - colors.button(secondary-press) -); -$chip-focus-background-color: functions.theme( - 'chip-focus-background-color', - colors.button(secondary-focus) -); -$chip-focus-border-color: functions.theme( - 'chip-focus-border-color', - colors.stroke(extra-dark) -); -$chip-focus-shadow-color: functions.theme( - 'chip-focus-shadow-color', - colors.stroke(extra-light-highlight) -); -$chip-disabled-background-color: functions.theme( - 'chip-disabled-background-color', - colors.button(secondary-disabled) -); -$chip-disabled-border-color: functions.theme( - 'chip-disabled-border-color', - colors.button(secondary-disabled) -); -$chip-disabled-color: functions.theme( - 'chip-disabled-color', - colors.font(disabled) -); - -.rcx-chip { - @extend %box--full; - @include button.kind-variant( - ( - background-color: $chip-background-color, - border-color: $chip-border-color, - color: $chip-color, - hover-background-color: $chip-hover-background-color, - hover-border-color: $chip-hover-border-color, - active-background-color: $chip-active-background-color, - active-border-color: $chip-active-border-color, - focus-background-color: $chip-focus-background-color, - focus-border-color: $chip-focus-border-color, - focus-shadow-color: $chip-focus-shadow-color, - disabled-background-color: $chip-disabled-background-color, - disabled-border-color: $chip-disabled-border-color, - disabled-color: $chip-disabled-color, - ) - ); - - @include clickable; - @include typography.use-font-scale('p2'); - - display: flex; - overflow: hidden; - align-items: center; - - min-height: lengths.size(28); - - border-width: 0; - - &.disabled, - &:disabled { - color: $button-secondary-color; - border-color: $button-secondary-border-color; - background-color: $button-secondary-background-color; - } - - &__text { - @include typography.use-text-ellipsis; - - white-space: nowrap; - letter-spacing: inherit; - - color: inherit; - - font: inherit; - } -} diff --git a/packages/fuselage/src/components/Chip/Chip.tsx b/packages/fuselage/src/components/Chip/Chip.tsx index 68edbcd213..ef8ab0aa09 100644 --- a/packages/fuselage/src/components/Chip/Chip.tsx +++ b/packages/fuselage/src/components/Chip/Chip.tsx @@ -1,11 +1,86 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { useMemo } from 'react'; +import { styled, Text } from '@tamagui/core'; -import { prependClassName } from '../../helpers/prependClassName'; +import { RcxText } from '../../primitives'; +import type { BoxCompatProps } from '../../utilities/boxCompat'; +import { extractBoxProps } from '../../utilities/boxCompat'; import { Avatar } from '../Avatar'; -import { Box } from '../Box'; -import { withBoxStyling } from '../Box/withBoxStyling'; import { Icon } from '../Icon'; -import { Margins } from '../Margins'; + +// Outer container — RcxText because: +// 1. Original was + {thumbUrl && renderThumb && renderThumb({ url: thumbUrl })} + {children && {children}} + {onDismiss && renderDismissSymbol && renderDismissSymbol()} + ); }; -export default withBoxStyling(Chip); +export default Chip; diff --git a/packages/fuselage/src/components/CodeSnippet/CodeSnippet.styles.scss b/packages/fuselage/src/components/CodeSnippet/CodeSnippet.styles.scss deleted file mode 100644 index 41e3932558..0000000000 --- a/packages/fuselage/src/components/CodeSnippet/CodeSnippet.styles.scss +++ /dev/null @@ -1,32 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -.rcx-code-snippet { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - width: 100%; - - min-height: lengths.size(60); - padding: lengths.padding(16); - - color: colors.font(default); - - border-radius: theme( - 'code-snippet-border-radius', - lengths.border-radius(medium) - ); - background-color: colors.surface(neutral); - - &__codebox { - margin-right: lengths.margin(8); - - white-space: pre-line; - word-break: break-all; - - font-family: monospace; - } -} diff --git a/packages/fuselage/src/components/CodeSnippet/CodeSnippet.tsx b/packages/fuselage/src/components/CodeSnippet/CodeSnippet.tsx index 5ae26e2086..b8098a6973 100644 --- a/packages/fuselage/src/components/CodeSnippet/CodeSnippet.tsx +++ b/packages/fuselage/src/components/CodeSnippet/CodeSnippet.tsx @@ -1,22 +1,50 @@ import type { ReactElement } from 'react'; -import { Box, type BoxProps } from '../Box'; +import { styled, Text } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; import { Button } from '../Button'; import { Skeleton } from '../Skeleton'; -export type CodeSnippetProps = BoxProps & { +// Outer — RcxView for layout (pre element in original) +const CodeSnippetBase = styled(RcxView, { + name: 'CodeSnippet', + + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + + width: '100%' as any, + minHeight: '$x60', + padding: '$x16', + + borderRadius: '$x4', + + backgroundColor: '$surfaceNeutral', +}); + +// Codebox — Text for font props (monospace) +const CodeSnippetCodebox = styled(Text, { + name: 'CodeSnippetCodebox', + + marginRight: '$x8', + + // Unsupported in styled: whiteSpace, wordBreak applied via style + fontFamily: '$mono', + color: '$fontDefault', +}); + +export type CodeSnippetProps = { children: string; buttonText?: string; buttonDisabled?: boolean; onClick?: () => void; + [key: string]: any; }; /** * The `CodeSnippet` is used to show code or commands and make easier to copy them. - * - * The default button text is `Copy` but you can use the `buttonText` prop to handle translations in your project. - * - * Please check the `useClipBoard` hook in `fuselage-hooks` package, to handle the copy behaviour. */ const CodeSnippet = ({ children, @@ -27,25 +55,26 @@ const CodeSnippet = ({ }: CodeSnippetProps): ReactElement => { if (!children) { return ( - + - + ); } return ( - - + + {children} - + {onClick && children && ( - - - + )} - + ); }; diff --git a/packages/fuselage/src/components/Divider/Divider.styles.scss b/packages/fuselage/src/components/Divider/Divider.styles.scss deleted file mode 100644 index c2043dba4e..0000000000 --- a/packages/fuselage/src/components/Divider/Divider.styles.scss +++ /dev/null @@ -1,45 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -$divider-size: theme('divider-size', lengths.border-width(default)); -$divider-color: theme('divider-color', colors.stroke(extra-light)); - -.rcx-divider { - margin-block: lengths.margin(8); - - border-top: $divider-size solid $divider-color; - - &--danger { - border-color: colors.stroke(error); - } - - &__bar { - display: flex; - justify-content: flex-end; - align-items: center; - flex-grow: 1; - - &::after { - flex-grow: 1; - - content: ''; - - border: $divider-size solid $divider-color; - } - } - - &__wrapper { - margin-block: lengths.margin(8); - padding-inline: lengths.padding(8); - } - - &--vertical { - width: 0; - height: lengths.size(20); - margin-block: 0; - margin-inline: lengths.margin(8); - - border-left: $divider-size solid $divider-color; - } -} diff --git a/packages/fuselage/src/components/Divider/Divider.tsx b/packages/fuselage/src/components/Divider/Divider.tsx index 433d4ebbfd..662f339d4a 100644 --- a/packages/fuselage/src/components/Divider/Divider.tsx +++ b/packages/fuselage/src/components/Divider/Divider.tsx @@ -1,31 +1,105 @@ -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useMemo } from 'react'; +import { styled } from '@tamagui/core'; -import { Box, type BoxProps } from '../Box'; +import { RcxView, RcxText } from '../../primitives'; +import type { BoxCompatProps } from '../../utilities/boxCompat'; +import { extractBoxProps } from '../../utilities/boxCompat'; -export type DividerProps = BoxProps & { +const DividerHr = styled(RcxView, { + name: 'DividerHr', + marginBlock: '$x8', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: '$strokeExtraLight', + + variants: { + danger: { + true: { + borderTopColor: '$strokeError', + }, + }, + vertical: { + true: { + width: 0, + height: '$x20', + marginBlock: 0, + marginInline: '$x8', + borderTopWidth: 0, + borderTopStyle: 'unset', + borderTopColor: 'transparent', + borderLeftWidth: 1, + borderLeftStyle: 'solid', + borderLeftColor: '$strokeExtraLight', + }, + }, + } as const, +}); + +const DividerBar = styled(RcxView, { + name: 'DividerBar', + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + flexGrow: 1, + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: '$strokeExtraLight', +}); + +const DividerWrapper = styled(RcxText, { + name: 'DividerWrapper', + marginBlock: '$x8', + paddingInline: '$x8', + fontFamily: '$body', + fontSize: '$c2', + fontWeight: '$c2', + lineHeight: '$c2', + letterSpacing: '$c2', + color: '$fontDefault', +}); + +const DividerContainer = styled(RcxView, { + name: 'DividerContainer', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', +}); + +export type DividerProps = { variation?: 'danger'; children?: ReactNode; vertical?: boolean; -}; + style?: CSSProperties; +} & Partial; + +const Divider = ({ variation, children, vertical, style: styleProp, ...props }: DividerProps) => { + const { style: boxStyle, rest } = extractBoxProps(props as Record); + const mergedStyle = useMemo(() => { + const hasBoxStyle = Object.keys(boxStyle).length > 0; + if (!hasBoxStyle && !styleProp) return undefined; + return { ...boxStyle, ...styleProp }; + }, [boxStyle, styleProp]); -const Divider = ({ variation, children, vertical, ...props }: DividerProps) => { if (!children) { return ( - ); } return ( - -
-
{children}
-
- + + + {children} + + ); }; diff --git a/packages/fuselage/src/components/Dropdown/Dropdown.styles.scss b/packages/fuselage/src/components/Dropdown/Dropdown.styles.scss deleted file mode 100644 index 401ce8e87e..0000000000 --- a/packages/fuselage/src/components/Dropdown/Dropdown.styles.scss +++ /dev/null @@ -1,29 +0,0 @@ -.rcx-dropdown-enter { - transform: translate3d(0, -1rem, 0); - - opacity: 0; -} - -.rcx-dropdown-enter-active { - transition: - opacity 300ms, - transform 300ms; - transform: translate3d(0, 0, 0); - - opacity: 1; -} - -.rcx-dropdown-exit { - transform: translate3d(0, 0, 0); - - opacity: 1; -} - -.rcx-dropdown-exit-active { - transition: - transform 300ms, - opacity 300ms; - transform: translate3d(0, -1rem, 0); - - opacity: 0 !important; -} diff --git a/packages/fuselage/src/components/Dropdown/DropdownDesktop.tsx b/packages/fuselage/src/components/Dropdown/DropdownDesktop.tsx index 6c5bc0a12d..2ee5eb7ce3 100644 --- a/packages/fuselage/src/components/Dropdown/DropdownDesktop.tsx +++ b/packages/fuselage/src/components/Dropdown/DropdownDesktop.tsx @@ -1,7 +1,16 @@ import type { CSSProperties, ReactNode } from 'react'; import { forwardRef } from 'react'; +import { styled } from '@tamagui/core'; -import { Box, Tile } from '..'; +import { RcxView } from '../../primitives'; +import Tile from '../Tile/Tile'; + +const DropdownContent = styled(RcxView, { + name: 'DropdownContent', + display: 'block', + flexShrink: 1, + paddingBlock: '$x12', +}); export type DropdownDesktopProps = { children: ReactNode; @@ -18,17 +27,16 @@ export const DropdownDesktop = forwardRef( style={style} ref={ref} elevation='2' - pi='0' - pb='0' + padding={0} display='flex' flexDirection='column' overflow='auto' data-testid='dropdown' {...props} > - + {style?.visibility === 'hidden' ? null : children} - + ); }, diff --git a/packages/fuselage/src/components/Dropdown/DropdownMobile.tsx b/packages/fuselage/src/components/Dropdown/DropdownMobile.tsx index bebcce5ccf..6dbdbf939f 100644 --- a/packages/fuselage/src/components/Dropdown/DropdownMobile.tsx +++ b/packages/fuselage/src/components/Dropdown/DropdownMobile.tsx @@ -1,7 +1,16 @@ import type { ReactNode } from 'react'; import { forwardRef } from 'react'; +import { styled } from '@tamagui/core'; -import { Box, Tile } from '..'; +import { RcxView } from '../../primitives'; +import Tile from '../Tile/Tile'; + +const DropdownContent = styled(RcxView, { + name: 'DropdownMobileContent', + display: 'block', + flexShrink: 1, + paddingBlock: '$x16', +}); export type DropdownMobileProps = { children: ReactNode; @@ -13,9 +22,8 @@ export const DropdownMobile = forwardRef( ( data-testid='dropdown' {...props} > - + {children} - + ); }, diff --git a/packages/fuselage/src/components/Field/Field.styles.scss b/packages/fuselage/src/components/Field/Field.styles.scss deleted file mode 100644 index 6b6e8051ac..0000000000 --- a/packages/fuselage/src/components/Field/Field.styles.scss +++ /dev/null @@ -1,65 +0,0 @@ -@use '../../styles/colors.scss'; -@use '../../styles/lengths.scss'; -@use '../../styles/typography.scss'; - -.rcx-field { - display: flex; - flex-flow: column nowrap; - align-items: stretch; - flex-shrink: 0; - - width: 100%; - - &__label { - @include typography.use-font-scale(p2m); - align-self: flex-start; - - margin-block: lengths.margin(2); - margin-inline-end: lengths.margin(8); - - color: colors.font(default); - } - - &__description { - @include typography.use-font-scale(p2); - margin-block: lengths.margin(2); - - color: colors.font(secondary-info); - @extend %--with-inline-elements; - } - - &__row { - display: flex; - flex-flow: row nowrap; - justify-content: space-between; - align-items: center; - - margin-block: lengths.margin(4) lengths.margin(2); - - color: colors.font(secondary-info); - } - - &__hint { - @include typography.use-font-scale(c1); - margin-block: lengths.margin(2); - - color: colors.font(secondary-info); - @extend %--with-inline-elements; - } - - &__error { - @include typography.use-font-scale(c1); - margin-block: lengths.margin(2); - - color: colors.font(danger); - @extend %--with-inline-elements; - } - - &__link { - @include typography.use-font-scale(c1); - margin-block: lengths.margin(2); - - color: colors.font(info); - @extend %--with-inline-elements; - } -} diff --git a/packages/fuselage/src/components/Field/Field.tsx b/packages/fuselage/src/components/Field/Field.tsx index 954751bd71..877b584d90 100644 --- a/packages/fuselage/src/components/Field/Field.tsx +++ b/packages/fuselage/src/components/Field/Field.tsx @@ -1,10 +1,28 @@ +import type { ReactNode } from 'react'; import { createContext } from 'react'; -import { Box, type BoxProps } from '../Box'; +import { styled } from '@tamagui/core'; + +import { RcxView } from '../../primitives'; export const FieldContext = createContext(false); -export type FieldProps = BoxProps; +const FieldFrame = styled(RcxView, { + name: 'Field', + + display: 'flex', + flexDirection: 'column', + flexWrap: 'nowrap', + alignItems: 'stretch', + flexShrink: 0, + + width: '100%', +}); + +export type FieldProps = { + children?: ReactNode; + [key: string]: any; +}; /** * A `Field` is a wrapper representing an entry in a form. @@ -12,7 +30,7 @@ export type FieldProps = BoxProps; function Field(props: FieldProps) { return ( - + ); } diff --git a/packages/fuselage/src/components/Field/FieldDescription.tsx b/packages/fuselage/src/components/Field/FieldDescription.tsx index cf73cb3f38..1414cb3680 100644 --- a/packages/fuselage/src/components/Field/FieldDescription.tsx +++ b/packages/fuselage/src/components/Field/FieldDescription.tsx @@ -1,12 +1,34 @@ +import { styled } from '@tamagui/core'; + +import { RcxText } from '../../primitives'; import WithErrorWrapper from '../../helpers/WithErrorWrapper'; -import { Box, type BoxProps } from '../Box'; import { FieldContext } from './Field'; -export type FieldDescriptionProps = BoxProps; +const FieldDescriptionBase = styled(RcxText, { + name: 'FieldDescription', + + display: 'block', + + // p2 font scale + fontFamily: '$body', + fontSize: '$p2', + fontWeight: '$p2', + lineHeight: '$p2', + letterSpacing: '$p2', + + marginBlock: '$x2', + + color: '$fontSecondaryInfo', +}); + +export type FieldDescriptionProps = { + children?: React.ReactNode; + [key: string]: any; +}; const FieldDescription = (props: FieldDescriptionProps) => { - const component = ; + const component = ; if (process.env['NODE_ENV'] === 'development') { return ( diff --git a/packages/fuselage/src/components/Field/FieldError.tsx b/packages/fuselage/src/components/Field/FieldError.tsx index 4981f9437c..0555e0db5f 100644 --- a/packages/fuselage/src/components/Field/FieldError.tsx +++ b/packages/fuselage/src/components/Field/FieldError.tsx @@ -1,12 +1,34 @@ +import { styled } from '@tamagui/core'; + +import { RcxText } from '../../primitives'; import WithErrorWrapper from '../../helpers/WithErrorWrapper'; -import { Box, type BoxProps } from '../Box'; import { FieldContext } from './Field'; -export type FieldErrorProps = BoxProps; +const FieldErrorBase = styled(RcxText, { + name: 'FieldError', + + display: 'block', + + // c1 font scale + fontFamily: '$body', + fontSize: '$c1', + fontWeight: '$c1', + lineHeight: '$c1', + letterSpacing: '$c1', + + marginBlock: '$x2', + + color: '$fontDanger', +}); + +export type FieldErrorProps = { + children?: React.ReactNode; + [key: string]: any; +}; const FieldError = (props: FieldErrorProps) => { - const component = ; + const component = ; if (process.env['NODE_ENV'] === 'development') { return ( diff --git a/packages/fuselage/src/components/Field/FieldHint.tsx b/packages/fuselage/src/components/Field/FieldHint.tsx index 9eb10a9a6d..4e2f471854 100644 --- a/packages/fuselage/src/components/Field/FieldHint.tsx +++ b/packages/fuselage/src/components/Field/FieldHint.tsx @@ -1,12 +1,34 @@ +import { styled } from '@tamagui/core'; + +import { RcxText } from '../../primitives'; import WithErrorWrapper from '../../helpers/WithErrorWrapper'; -import { Box, type BoxProps } from '../Box'; import { FieldContext } from './Field'; -export type FieldHintProps = BoxProps; +const FieldHintBase = styled(RcxText, { + name: 'FieldHint', + + display: 'block', + + // c1 font scale + fontFamily: '$body', + fontSize: '$c1', + fontWeight: '$c1', + lineHeight: '$c1', + letterSpacing: '$c1', + + marginBlock: '$x2', + + color: '$fontSecondaryInfo', +}); + +export type FieldHintProps = { + children?: React.ReactNode; + [key: string]: any; +}; const FieldHint = (props: FieldHintProps) => { - const component = ; + const component = ; if (process.env['NODE_ENV'] === 'development') { return ( diff --git a/packages/fuselage/src/components/Field/FieldLabel.tsx b/packages/fuselage/src/components/Field/FieldLabel.tsx index fd8caee091..94dc4d7630 100644 --- a/packages/fuselage/src/components/Field/FieldLabel.tsx +++ b/packages/fuselage/src/components/Field/FieldLabel.tsx @@ -6,11 +6,22 @@ import { Label } from '../Label'; import { FieldContext } from './Field'; -export type FieldLabelProps = LabelProps; +export type FieldLabelProps = LabelProps & { + [key: string]: any; +}; const FieldLabel = forwardRef( function FieldLabel(props, ref) { - const component =