snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import baseConfig from '@rocket.chat/storybook-config/main';
export default baseConfig();

View File

@@ -0,0 +1,11 @@
import { parameters, decorators } from '@rocket.chat/storybook-config/preview';
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
...parameters,
},
decorators: [...decorators],
};
export default preview;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
moduleNameMapper: {
'^react($|/.+)': '<rootDir>/../../node_modules/react$1',
'^react-dom($|/.+)': '<rootDir>/../../node_modules/react-dom$1',
},
} satisfies Config;

View File

@@ -0,0 +1,81 @@
{
"name": "@rocket.chat/ui-client",
"version": "34.0.0",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"storybook": "storybook dev -p 6006",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/onboarding-ui": "~0.37.3",
"@rocket.chat/tools": "workspace:~",
"dompurify": "~3.4.14"
},
"devDependencies": {
"@react-aria/toolbar": "^3.0.0-nightly.5042",
"@rocket.chat/core-typings": "workspace:~",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/fuselage": "^0.89.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/layout": "~0.36.3",
"@rocket.chat/logo": "^0.33.2",
"@rocket.chat/mock-providers": "workspace:^",
"@rocket.chat/models": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/styled": "~0.34.1",
"@rocket.chat/tsconfig": "workspace:*",
"@rocket.chat/ui-avatar": "workspace:~",
"@rocket.chat/ui-contexts": "workspace:~",
"@storybook/react": "^9.1.20",
"@testing-library/dom": "~10.4.1",
"@testing-library/react": "~16.3.3",
"@types/jest": "~30.0.0",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.5",
"eslint": "~9.39.5",
"i18next": "~23.4.9",
"jest": "~30.2.0",
"overlayscrollbars": "~2.11.5",
"overlayscrollbars-react": "^0.5.6",
"re-resizable": "^6.10.4",
"react": "~19.2.8",
"react-dom": "~19.2.8",
"react-hook-form": "~7.54.2",
"react-i18next": "~13.2.2",
"react-virtuoso": "~4.18.12",
"storybook": "^9.1.20",
"typescript": "~5.9.3"
},
"peerDependencies": {
"@react-aria/toolbar": "*",
"@rocket.chat/css-in-js": "*",
"@rocket.chat/fuselage": "*",
"@rocket.chat/fuselage-hooks": "*",
"@rocket.chat/icons": "*",
"@rocket.chat/ui-avatar": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"overlayscrollbars": "*",
"overlayscrollbars-react": "*",
"re-resizable": "*",
"react": "*",
"react-i18next": "*"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable testing-library/no-node-access */
import { render, screen } from '@testing-library/react';
import AnchorPortal from './AnchorPortal';
it('should render children', () => {
render(
<AnchorPortal id='test-anchor'>
<div role='presentation' aria-label='example' />
</AnchorPortal>,
);
expect(screen.getByRole('presentation', { name: 'example' })).toBeInTheDocument();
});
it('should not recreate the anchor element', () => {
const { baseElement } = render(
<AnchorPortal id='test-anchor'>
<div role='presentation' aria-label='example A' />
</AnchorPortal>,
);
const anchorA = baseElement.querySelector('#test-anchor');
render(
<AnchorPortal id='test-anchor'>
<div role='presentation' aria-label='example B' />
</AnchorPortal>,
);
const anchorB = baseElement.querySelector('#test-anchor');
expect(anchorA).toBe(anchorB);
expect(screen.getByRole('presentation', { name: 'example A' })).toBeInTheDocument();
expect(screen.getByRole('presentation', { name: 'example B' })).toBeInTheDocument();
});
it('should remove the anchor element when unmounted', () => {
const { baseElement, unmount } = render(
<AnchorPortal id='test-anchor'>
<div role='presentation' aria-label='example' />
</AnchorPortal>,
);
expect(baseElement.querySelector('#test-anchor')).toBeInTheDocument();
unmount();
expect(baseElement.querySelector('#test-anchor')).not.toBeInTheDocument();
});
it('should not remove the anchor element after unmounting if there are other portals with the same id', () => {
const { baseElement, unmount } = render(
<AnchorPortal id='test-anchor'>
<div role='presentation' aria-label='example' />
</AnchorPortal>,
);
expect(baseElement.querySelector('#test-anchor')).toBeInTheDocument();
render(
<AnchorPortal id='test-anchor'>
<div role='presentation' aria-label='example' />
</AnchorPortal>,
);
unmount();
expect(baseElement.querySelector('#test-anchor')).toBeInTheDocument();
});

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { ensureAnchorElement, refAnchorElement, unrefAnchorElement } from '../helpers/anchors';
export type AnchorPortalProps = {
id: string;
children: ReactNode;
};
const AnchorPortal = ({ id, children }: AnchorPortalProps) => {
const anchorElement = ensureAnchorElement(id);
useLayoutEffect(() => {
refAnchorElement(anchorElement);
return () => {
unrefAnchorElement(anchorElement);
};
}, [anchorElement]);
return <>{createPortal(children, anchorElement)}</>;
};
export default AnchorPortal;

View File

@@ -0,0 +1,23 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './AnnouncementBanner.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
/**
** We are disabling the rule in this case because ideally we should not have nested interactive
** but in this case we need to open a modal when clicking in the `AnnouncementBanner`
**/
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import AnnouncementBanner from './AnnouncementBanner';
export default {
component: AnnouncementBanner,
args: {
onClick: action('clicked'),
},
} satisfies Meta<typeof AnnouncementBanner>;
export const Default: StoryObj<typeof AnnouncementBanner> = {
args: {
children: 'Announcement',
},
};
export const WithLink: StoryObj<typeof AnnouncementBanner> = {
args: {
children: (
<a target='_blank' href='https://rocket.chat' rel='noreferrer'>
Announcement
</a>
),
},
};

View File

@@ -0,0 +1,51 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes, ReactNode, MouseEvent } from 'react';
export type AnnouncementBannerProps = {
children: ReactNode;
onClick?: (e: MouseEvent) => void;
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
const AnnouncementBanner = ({ children, className, onClick, ...props }: AnnouncementBannerProps) => {
const announcementBar = css`
background-color: ${Palette.status['status-background-info'].theme('announcement-background')};
color: ${Palette.text['font-pure-black'].theme('announcement-text')};
cursor: pointer;
transition: transform 0.2s ease-out;
a:link {
color: ${Palette.text['font-pure-black'].theme('announcement-text')};
text-decoration: underline;
}
> * {
flex: auto;
}
&:hover {
text-decoration: underline;
}
`;
return (
<Box
focusable
height='x40'
paddingInline={24}
alignItems='center'
display='flex'
fontScale='p2m'
textAlign='center'
borderRadius={0}
className={[announcementBar, className]}
tabIndex={onClick ? 0 : -1}
role={onClick ? 'button' : 'banner'}
onClick={onClick}
{...props}
>
<Box withTruncatedText width='none'>
{children}
</Box>
</Box>
);
};
export default AnnouncementBanner;

View File

@@ -0,0 +1,43 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-box--focusable rcx-css-1itjqx1 rcx-css-k3fjzq"
role="button"
tabindex="0"
>
<div
class="rcx-box rcx-box--full rcx-css-1n09zs3"
>
Announcement
</div>
</div>
</div>
</body>
`;
exports[`renders WithLink without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-box--focusable rcx-css-1itjqx1 rcx-css-k3fjzq"
role="button"
tabindex="0"
>
<div
class="rcx-box rcx-box--full rcx-css-1n09zs3"
>
<a
href="https://rocket.chat"
rel="noreferrer"
target="_blank"
>
Announcement
</a>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export { default } from './AnnouncementBanner';

View File

@@ -0,0 +1,12 @@
import { ContextualbarAction, type ContextualbarActionProps } from '@rocket.chat/fuselage';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export type ContextualbarBackProps = Partial<ContextualbarActionProps>;
const ContextualbarBack = (props: ContextualbarBackProps) => {
const { t } = useTranslation();
return <ContextualbarAction {...props} title={t('Back')} name='arrow-back' />;
};
export default memo(ContextualbarBack);

View File

@@ -0,0 +1,12 @@
import { ContextualbarAction, type ContextualbarActionProps } from '@rocket.chat/fuselage';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export type ContextualbarCloseProps = Partial<ContextualbarActionProps>;
const ContextualbarClose = (props: ContextualbarCloseProps) => {
const { t } = useTranslation();
return <ContextualbarAction data-qa='ContextualbarActionClose' {...props} aria-label={t('Close')} name='cross' />;
};
export default memo(ContextualbarClose);

View File

@@ -0,0 +1,48 @@
import type { AriaDialogProps } from '@react-aria/dialog';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { Contextualbar, type ContextualbarProps } from '@rocket.chat/fuselage';
import { useLayoutSizes, useLayoutContextualBarPosition, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useCallback, useRef } from 'react';
import ContextualbarResizable from './ContextualbarResizable';
export type ContextualbarDialogProps = AriaDialogProps & ContextualbarProps & { onClose?: () => void };
/**
* @prop onClose can be used to close contextualbar outside the room context with ESC key
* */
const ContextualbarDialog = ({ onClose, ...props }: ContextualbarDialogProps) => {
const ref = useRef<HTMLElement | null>(null);
const { dialogProps } = useDialog({ 'aria-labelledby': 'contextualbarTitle', ...props }, ref);
const { contextualBar } = useLayoutSizes();
const position = useLayoutContextualBarPosition();
const { closeTab } = useRoomToolbox();
const closeContextualbar = onClose ?? closeTab;
const callbackRef = useCallback(
(node: HTMLElement | null) => {
if (!node) {
return;
}
ref.current = node;
node.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeContextualbar();
}
});
},
[closeContextualbar],
);
return (
<FocusScope autoFocus restoreFocus>
<ContextualbarResizable defaultWidth={contextualBar}>
<Contextualbar ref={callbackRef} width='100%' position={position} {...dialogProps} {...props} />
</ContextualbarResizable>
</FocusScope>
);
};
export default ContextualbarDialog;

View File

@@ -0,0 +1,8 @@
import { ContextualbarHeader as FuselageContextualbarHeader } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import { memo } from 'react';
const ContextualbarHeader = (props: ComponentPropsWithoutRef<typeof FuselageContextualbarHeader>) => (
<FuselageContextualbarHeader marginBlockStart={-1} {...props} />
);
export default memo(ContextualbarHeader);

View File

@@ -0,0 +1,9 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { memo } from 'react';
const ContextualbarInnerContent = (props: ComponentProps<typeof Box>) => (
<Box rcx-vertical-bar--inner-content position='absolute' height='full' display='flex' insetInline={0} {...props} />
);
export default memo(ContextualbarInnerContent);

View File

@@ -0,0 +1,41 @@
import { css } from '@rocket.chat/css-in-js';
import { Palette, Box } from '@rocket.chat/fuselage';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { Resizable } from 're-resizable';
import type { ComponentProps } from 'react';
export type ContextualbarResizableProps = { defaultWidth: string } & ComponentProps<typeof Resizable>;
const ContextualbarResizable = ({ defaultWidth, children, ...props }: ContextualbarResizableProps) => {
const [contextualbarWidth, setContextualbarWidth] = useLocalStorage('contextualbarWidth', defaultWidth);
const [expanded] = useLocalStorage('expand-threads', false);
const handleStyle = css`
height: 100%;
&:hover {
background-color: ${Palette.stroke['stroke-highlight']};
}
`;
return (
<Resizable
{...props}
onResize={(_e: any, _dir: any, elRef: HTMLElement) => {
setContextualbarWidth(elRef.style.width);
}}
defaultSize={{
width: contextualbarWidth,
height: '100%',
}}
minWidth={defaultWidth}
maxWidth='50%'
minHeight='100%'
handleStyles={{ left: { width: '3px', zIndex: expanded ? 5 : 99, left: 0 } }}
handleComponent={{ left: <Box className={handleStyle} /> }}
>
{children}
</Resizable>
);
};
export default ContextualbarResizable;

View File

@@ -0,0 +1,17 @@
import { Margins } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { forwardRef, memo } from 'react';
import { PageScrollableContent } from '../Page';
const ContextualbarScrollableContent = forwardRef<HTMLElement, ComponentProps<typeof PageScrollableContent>>(
function ContextualbarScrollableContent({ children, ...props }, ref) {
return (
<PageScrollableContent paddingInline={16} {...props} ref={ref}>
<Margins blockEnd={16}>{children}</Margins>
</PageScrollableContent>
);
},
);
export default memo(ContextualbarScrollableContent);

View File

@@ -0,0 +1,10 @@
import ContextualbarDialog from './ContextualbarDialog';
import ContextualbarSkeletonBody from './ContextualbarSkeletonBody';
const ContextualbarSkeleton = ({ onClose }: { onClose?: () => void }) => (
<ContextualbarDialog onClose={onClose}>
<ContextualbarSkeletonBody />
</ContextualbarDialog>
);
export default ContextualbarSkeleton;

View File

@@ -0,0 +1,21 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import ContextualbarHeader from './ContextualbarHeader';
const ContextualbarSkeletonBody = () => (
<>
<ContextualbarHeader>
<Skeleton width='100%' />
</ContextualbarHeader>
<Box padding={24}>
<Skeleton marginBlockEnd={4} width='x32' height='x32' variant='rect' />
{Array(5)
.fill(5)
.map((_, index) => (
<Skeleton key={index} />
))}
</Box>
</>
);
export default ContextualbarSkeletonBody;

View File

@@ -0,0 +1,8 @@
import { ContextualbarTitle as FuselageContextualbarTitle } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const ContextualbarTitle = (props: ComponentProps<typeof FuselageContextualbarTitle>) => (
<FuselageContextualbarTitle id='contextualbarTitle' {...props} />
);
export default ContextualbarTitle;

View File

@@ -0,0 +1,21 @@
export {
Contextualbar,
ContextualbarAction,
ContextualbarActions,
ContextualbarButton,
ContextualbarContent,
ContextualbarEmptyContent,
ContextualbarFooter,
ContextualbarIcon,
ContextualbarSection,
} from '@rocket.chat/fuselage';
export { default as ContextualbarBack } from './ContextualbarBack';
export { default as ContextualbarClose } from './ContextualbarClose';
export { default as ContextualbarDialog } from './ContextualbarDialog';
export { default as ContextualbarHeader } from './ContextualbarHeader';
export { default as ContextualbarInnerContent } from './ContextualbarInnerContent';
export { default as ContextualbarScrollableContent } from './ContextualbarScrollableContent';
export { default as ContextualbarSkeleton } from './ContextualbarSkeleton';
export { default as ContextualbarSkeletonBody } from './ContextualbarSkeletonBody';
export { default as ContextualbarTitle } from './ContextualbarTitle';

View File

@@ -0,0 +1,139 @@
import type { CustomFieldMetadata } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Control } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { CustomFieldsForm } from './CustomFieldsForm';
type TestComponentProps = {
metadata: CustomFieldMetadata[];
formName: string;
onSubmit: (data: any) => void;
};
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Required_field: '{{field}} required',
})
.build();
const TestComponent = ({ metadata, formName, onSubmit }: TestComponentProps) => {
const { control, handleSubmit } = useForm({ mode: 'onBlur' });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<CustomFieldsForm formName={formName} formControl={control as unknown as Control<any>} metadata={metadata} />
<button type='submit'>Submit</button>
</form>
);
};
describe('CustomFieldsForm', () => {
it('should render all custom fields', () => {
const metadata: CustomFieldMetadata[] = [
{ name: 'field1', type: 'text', label: 'Field 1', required: true, defaultValue: '', options: [] },
{
name: 'field2',
type: 'select',
label: 'Field 2',
required: false,
defaultValue: 'a',
options: [
['a', 'a'],
['b', 'b'],
],
},
];
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />);
expect(screen.getByRole('textbox', { name: 'Field 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Field 2/ })).toBeInTheDocument();
});
it('should render a text input', () => {
const metadata: CustomFieldMetadata[] = [
{ name: 'field1', type: 'text', label: 'Field 1', required: true, defaultValue: '', options: [] },
];
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByRole('textbox', { name: 'Field 1' })).toBeInTheDocument();
});
it('should render a select input', () => {
const metadata: CustomFieldMetadata[] = [
{
name: 'field2',
type: 'select',
label: 'Field 2',
required: false,
defaultValue: 'a',
options: [
['a', 'a'],
['b', 'b'],
],
},
];
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByRole('button', { name: /Field 2/ })).toBeInTheDocument();
});
it('should show required error message', async () => {
const metadata: CustomFieldMetadata[] = [
{ name: 'field1', type: 'text', label: 'Field 1', required: true, defaultValue: '', options: [] },
];
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />);
const input = screen.getByRole('textbox', { name: 'Field 1' });
await userEvent.click(input);
await userEvent.tab();
await waitFor(() => expect(input).toHaveAccessibleDescription('Field 1 required'));
});
it('should show minLength error message', async () => {
const metadata: CustomFieldMetadata[] = [
{ name: 'field1', type: 'text', label: 'Field 1', required: true, minLength: 5, defaultValue: '', options: [] },
];
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />, { wrapper: appRoot });
const input = screen.getByRole('textbox', { name: 'Field 1' });
await userEvent.type(input, '123');
await userEvent.tab();
await waitFor(() => expect(input).toHaveAccessibleDescription('Min_length_is'));
});
it('should validate maxLength', async () => {
const metadata: CustomFieldMetadata[] = [
{ name: 'field1', type: 'text', label: 'Field 1', required: true, maxLength: 3, defaultValue: '', options: [] },
];
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />, { wrapper: appRoot });
const input = screen.getByRole('textbox', { name: 'Field 1' });
await userEvent.type(input, '123456');
await userEvent.tab();
expect(input).toHaveValue('123');
});
it('should not throw when attempting to render invalid field type', () => {
const metadata: CustomFieldMetadata[] = [
{ name: 'field1', type: 'invalid_type' as any, label: 'Field 1', required: true, defaultValue: '', options: [] },
];
expect(() =>
render(<TestComponent metadata={metadata} formName='testForm' onSubmit={jest.fn()} />, { wrapper: appRoot }),
).not.toThrow();
expect(screen.queryByLabelText('Field 1')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
import type { CustomFieldMetadata } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Field, FieldLabel, FieldRow, FieldError, Select, TextInput, FieldGroup } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useCallback, useId, useMemo } from 'react';
import type { Control, FieldValues, FieldError as RHFFieldError } from 'react-hook-form';
import { Controller, useFormState, get } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type CustomFieldFormProps<T extends FieldValues> = {
metadata: CustomFieldMetadata[];
formControl: Control<T>;
formName: string;
};
type CustomFieldProps<T extends FieldValues> = Omit<CustomFieldMetadata, 'name'> & {
control: Control<T>;
name: string;
};
const FIELD_TYPES = {
select: Select,
text: TextInput,
} as const;
const CustomField = <T extends FieldValues>({
name,
type,
control,
label,
required,
defaultValue,
options = [],
...props
}: CustomFieldProps<T>) => {
const { t } = useTranslation();
const { errors } = useFormState({ control });
const fieldId = useId();
const Component = FIELD_TYPES[type] ?? null;
const selectOptions = useMemo(
() =>
options.length > 0 && options[0] instanceof Array ? options : options.map((option) => [option, option, defaultValue === option]),
[defaultValue, options],
);
const validateRequired = useCallback((value: string) => (required ? typeof value === 'string' && !!value.trim() : true), [required]);
const getErrorMessage = useCallback(
(error: RHFFieldError) => {
switch (error?.type) {
case 'required':
return t('Required_field', { field: label || name });
case 'minLength':
return t('Min_length_is', { limit: props?.minLength });
case 'maxLength':
return t('Max_length_is', { limit: props?.maxLength });
default:
return error?.message || '';
}
},
[label, name, props?.maxLength, props?.minLength, t],
);
const error = get(errors, name);
const errorMessage = useMemo(() => getErrorMessage(error), [error, getErrorMessage]);
if (!Component) {
return null;
}
return (
<Controller<T, any>
name={name}
control={control}
defaultValue={defaultValue ?? ''}
rules={{ minLength: props.minLength, maxLength: props.maxLength, validate: { required: validateRequired } }}
render={({ field }) => (
<Field rcx-field-group__item>
<FieldLabel is='span' id={fieldId} required={required}>
{label || t(name as TranslationKey)}
</FieldLabel>
<FieldRow>
<Component
{...props}
{...field}
aria-labelledby={fieldId}
aria-describedby={errorMessage && `${fieldId}-error`}
error={errorMessage}
options={selectOptions as SelectOption[]}
flexGrow={1}
/>
</FieldRow>
{errorMessage ? (
<FieldError aria-live='assertive' id={`${fieldId}-error`}>
{errorMessage}
</FieldError>
) : null}
</Field>
)}
/>
);
};
// eslint-disable-next-line react/no-multi-comp
export const CustomFieldsForm = <T extends FieldValues>({ formName, formControl, metadata }: CustomFieldFormProps<T>) => {
const { t } = useTranslation();
return (
<FieldGroup aria-label={t('Custom_Fields')}>
{metadata.map(({ name: fieldName, label, ...props }) => (
<CustomField key={fieldName} name={`${formName}.${fieldName}`} control={formControl} label={label ?? fieldName} {...props} />
))}
</FieldGroup>
);
};

View File

@@ -0,0 +1,32 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { forwardRef, memo } from 'react';
import 'overlayscrollbars/styles/overlayscrollbars.css';
export const getScrollbarsOptions = (overflowX?: boolean) =>
({
scrollbars: { autoHide: 'move' },
overflow: { x: overflowX ? 'scroll' : 'hidden' },
}) as const;
const BaseScrollbars = forwardRef<HTMLElement, ComponentProps<typeof Box>>(function BaseScrollbars(props, ref) {
return (
<Box
ref={ref}
height='full'
width='full'
className={css`
.os-scrollbar {
--os-handle-bg: ${Palette.stroke['stroke-dark']};
--os-handle-bg-hover: ${Palette.stroke['stroke-dark']};
--os-handle-bg-active: ${Palette.stroke['stroke-dark']};
}
`}
{...props}
/>
);
});
export default memo(BaseScrollbars);

View File

@@ -0,0 +1,49 @@
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { HTMLAttributes, ReactNode } from 'react';
import { useEffect, useRef, forwardRef, memo } from 'react';
import type { OverlayScrollbars } from '.';
import BaseScrollbars, { getScrollbarsOptions } from './BaseScrollbars';
export type CustomScrollbarsProps = {
children: ReactNode;
overflowX?: boolean;
onScroll?: (args: OverlayScrollbars) => void;
} & Omit<HTMLAttributes<HTMLDivElement>, 'is' | 'onScroll'>;
const CustomScrollbars = forwardRef<HTMLElement, CustomScrollbarsProps>(function CustomScrollbars({ overflowX, onScroll, ...props }, ref) {
const rootRef = useRef(null);
const scrollbarsOptions = getScrollbarsOptions(overflowX);
const [initialize, osInstance] = useOverlayScrollbars({
options: scrollbarsOptions,
events: {
scroll: (args) => onScroll?.(args),
},
});
useEffect(() => {
const { current: root } = rootRef;
if (root) {
initialize({ target: root });
const instance = osInstance();
if (!instance || !ref) {
return;
}
if (typeof ref === 'function') {
ref(instance.elements().viewport || null);
return;
}
ref.current = instance.elements().viewport || null;
}
return () => osInstance()?.destroy();
}, [initialize, osInstance, ref]);
return <BaseScrollbars ref={rootRef} {...props} />;
});
export default memo(CustomScrollbars);

View File

@@ -0,0 +1,48 @@
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { HTMLAttributes, ReactNode } from 'react';
import { useEffect, memo, forwardRef, useRef } from 'react';
import BaseScrollbars from './BaseScrollbars';
export type CustomScrollbarsProps = {
children: ReactNode;
} & Omit<HTMLAttributes<HTMLDivElement>, 'is' | 'onScroll'>;
const CustomVirtuaScrollbars = forwardRef<HTMLElement, CustomScrollbarsProps>(function CustomScrollbars({ ...props }, ref) {
const rootRef = useRef<HTMLElement | null>(null);
const [initialize] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
// force overflow styles
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
if (typeof ref === 'function') {
ref(viewport);
} else if (ref) {
ref.current = viewport;
}
},
},
});
useEffect(() => {
const { current: root } = rootRef;
if (root?.firstElementChild && root.firstElementChild instanceof HTMLElement) {
initialize({
target: root,
elements: {
viewport: root.firstElementChild,
},
});
}
}, [initialize]);
return <BaseScrollbars ref={rootRef} {...props} />;
});
export default memo(CustomVirtuaScrollbars);

View File

@@ -0,0 +1,42 @@
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { HTMLAttributes, ReactElement } from 'react';
import { useEffect, useState, useRef, cloneElement, forwardRef, memo } from 'react';
import BaseScrollbars, { getScrollbarsOptions } from './BaseScrollbars';
export type VirtualizedScrollbarsProps = {
overflowX?: boolean;
children: ReactElement<any>;
} & Omit<HTMLAttributes<HTMLDivElement>, 'is'>;
const VirtualizedScrollbars = forwardRef<HTMLElement, VirtualizedScrollbarsProps>(function VirtualizedScrollbars(
{ overflowX, ...props },
ref,
) {
const rootRef = useRef(null);
const [scroller, setScroller] = useState(null);
const scrollbarsOptions = getScrollbarsOptions(overflowX);
const [initialize, osInstance] = useOverlayScrollbars({
options: scrollbarsOptions,
defer: true,
});
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [initialize, osInstance, ref, scroller]);
return <BaseScrollbars ref={rootRef}>{cloneElement(props.children, { tabIndex: -1, scrollerRef: setScroller })}</BaseScrollbars>;
});
export default memo(VirtualizedScrollbars);

View File

@@ -0,0 +1,6 @@
import { OverlayScrollbars } from 'overlayscrollbars';
export { OverlayScrollbars };
export { default as CustomScrollbars } from './CustomScrollbars';
export { default as VirtualizedScrollbars } from './VirtualizedScrollbars';
export { default as CustomVirtuaScrollbars } from './CustomVirtuaScrollbars';

View File

@@ -0,0 +1,19 @@
import { Box } from '@rocket.chat/fuselage';
import type { Meta, StoryObj } from '@storybook/react';
import DotLeader from './DotLeader';
export default {
component: DotLeader,
} satisfies Meta<typeof DotLeader>;
export const Default: StoryObj<typeof DotLeader> = {
name: 'DotLeader',
render: (args) => (
<Box display='flex' flexDirection='row'>
Label
<DotLeader {...args} />
12345
</Box>
),
};

View File

@@ -0,0 +1,21 @@
import { Box } from '@rocket.chat/fuselage';
import type { CSSProperties } from 'react';
export type DotLeaderProps = {
color?: CSSProperties['borderColor'];
dotSize?: CSSProperties['borderBlockEndWidth'];
};
const DotLeader = ({ color = 'neutral-300', dotSize = 'x2' }: DotLeaderProps) => (
<Box
flexGrow={1}
height='full'
alignSelf='flex-end'
borderBlockEndStyle='dotted'
borderBlockEndWidth={dotSize}
margin={2}
borderColor={color}
/>
);
export default DotLeader;

View File

@@ -0,0 +1,12 @@
import { ButtonGroup, Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type EmojiPickerCategoryHeaderProps = ComponentPropsWithoutRef<typeof ButtonGroup>;
const EmojiPickerCategoryHeader = (props: EmojiPickerCategoryHeaderProps) => (
<Box marginBlockStart={12} marginInline={12}>
<ButtonGroup small stretch {...props} />
</Box>
);
export default EmojiPickerCategoryHeader;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type EmojiPickerCategoryWrapperProps = ComponentPropsWithoutRef<typeof Box>;
const EmojiPickerCategoryWrapper = (props: EmojiPickerCategoryWrapperProps) => <Box {...props} display='flex' flexWrap='wrap' />;
export default EmojiPickerCategoryWrapper;

View File

@@ -0,0 +1,23 @@
import { Box } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
import { forwardRef } from 'react';
const EmojiPickerContainer = forwardRef<HTMLElement, Omit<AllHTMLAttributes<HTMLDivElement>, 'is' | 'style'>>(
function EmojiPickerContainer(props, ref) {
return (
<Box
{...props}
color='default'
ref={ref}
height='x480'
backgroundColor='light'
borderRadius={4}
display='flex'
flexDirection='column'
marginBlock='neg-x12'
/>
);
},
);
export default EmojiPickerContainer;

View File

@@ -0,0 +1,17 @@
import { Box } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
const EmojiPickerFooter = (props: Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>) => (
<Box
{...props}
backgroundColor='neutral'
display='flex'
flexDirection='column'
alignItems='center'
color='secondary-info'
fontScale='micro'
paddingBlock={8}
/>
);
export default EmojiPickerFooter;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
const EmojiPickerHeader = (props: Omit<AllHTMLAttributes<HTMLDivElement>, 'is' | 'style'>) => (
<Box {...props} display='flex' alignItems='center' paddingBlockStart={12} paddingInline={12} />
);
export default EmojiPickerHeader;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
const EmojiPickerListArea = (props: Omit<AllHTMLAttributes<HTMLDivElement>, 'is' | 'style'>) => (
<Box {...props} width='full' height='full' paddingInlineStart={12} overflow='hidden' />
);
export default EmojiPickerListArea;

View File

@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EmojiPickerLoadMore from './EmojiPickerLoadMore';
describe('EmojiPickerLoadMore', () => {
it('should render children inside the button', () => {
render(<EmojiPickerLoadMore>Load More Emojis</EmojiPickerLoadMore>);
expect(screen.getByRole('button', { name: /load more emojis/i })).toBeInTheDocument();
});
it('should call onClick when button is clicked', async () => {
const handleClick = jest.fn();
render(<EmojiPickerLoadMore onClick={handleClick}>Load More</EmojiPickerLoadMore>);
await userEvent.click(screen.getByRole('button', { name: /load more/i }));
expect(handleClick).toHaveBeenCalled();
});
it('should pass additional props to the button', () => {
render(<EmojiPickerLoadMore aria-label='custom-label'>More</EmojiPickerLoadMore>);
expect(screen.getByRole('button', { name: 'custom-label' })).toHaveAttribute('aria-label', 'custom-label');
});
});

View File

@@ -0,0 +1,12 @@
import { Box, Button } from '@rocket.chat/fuselage';
import type { ButtonHTMLAttributes } from 'react';
const EmojiPickerLoadMore = ({ children, ...props }: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style' | 'is'>) => (
<Box display='flex' flexDirection='column' alignItems='center' marginBlockEnd={8}>
<Button {...props} size='small'>
{children}
</Button>
</Box>
);
export default EmojiPickerLoadMore;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
const EmojiPickerNotFound = (props: Omit<AllHTMLAttributes<HTMLDivElement>, 'is' | 'style'>) => (
<Box {...props} display='flex' flexDirection='column' alignItems='center' fontScale='c1' marginBlock={8} width='full' />
);
export default EmojiPickerNotFound;

View File

@@ -0,0 +1,35 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import DOMPurify from 'dompurify';
import type { AllHTMLAttributes } from 'react';
export type EmojiPickerPreviewProps = { emoji: string; name: string } & Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>;
const EmojiPickerPreview = ({ emoji, name, ...props }: EmojiPickerPreviewProps) => {
const previewEmojiClass = css`
span {
font-size: 2.5rem;
}
span.emoji--custom {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
`;
return (
<Box {...props} display='flex' alignItems='center'>
<Box className={previewEmojiClass} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emoji) }}></Box>
<Box marginInlineStart={4} display='flex' flexDirection='column' maxWidth='x160'>
<Box fontScale='c2' withTruncatedText>
{name}
</Box>
<Box fontScale='c1' withTruncatedText>{`:${name}:`}</Box>
</Box>
</Box>
);
};
export default EmojiPickerPreview;

View File

@@ -0,0 +1,17 @@
import { Box } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
const EmojiPickerPreviewArea = (props: Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>) => (
<Box
{...props}
padding={12}
backgroundColor='tint'
color='secondary-info'
display='flex'
alignItems='center'
justifyContent='space-between'
minHeight='x64'
/>
);
export default EmojiPickerPreviewArea;

View File

@@ -0,0 +1,10 @@
export { default as EmojiPickerCategoryHeader } from './EmojiPickerCategoryHeader';
export { default as EmojiPickerCategoryWrapper } from './EmojiPickerCategoryWrapper';
export { default as EmojiPickerContainer } from './EmojiPickerContainer';
export { default as EmojiPickerFooter } from './EmojiPickerFooter';
export { default as EmojiPickerHeader } from './EmojiPickerHeader';
export { default as EmojiPickerListArea } from './EmojiPickerListArea';
export { default as EmojiPickerLoadMore } from './EmojiPickerLoadMore';
export { default as EmojiPickerNotFound } from './EmojiPickerNotFound';
export { default as EmojiPickerPreviewArea } from './EmojiPickerPreviewArea';
export { default as EmojiPickerPreview } from './EmojiPickerPreview';

View File

@@ -0,0 +1,12 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type ExternalLinkProps = {
to: string;
} & ComponentPropsWithoutRef<typeof Box>;
export const ExternalLink = ({ children, to, ...props }: ExternalLinkProps) => (
<Box is='a' href={to} target='_blank' rel='noopener noreferrer' {...props}>
{children || to}
</Box>
);

View File

@@ -0,0 +1,37 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import FeaturePreview from './FeaturePreview';
import FeaturePreviewOff from './FeaturePreviewOff';
import FeaturePreviewOn from './FeaturePreviewOn';
test('should renders off if the feature is disabled', async () => {
render(
<FeaturePreview feature='secondarySidebar'>
<FeaturePreviewOn>on</FeaturePreviewOn>
<FeaturePreviewOff>off</FeaturePreviewOff>
</FeaturePreview>,
{
wrapper: mockAppRoot().withSetting('Accounts_AllowFeaturePreview', true).build(),
},
);
expect(screen.getByText('off')).toBeInTheDocument();
});
test('should renders on if the feature is enabled', async () => {
render(
<FeaturePreview feature='secondarySidebar'>
<FeaturePreviewOn>on</FeaturePreviewOn>
<FeaturePreviewOff>off</FeaturePreviewOff>
</FeaturePreview>,
{
wrapper: mockAppRoot()
.withSetting('Accounts_AllowFeaturePreview', true)
.withUserPreference('featuresPreview', [{ name: 'secondarySidebar', value: true }])
.build(),
},
);
expect(screen.getByText('on')).toBeInTheDocument();
});

View File

@@ -0,0 +1,25 @@
import type { ReactElement } from 'react';
import { Children, Suspense, cloneElement } from 'react';
import { useFeaturePreview } from '../../hooks/useFeaturePreview';
import type { FeaturesAvailable } from '../../hooks/useFeaturePreviewList';
export type FeaturePreviewProps = {
feature: FeaturesAvailable;
disabled?: boolean;
children: ReactElement<{ featureToggleEnabled?: boolean }>[];
};
const FeaturePreview = ({ feature, disabled = false, children }: FeaturePreviewProps) => {
const featureToggleEnabled = useFeaturePreview(feature) && !disabled;
const toggledChildren = Children.map(children, (child) =>
cloneElement(child, {
featureToggleEnabled,
}),
);
return <Suspense fallback={null}>{toggledChildren}</Suspense>;
};
export default FeaturePreview;

View File

@@ -0,0 +1,21 @@
import { Badge } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { usePreferenceFeaturePreviewList } from '../../hooks/usePreferenceFeaturePreviewList';
const FeaturePreviewBadge = () => {
const { t } = useTranslation();
const { unseenFeatures } = usePreferenceFeaturePreviewList();
if (!unseenFeatures) {
return null;
}
return (
<Badge variant='primary' aria-label={t('Unseen_features')}>
{unseenFeatures}
</Badge>
);
};
export default FeaturePreviewBadge;

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
export type FeaturePreviewOnProps = {
children: ReactNode;
featureToggleEnabled?: boolean;
};
const FeaturePreviewOff = ({ children, featureToggleEnabled }: FeaturePreviewOnProps) => <>{!featureToggleEnabled && children}</>;
export default FeaturePreviewOff;

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
export type FeaturePreviewOnProps = {
children: ReactNode;
featureToggleEnabled?: boolean;
};
const FeaturePreviewOn = ({ children, featureToggleEnabled }: FeaturePreviewOnProps) => <>{featureToggleEnabled && children}</>;
export default FeaturePreviewOn;

View File

@@ -0,0 +1,4 @@
export { default as FeaturePreview } from './FeaturePreview';
export { default as FeaturePreviewBadge } from './FeaturePreviewBadge';
export { default as FeaturePreviewOn } from './FeaturePreviewOn';
export { default as FeaturePreviewOff } from './FeaturePreviewOff';

View File

@@ -0,0 +1,26 @@
import { Box, Icon } from '@rocket.chat/fuselage';
export type FilePreviewIconProps = { format: string };
const FilePreviewIcon = ({ format }: FilePreviewIconProps) => {
return (
<Box
width='x48'
height='x48'
borderRadius={4}
backgroundColor='surface-neutral'
justifyContent='center'
display='flex'
flexDirection='column'
alignItems='center'
flexShrink={0}
>
<Icon name='attachment-file' color='default' size={32} />
<Box withTruncatedText fontScale='micro' maxWidth='x40' color='default' textTransform='uppercase'>
{format}
</Box>
</Box>
);
};
export default FilePreviewIcon;

View File

@@ -0,0 +1,59 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import GenericMenu from './GenericMenu';
const mockedFunction = jest.fn();
const regular = {
items: [
{
id: 'edit',
content: 'Edit',
icon: 'pencil' as const,
onClick: mockedFunction,
},
],
};
const danger = {
items: [
{
id: 'delete',
content: 'Delete',
icon: 'trash' as const,
onClick: () => null,
variant: 'danger',
},
],
};
const sections = [regular, danger];
describe('Room Actions Menu', () => {
it('should render kebab menu with the list content', async () => {
render(<GenericMenu title='Kebab' sections={sections} />, { wrapper: mockAppRoot().build() });
await userEvent.click(screen.getByRole('button'));
expect(await screen.findByText('Edit')).toBeInTheDocument();
expect(await screen.findByText('Delete')).toBeInTheDocument();
});
it('should have two different sections, regular and danger', async () => {
render(<GenericMenu title='Kebab' sections={sections} />, { wrapper: mockAppRoot().build() });
await userEvent.click(screen.getByRole('button'));
expect(screen.getAllByRole('presentation')).toHaveLength(2);
expect(screen.getByRole('separator')).toBeInTheDocument();
});
it('should call the action when item clicked', async () => {
render(<GenericMenu title='Kebab' sections={sections} />, { wrapper: mockAppRoot().build() });
await userEvent.click(screen.getByRole('button'));
await userEvent.click(screen.getAllByRole('menuitem')[0]);
expect(mockedFunction).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,108 @@
import { IconButton, MenuItem, MenuSection, Menu } from '@rocket.chat/fuselage';
import { cloneElement, type ComponentProps, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { GenericMenuItemProps } from './GenericMenuItem';
import GenericMenuItem from './GenericMenuItem';
import { useHandleMenuAction } from './hooks/useHandleMenuAction';
type GenericMenuCommonProps = {
title: string;
icon?: ComponentProps<typeof IconButton>['icon'];
disabled?: boolean;
callbackAction?: () => void;
isOpen?: boolean;
};
type GenericMenuConditionalProps =
| {
sections?: {
title?: ReactNode;
items: GenericMenuItemProps[];
permission?: boolean | '' | 0 | null | undefined;
}[];
items?: never;
}
| {
items?: GenericMenuItemProps[];
sections?: never;
};
export type GenericMenuProps = GenericMenuCommonProps & GenericMenuConditionalProps & Omit<ComponentProps<typeof Menu>, 'children'>;
const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, button, className, ...props }: GenericMenuProps) => {
const { t, i18n } = useTranslation();
const sections = 'sections' in props && props.sections;
const items = 'items' in props && props.items;
const itemsList = sections ? sections.flatMap(({ items }) => items) : items || [];
const disabledKeys = itemsList.filter(({ disabled }) => disabled).map(({ id }) => id);
const handleAction = useHandleMenuAction(itemsList || [], callbackAction);
const hasIcon = itemsList.some(({ icon }) => icon);
const handleItems = (items: GenericMenuItemProps[]) =>
hasIcon ? items.map((item) => ({ ...item, gap: item.gap ?? (!item.icon && !item.status) })) : items;
const isMenuEmpty = !(sections && sections.length > 0) && !(items && items.length > 0);
if (isMenuEmpty || disabled) {
if (button) {
// FIXME: deprecate prop `button` as there's no way to ensure it is actually a button
// (e.g cloneElement could be passing props to a fragment)
return cloneElement(button, { small: true, icon, disabled, title, className } as any);
}
return <IconButton small icon={icon} className={className} title={title} disabled />;
}
return (
<>
{sections && (
<Menu
icon={icon}
title={i18n.exists(title) ? t(title) : title}
onAction={onAction || handleAction}
className={className}
button={button}
{...(disabledKeys && { disabledKeys })}
{...props}
>
{sections.map(({ title, items }, key) => (
<MenuSection
title={typeof title === 'string' && i18n.exists(title) ? t(title) : title}
items={handleItems(items)}
key={`${title}-${key}`}
>
{(item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
)}
</MenuSection>
))}
</Menu>
)}
{items && (
<Menu
icon={icon}
title={i18n.exists(title) ? t(title) : title}
onAction={onAction || handleAction}
className={className}
button={button}
{...(disabledKeys && { disabledKeys })}
{...props}
>
{handleItems(items).map((item) => (
<MenuItem key={item.id}>
<GenericMenuItem {...item} />
</MenuItem>
))}
</Menu>
)}
</>
);
};
export default GenericMenu;

View File

@@ -0,0 +1,29 @@
import { MenuItemColumn, MenuItemContent, MenuItemIcon, MenuItemInput } from '@rocket.chat/fuselage';
import type { ComponentProps, MouseEvent, ReactNode } from 'react';
export type GenericMenuItemProps = {
id: string;
icon?: ComponentProps<typeof MenuItemIcon>['name'];
iconColor?: ComponentProps<typeof MenuItemIcon>['color'];
content?: ReactNode;
addon?: ReactNode;
onClick?: (e?: MouseEvent<HTMLElement>) => void;
status?: ReactNode;
disabled?: boolean;
description?: ReactNode;
gap?: boolean;
tooltip?: string;
variant?: string;
};
const GenericMenuItem = ({ icon, iconColor, content, addon, status, gap, tooltip }: GenericMenuItemProps) => (
<>
{gap && <MenuItemColumn />}
{icon && <MenuItemIcon name={icon} color={iconColor} />}
{status && <MenuItemColumn>{status}</MenuItemColumn>}
{content && <MenuItemContent title={tooltip}>{content}</MenuItemContent>}
{addon && <MenuItemInput>{addon}</MenuItemInput>}
</>
);
export default GenericMenuItem;

View File

@@ -0,0 +1,13 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { Key } from 'react';
import type { GenericMenuItemProps } from '../GenericMenuItem';
export const useHandleMenuAction = (items: GenericMenuItemProps[], callbackAction?: () => void) =>
useStableCallback((id: Key) => {
const item = items.find((item) => item.id === id && !!item.onClick);
if (item) {
item.onClick?.();
callbackAction?.();
}
});

View File

@@ -0,0 +1,3 @@
export { default as GenericMenu } from './GenericMenu';
export { default as GenericMenuItem, type GenericMenuItemProps } from './GenericMenuItem';
export { useHandleMenuAction } from './hooks/useHandleMenuAction';

View File

@@ -0,0 +1,77 @@
import { TextInput, Box, Icon } from '@rocket.chat/fuselage';
import type { Meta, StoryObj } from '@storybook/react';
import {
GenericTable,
GenericTableHeaderCell,
GenericTableCell,
GenericTableRow,
GenericTableHeader,
GenericTableBody,
GenericTableLoadingTable,
} from '.';
export default {
component: GenericTable,
parameters: {
layout: 'padded',
actions: { argTypesRegex: '^on.*' },
},
decorators: [
(fn) => <div style={{ height: '100vh', maxHeight: 300, display: 'flex', flexDirection: 'column', marginInline: 24 }}>{fn()}</div>,
],
} satisfies Meta<typeof GenericTable>;
const headers = (
<>
<GenericTableHeaderCell key='name'>Name</GenericTableHeaderCell>
<GenericTableHeaderCell key='email'>Email</GenericTableHeaderCell>
</>
);
const results = Array.from({ length: 10 }, (_, i) => ({
_id: i,
name: `John Doe #${i}`,
email: `john.doe.n${i}@example.com`,
}));
const filter = (
<>
<Box marginBlock={16} is='form' display='flex' flexDirection='column'>
<TextInput flexShrink={0} placeholder='Search...' endAddon={<Icon name='magnifier' size='x20' />} />
</Box>
</>
);
export const Default: StoryObj<typeof GenericTable> = {
render: () => (
<>
{filter}
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{results?.map(({ _id, name, email }: any) => (
<GenericTableRow key={_id}>
<GenericTableCell>{name}</GenericTableCell>
<GenericTableCell>{email}</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
</>
),
};
export const Loading: StoryObj<typeof GenericTable> = {
render: () => (
<>
{filter}
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingTable headerCells={2} />
</GenericTableBody>
</GenericTable>
</>
),
};

View File

@@ -0,0 +1,25 @@
import { Box, Table } from '@rocket.chat/fuselage';
import type { ComponentProps, ForwardedRef, ReactNode } from 'react';
import { forwardRef } from 'react';
import { CustomScrollbars } from '../CustomScrollbars';
export type GenericTableProps = {
fixed?: boolean;
children: ReactNode;
} & ComponentProps<typeof Table>;
export const GenericTable = forwardRef(function GenericTable(
{ fixed = true, children, ...props }: GenericTableProps,
ref: ForwardedRef<HTMLElement>,
) {
return (
<Box marginInline='neg-x24' paddingInline={24} flexShrink={1} flexGrow={1} ref={ref} overflow='hidden'>
<CustomScrollbars overflowX>
<Table fixed={fixed} sticky {...props}>
{children}
</Table>
</CustomScrollbars>
</Box>
);
});

View File

@@ -0,0 +1,6 @@
import { TableBody } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type GenericTableBodyProps = ComponentPropsWithoutRef<typeof TableBody>;
export const GenericTableBody = (props: GenericTableBodyProps) => <TableBody {...props} />;

View File

@@ -0,0 +1,6 @@
import { TableCell } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type GenericTableCellProps = ComponentPropsWithoutRef<typeof TableCell>;
export const GenericTableCell = (props: GenericTableCellProps) => <TableCell {...props} />;

View File

@@ -0,0 +1,12 @@
import { TableHead } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import { GenericTableRow } from './GenericTableRow';
export type GenericTableHeaderProps = ComponentPropsWithoutRef<typeof TableHead>;
export const GenericTableHeader = ({ children, ...props }: GenericTableHeaderProps) => (
<TableHead {...props}>
<GenericTableRow>{children}</GenericTableRow>
</TableHead>
);

View File

@@ -0,0 +1,31 @@
import { Box, TableCell } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import SortIcon from './SortIcon';
type GenericTableHeaderCellProps<T extends string> = Omit<ComponentProps<typeof Box>, 'onClick'> & {
active?: boolean;
direction?: 'asc' | 'desc';
sort?: T;
onClick?: (sort: T) => void;
};
export const GenericTableHeaderCell = <T extends string = string>({
children,
active,
direction,
sort,
onClick,
...props
}: GenericTableHeaderCellProps<T>) => {
const fn = useCallback(() => onClick && sort && onClick(sort), [sort, onClick]);
return (
<TableCell clickable={!!sort} onClick={fn} {...props}>
<Box display='flex' alignItems='center' wrap='no-wrap'>
{children}
{sort && <SortIcon direction={active ? direction : undefined} />}
</Box>
</TableCell>
);
};

View File

@@ -0,0 +1,22 @@
import { Box, Skeleton, TableRow, TableCell } from '@rocket.chat/fuselage';
export type GenericTableLoadingRowProps = { cols: number };
export const GenericTableLoadingRow = ({ cols }: GenericTableLoadingRowProps) => (
<TableRow>
<TableCell>
<Box display='flex'>
<Skeleton variant='rect' height={40} width={40} />
<Box marginInline={8} flexGrow={1}>
<Skeleton width='100%' />
<Skeleton width='100%' />
</Box>
</Box>
</TableCell>
{Array.from({ length: cols - 1 }, (_, i) => (
<TableCell key={i}>
<Skeleton width='100%' />
</TableCell>
))}
</TableRow>
);

View File

@@ -0,0 +1,11 @@
import { GenericTableLoadingRow } from './GenericTableLoadingRow';
export type GenericTableLoadingTableProps = { headerCells: number };
export const GenericTableLoadingTable = ({ headerCells }: GenericTableLoadingTableProps) => (
<>
{Array.from({ length: 10 }, (_, i) => (
<GenericTableLoadingRow key={i} cols={headerCells} />
))}
</>
);

View File

@@ -0,0 +1,6 @@
import { TableRow } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type GenericTableRowProps = ComponentPropsWithoutRef<typeof TableRow>;
export const GenericTableRow = (props: GenericTableRowProps) => <TableRow {...props} />;

View File

@@ -0,0 +1,26 @@
import { Box } from '@rocket.chat/fuselage';
export type SortIconProps = {
direction?: 'asc' | 'desc';
};
const SortIcon = ({ direction }: SortIconProps) => (
<Box is='svg' width='x16' height='x16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M5.33337 5.99999L8.00004 3.33333L10.6667 5.99999'
stroke={direction === 'desc' ? '#9EA2A8' : '#E4E7EA'}
strokeWidth='1.33333'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M5.33337 10L8.00004 12.6667L10.6667 10'
stroke={direction === 'asc' ? '#9EA2A8' : '#E4E7EA'}
strokeWidth='1.33333'
strokeLinecap='round'
strokeLinejoin='round'
/>
</Box>
);
export default SortIcon;

View File

@@ -0,0 +1,6 @@
export * from './useCurrent';
export * from './useItemsPerPage';
export * from './useItemsPerPageLabel';
export * from './usePagination';
export * from './useShowingResultsLabel';
export * from './useSort';

View File

@@ -0,0 +1,8 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
export const useCurrent = (currentInitialValue = 0): [number, Dispatch<SetStateAction<number>>] => {
const [current, setCurrent] = useState<number>(currentInitialValue);
return [current, setCurrent];
};

View File

@@ -0,0 +1,12 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
type UseItemsPerPageValue = 25 | 50 | 100;
export const useItemsPerPage = (
itemsPerPageInitialValue: UseItemsPerPageValue = 25,
): [UseItemsPerPageValue, Dispatch<SetStateAction<UseItemsPerPageValue>>] => {
const [itemsPerPage, setItemsPerPage] = useState<UseItemsPerPageValue>(itemsPerPageInitialValue);
return [itemsPerPage, setItemsPerPage];
};

View File

@@ -0,0 +1,7 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useItemsPerPageLabel = (): (() => string) => {
const { t } = useTranslation();
return useCallback(() => t('Items_per_page:'), [t]);
};

View File

@@ -0,0 +1,40 @@
import { useEffect, useMemo } from 'react';
import { useCurrent } from './useCurrent';
import { useItemsPerPage } from './useItemsPerPage';
import { useItemsPerPageLabel } from './useItemsPerPageLabel';
import { useShowingResultsLabel } from './useShowingResultsLabel';
/**
* TODO: Move `usePagination` outside from `GenericTable` folder
*/
export const usePagination = (): {
current: ReturnType<typeof useCurrent>[0];
setCurrent: ReturnType<typeof useCurrent>[1];
itemsPerPage: ReturnType<typeof useItemsPerPage>[0];
setItemsPerPage: ReturnType<typeof useItemsPerPage>[1];
itemsPerPageLabel: ReturnType<typeof useItemsPerPageLabel>;
showingResultsLabel: ReturnType<typeof useShowingResultsLabel>;
} => {
const [itemsPerPage, setItemsPerPage] = useItemsPerPage();
const [current, setCurrent] = useCurrent();
const itemsPerPageLabel = useItemsPerPageLabel();
const showingResultsLabel = useShowingResultsLabel();
// Reset to first page when itemsPerPage changes
useEffect(() => {
setCurrent(0);
}, [itemsPerPage, setCurrent]);
return useMemo(
() => ({
itemsPerPage,
setItemsPerPage,
current,
setCurrent,
itemsPerPageLabel,
showingResultsLabel,
}),
[itemsPerPage, setItemsPerPage, current, setCurrent, itemsPerPageLabel, showingResultsLabel],
);
};

View File

@@ -0,0 +1,11 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useShowingResultsLabel = () => {
const { t } = useTranslation();
return useCallback(
({ count, current, itemsPerPage }: { count: number; current: number; itemsPerPage: 25 | 50 | 100 }) =>
t('Showing_results_of', { from: current + 1, to: Math.min(current + itemsPerPage, count), total: count }),
[t],
);
};

View File

@@ -0,0 +1,34 @@
import { useCallback, useState } from 'react';
type Direction = 'asc' | 'desc';
export const useSort = <T extends string>(
by: T,
initialDirection: Direction = 'asc',
): {
sortBy: T;
sortDirection: Direction;
setSort: (sortBy: T, direction?: Direction | undefined) => void;
} => {
const [sort, _setSort] = useState<[T, Direction]>(() => [by, initialDirection]);
const setSort = useCallback((id: T, direction?: Direction | undefined) => {
_setSort(([sortBy, sortDirection]) => {
if (direction) {
return [id, direction];
}
if (sortBy === id) {
return [id, sortDirection === 'asc' ? 'desc' : 'asc'];
}
return [id, 'asc'];
});
}, []);
return {
sortBy: sort[0],
sortDirection: sort[1],
setSort,
};
};

View File

@@ -0,0 +1,9 @@
export * from './GenericTable';
export * from './GenericTableBody';
export * from './GenericTableCell';
export * from './GenericTableHeader';
export * from './GenericTableHeaderCell';
export * from './GenericTableLoadingRow';
export * from './GenericTableLoadingTable';
export * from './GenericTableRow';
export * from './hooks';

View File

@@ -0,0 +1,224 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Avatar, Box, IconButton } from '@rocket.chat/fuselage';
import { SettingsContext } from '@rocket.chat/ui-contexts';
import type { Meta, StoryObj } from '@storybook/react';
import type { ComponentPropsWithoutRef, ComponentType } from 'react';
import { action } from 'storybook/actions';
import {
Header,
HeaderAvatar,
HeaderContent,
HeaderContentRow,
HeaderIcon,
HeaderToolbar,
HeaderToolbarAction,
HeaderToolbarActionBadge,
HeaderTitle,
HeaderState,
} from '.';
import AnnouncementBanner from '../AnnouncementBanner';
const avatarUrl =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z';
export default {
component: Header,
subcomponents: {
HeaderToolbar: HeaderToolbar as ComponentType<any>,
HeaderToolbarAction: HeaderToolbarAction as ComponentType<any>,
HeaderAvatar: HeaderAvatar as ComponentType<any>,
HeaderContent: HeaderContent as ComponentType<any>,
HeaderContentRow: HeaderContentRow as ComponentType<any>,
},
parameters: {
layout: 'fullscreen',
},
decorators: [
(fn) => (
<SettingsContext.Provider
value={{
hasPrivateAccess: true,
querySetting: (_id) => [
() => () => undefined,
() => ({
_id,
type: 'action',
value: '',
actionText: '',
public: true,
blocked: false,
createdAt: new Date(),
env: true,
i18nLabel: _id,
packageValue: false,
sorter: 1,
ts: new Date(),
_updatedAt: new Date(),
}),
],
querySettings: () => [() => () => undefined, () => []],
dispatch: async () => undefined,
}}
>
{fn()}
</SettingsContext.Provider>
),
],
} satisfies Meta<typeof Header>;
const room: IRoom = {
t: 'c',
name: 'general general general general general general general general general general general general general general general general general general general',
_id: 'GENERAL',
topic: 'lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum',
announcement: 'Announcement',
encrypted: true,
autoTranslate: true,
autoTranslateLanguage: 'pt-BR',
u: {
_id: 'rocket.cat',
name: 'rocket.cat',
username: 'rocket.cat',
},
msgs: 123,
usersCount: 3,
_updatedAt: new Date(),
} as const;
const CustomAvatar = (props: Omit<ComponentPropsWithoutRef<typeof Avatar>, 'url'>) => <Avatar size='x28' url={avatarUrl} {...props} />;
const icon = { name: 'hash' } as const;
export const Default: StoryObj<typeof Header> = {
render: () => (
<Header>
<HeaderAvatar>
<CustomAvatar />
</HeaderAvatar>
<HeaderContent>
<HeaderContentRow>
{icon && <HeaderIcon icon={icon} />}
<HeaderTitle>{room.name}</HeaderTitle>
<HeaderState onClick={action('click')} title='star' icon='star' />
<HeaderState icon='key' />
<HeaderState icon='language' />
</HeaderContentRow>
</HeaderContent>
<HeaderToolbar>
<HeaderToolbarAction title='magnifier' icon='magnifier' />
<HeaderToolbarAction title='key' icon='key' />
<HeaderToolbarAction title='menu' icon='kebab' />
</HeaderToolbar>
</Header>
),
};
export const WithBurger: StoryObj<typeof Header> = {
render: () => (
<Header>
<HeaderToolbar>
<IconButton title='burger' icon='burger' />
</HeaderToolbar>
<HeaderAvatar>
<CustomAvatar />
</HeaderAvatar>
<HeaderContent>
<HeaderContentRow>
{icon && <HeaderIcon icon={icon} />}
<HeaderTitle>{room.name}</HeaderTitle>
<HeaderState onClick={action('onClick')} title='star' icon='star' />
<HeaderState icon='key' />
<HeaderState icon='language' />
</HeaderContentRow>
</HeaderContent>
<HeaderToolbar>
<HeaderToolbarAction title='magnifier' icon='magnifier' />
<HeaderToolbarAction title='key' icon='key' />
<HeaderToolbarAction title='menu' icon='kebab' />
</HeaderToolbar>
</Header>
),
};
export const WithActionBadge: StoryObj<typeof Header> = {
render: () => (
<Header>
<HeaderAvatar>
<CustomAvatar />
</HeaderAvatar>
<HeaderContent>
<HeaderContentRow>
{icon && <HeaderIcon icon={icon} />}
<HeaderTitle>{room.name}</HeaderTitle>
<HeaderState onClick={action('onClick')} title='star' icon='star' />
</HeaderContentRow>
</HeaderContent>
<HeaderToolbar>
<HeaderToolbarAction title='call' icon='phone'>
<HeaderToolbarActionBadge variant='primary'>1</HeaderToolbarActionBadge>
</HeaderToolbarAction>
<HeaderToolbarAction title='disable' icon='phone'>
<HeaderToolbarActionBadge variant='danger'>2</HeaderToolbarActionBadge>
</HeaderToolbarAction>
<HeaderToolbarAction title='decline' icon='phone'>
<HeaderToolbarActionBadge variant='warning'>99</HeaderToolbarActionBadge>
</HeaderToolbarAction>
<HeaderToolbarAction title='menu' icon='kebab' />
</HeaderToolbar>
</Header>
),
};
export const WithTopic: StoryObj<typeof Header> = {
render: () => (
<>
<Header>
<HeaderAvatar>
<CustomAvatar />
</HeaderAvatar>
<HeaderContent>
<HeaderContentRow>
{icon && <HeaderIcon icon={icon} />}
<HeaderTitle>{room.name}</HeaderTitle>
<HeaderState onClick={action('onClick')} title='star' icon='star' />
<HeaderState icon='key' />
<HeaderState icon='language' />
<Box withTruncatedText>{room.topic}</Box>
</HeaderContentRow>
</HeaderContent>
<HeaderToolbar>
<HeaderToolbarAction title='magnifier' icon='magnifier' />
<HeaderToolbarAction title='key' icon='key' />
<HeaderToolbarAction title='menu' icon='kebab' />
</HeaderToolbar>
</Header>
</>
),
};
export const WithAnnouncement: StoryObj<typeof Header> = {
render: () => (
<>
<Header>
<HeaderAvatar>
<CustomAvatar />
</HeaderAvatar>
<HeaderContent>
<HeaderContentRow>
{icon && <HeaderIcon icon={icon} />}
<HeaderTitle>{room.name}</HeaderTitle>
<HeaderState onClick={action('onClick')} title='star' icon='star' />
<HeaderState icon='key' />
<HeaderState icon='language' />
</HeaderContentRow>
</HeaderContent>
<HeaderToolbar>
<HeaderToolbarAction title='magnifier' icon='magnifier' />
<HeaderToolbarAction title='key' icon='key' />
<HeaderToolbarAction title='menu' icon='kebab' />
</HeaderToolbar>
</Header>
<AnnouncementBanner onClick={action('clicked')}>{room.announcement}</AnnouncementBanner>
</>
),
};

View File

@@ -0,0 +1,35 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import HeaderDivider from './HeaderDivider';
export type HeaderProps = ComponentPropsWithoutRef<typeof Box>;
const Header = (props: HeaderProps) => (
<Box
rcx-room-header
is='header'
color='default'
display='flex'
justifyContent='center'
flexDirection='column'
overflow='hidden'
flexShrink={0}
>
<Box
paddingInline='x16'
height='x44'
display='flex'
flexGrow={1}
justifyContent='center'
alignItems='center'
overflow='hidden'
flexDirection='row'
backgroundColor='room'
{...props}
/>
<HeaderDivider />
</Box>
);
export default Header;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderAvatarProps = ComponentPropsWithoutRef<typeof Box>;
const HeaderAvatar = (props: HeaderAvatarProps) => <Box marginInline={4} display='flex' alignItems='center' {...props} />;
export default HeaderAvatar;

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderContentProps = ComponentPropsWithoutRef<typeof Box>;
const HeaderContent = (props: HeaderContentProps) => (
<Box flexGrow={1} width={1} flexShrink={1} marginInline={4} display='flex' justifyContent='center' flexDirection='column' {...props} />
);
export default HeaderContent;

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderContentRowProps = ComponentPropsWithoutRef<typeof Box>;
const HeaderContentRow = (props: HeaderContentRowProps) => (
<Box alignItems='center' flexShrink={1} flexGrow={1} display='flex' width='full' {...props} />
);
export default HeaderContentRow;

View File

@@ -0,0 +1,5 @@
import { Divider } from '@rocket.chat/fuselage';
const HeaderDivider = () => <Divider marginBlockStart={-2} marginBlockEnd={0} />;
export default HeaderDivider;

View File

@@ -0,0 +1,14 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef, ReactElement } from 'react';
import { isValidElement } from 'react';
export type HeaderIconProps = { icon: ReactElement<any> | ComponentPropsWithoutRef<typeof Icon> | null };
const HeaderIcon = ({ icon }: HeaderIconProps) =>
icon && (
<Box display='flex' flexShrink={0} alignItems='center' overflow='hidden' justifyContent='center'>
{isValidElement<any>(icon) ? icon : <Icon color='default' size='x20' name={icon.name} />}
</Box>
);
export default HeaderIcon;

View File

@@ -0,0 +1,17 @@
import { Icon, IconButton } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { AllHTMLAttributes, ComponentPropsWithoutRef, MouseEventHandler } from 'react';
export type HeaderStateProps =
| (Pick<ComponentPropsWithoutRef<typeof IconButton>, 'color' | 'title' | 'icon'> & {
onClick: MouseEventHandler;
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>)
| (Omit<ComponentPropsWithoutRef<typeof Icon>, 'name' | 'onClick'> & {
icon: IconName;
onClick?: undefined;
});
const HeaderState = (props: HeaderStateProps) =>
props.onClick ? <IconButton tiny marginInlineEnd={4} {...props} /> : <Icon size='x16' marginInlineEnd={8} name={props.icon} {...props} />;
export default HeaderState;

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderSubtitleProps = ComponentPropsWithoutRef<typeof Box>;
const HeaderSubtitle = (props: HeaderSubtitleProps) => (
<Box color='hint' fontScale='p2' paddingBlock='x4' flexGrow={1} withTruncatedText {...props} />
);
export default HeaderSubtitle;

View File

@@ -0,0 +1,8 @@
import { render } from '@testing-library/react';
import HeaderTag from './HeaderTag';
it('should match snapshot', () => {
const { baseElement } = render(<HeaderTag>Test Tag</HeaderTag>);
expect(baseElement).toMatchSnapshot();
});

View File

@@ -0,0 +1,14 @@
import { Box, Tag } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderTagProps = ComponentPropsWithoutRef<typeof Tag>;
const HeaderTag = ({ children, ...props }: HeaderTagProps) => (
<Box padding={4} withTruncatedText minWidth='x32'>
<Tag medium {...props}>
{children}
</Tag>
</Box>
);
export default HeaderTag;

View File

@@ -0,0 +1,23 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef, ReactElement } from 'react';
import { isValidElement } from 'react';
type HeaderIconProps = {
icon: ReactElement<any> | Pick<ComponentPropsWithoutRef<typeof Icon>, 'name' | 'color'> | null;
};
const HeaderTagIcon = ({ icon }: HeaderIconProps) => {
if (!icon) {
return null;
}
return isValidElement<any>(icon) ? (
<Box marginInlineEnd={4} display='inline-block' verticalAlign='middle'>
{icon}
</Box>
) : (
<Icon size='x12' marginInlineEnd={4} {...icon} />
);
};
export default HeaderTagIcon;

View File

@@ -0,0 +1,5 @@
import { Skeleton } from '@rocket.chat/fuselage';
const HeaderTagSkeleton = () => <Skeleton width='x48' />;
export default HeaderTagSkeleton;

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`should match snapshot 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-1kopyx3"
>
<span
class="rcx-box rcx-box--full rcx-tag rcx-tag--medium"
>
<span
class="rcx-tag__inner"
>
Test Tag
</span>
</span>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,3 @@
export { default as HeaderTag } from './HeaderTag';
export { default as HeaderTagIcon } from './HeaderTagIcon';
export { default as HeaderTagSkeleton } from './HeaderTagSkeleton';

View File

@@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderTitleProps = ComponentPropsWithoutRef<typeof Box>;
const HeaderTitle = (props: HeaderTitleProps) => (
<Box color='titles-labels' marginInline={4} is='h1' fontScale='h4' withTruncatedText {...props} />
);
export default HeaderTitle;

View File

@@ -0,0 +1,27 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderTitleButtonProps = Omit<ComponentPropsWithoutRef<typeof Box>, 'className'> & { className?: string };
const HeaderTitleButton = ({ className, ...props }: HeaderTitleButtonProps) => {
const customClass = css`
border-width: 1px;
border-style: solid;
border-color: transparent;
&:hover {
cursor: pointer;
background-color: ${Palette.surface['surface-hover']};
}
&:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']};
border-color: ${Palette.stroke['stroke-highlight']};
}
`;
return <Box display='flex' alignItems='center' borderRadius={4} withTruncatedText className={[customClass, className]} {...props} />;
};
export default HeaderTitleButton;

View File

@@ -0,0 +1,19 @@
import { useToolbar } from '@react-aria/toolbar';
import { ButtonGroup } from '@rocket.chat/fuselage';
import { useRef } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderToolbarProps = ComponentPropsWithoutRef<typeof ButtonGroup>;
const HeaderToolbar = (props: HeaderToolbarProps) => {
const ref = useRef(null);
const { toolbarProps } = useToolbar(props, ref);
return (
<ButtonGroup role='toolbar' ref={ref} {...toolbarProps}>
{props.children}
</ButtonGroup>
);
};
export default HeaderToolbar;

View File

@@ -0,0 +1,16 @@
import { IconButton } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
export type HeaderToolbarActionProps = ComponentPropsWithoutRef<typeof IconButton> & {
tooltip?: string;
};
const HeaderToolbarAction = forwardRef<HTMLButtonElement, HeaderToolbarActionProps>(function HeaderToolbarAction(
{ icon, title, tooltip, ...props },
ref,
) {
return <IconButton ref={ref} icon={icon} small position='relative' overflow='visible' title={tooltip ?? title} {...props} />;
});
export default HeaderToolbarAction;

View File

@@ -0,0 +1,22 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Badge } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type HeaderToolbarActionBadgeProps = ComponentPropsWithoutRef<typeof Badge>;
const HeaderToolbarActionBadge = (props: HeaderToolbarActionBadgeProps) => (
<Box
position='absolute'
role='status'
className={css`
top: 0;
right: 0;
transform: translate(30%, -30%);
z-index: 1;
`}
>
<Badge {...props} />
</Box>
);
export default HeaderToolbarActionBadge;

View File

@@ -0,0 +1,5 @@
import { Divider } from '@rocket.chat/fuselage';
const HeaderToolbarDivider = () => <Divider marginInline={4} vertical />;
export default HeaderToolbarDivider;

View File

@@ -0,0 +1,4 @@
export { default as HeaderToolbar } from './HeaderToolbar';
export { default as HeaderToolbarAction } from './HeaderToolbarAction';
export { default as HeaderToolbarActionBadge } from './HeaderToolbarActionBadge';
export { default as HeaderToolbarDivider } from './HeaderToolbarDivider';

View File

@@ -0,0 +1,12 @@
export { default as Header } from './Header';
export { default as HeaderAvatar } from './HeaderAvatar';
export { default as HeaderContent } from './HeaderContent';
export { default as HeaderContentRow } from './HeaderContentRow';
export { default as HeaderDivider } from './HeaderDivider';
export { default as HeaderIcon } from './HeaderIcon';
export { default as HeaderState } from './HeaderState';
export { default as HeaderSubtitle } from './HeaderSubtitle';
export { HeaderTag, HeaderTagIcon, HeaderTagSkeleton } from './HeaderTag';
export { default as HeaderTitle } from './HeaderTitle';
export { default as HeaderTitleButton } from './HeaderTitleButton';
export { HeaderToolbar, HeaderToolbarAction, HeaderToolbarActionBadge, HeaderToolbarDivider } from './HeaderToolbar';

View File

@@ -0,0 +1,18 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './InfoPanel.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react';
import type { ComponentType } from 'react';
import {
InfoPanel,
InfoPanelAction,
InfoPanelActionGroup,
InfoPanelAvatar,
InfoPanelField,
InfoPanelLabel,
InfoPanelSection,
InfoPanelText,
InfoPanelTitle,
} from '.';
export default {
component: InfoPanel,
subcomponents: {
InfoPanelAction: InfoPanelAction as ComponentType<any>,
InfoPanelActionGroup: InfoPanelActionGroup as ComponentType<any>,
InfoPanelAvatar: InfoPanelAvatar as ComponentType<any>,
InfoPanelField: InfoPanelField as ComponentType<any>,
InfoPanelLabel: InfoPanelLabel as ComponentType<any>,
InfoPanelSection: InfoPanelSection as ComponentType<any>,
InfoPanelText: InfoPanelText as ComponentType<any>,
InfoPanelTitle: InfoPanelTitle as ComponentType<any>,
},
} satisfies Meta<typeof InfoPanel>;
export const Default: StoryObj<typeof InfoPanel> = {
render: () => (
<InfoPanel>
<InfoPanelAvatar />
<InfoPanelSection>
<InfoPanelTitle title='rocketchat-frontend-team' icon='hashtag' />
</InfoPanelSection>
<InfoPanelSection>
<InfoPanelField>
<InfoPanelLabel>Description</InfoPanelLabel>
<InfoPanelText>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit
libero
</InfoPanelText>
</InfoPanelField>
<InfoPanelField>
<InfoPanelLabel>Announcement</InfoPanelLabel>
<InfoPanelText>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit
libero
</InfoPanelText>
</InfoPanelField>
<InfoPanelField>
<InfoPanelLabel title='This is helpful information about the label'>Topic</InfoPanelLabel>
<InfoPanelText>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam mollis nisi vel arcu bibendum vehicula. Integer vitae suscipit
libero
</InfoPanelText>
</InfoPanelField>
</InfoPanelSection>
</InfoPanel>
),
};

Some files were not shown because too many files have changed in this diff Show More