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
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:
3
packages/ui-client/.storybook/main.ts
Normal file
3
packages/ui-client/.storybook/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '@rocket.chat/storybook-config/main';
|
||||
|
||||
export default baseConfig();
|
||||
11
packages/ui-client/.storybook/preview.tsx
Normal file
11
packages/ui-client/.storybook/preview.tsx
Normal 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;
|
||||
2883
packages/ui-client/CHANGELOG.md
Normal file
2883
packages/ui-client/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
11
packages/ui-client/jest.config.ts
Normal file
11
packages/ui-client/jest.config.ts
Normal 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;
|
||||
81
packages/ui-client/package.json
Normal file
81
packages/ui-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
64
packages/ui-client/src/components/AnchorPortal.spec.tsx
Normal file
64
packages/ui-client/src/components/AnchorPortal.spec.tsx
Normal 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();
|
||||
});
|
||||
26
packages/ui-client/src/components/AnchorPortal.tsx
Normal file
26
packages/ui-client/src/components/AnchorPortal.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AnnouncementBanner';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
21
packages/ui-client/src/components/Contextualbar/index.ts
Normal file
21
packages/ui-client/src/components/Contextualbar/index.ts
Normal 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';
|
||||
139
packages/ui-client/src/components/CustomFieldsForm.spec.tsx
Normal file
139
packages/ui-client/src/components/CustomFieldsForm.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
117
packages/ui-client/src/components/CustomFieldsForm.tsx
Normal file
117
packages/ui-client/src/components/CustomFieldsForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
19
packages/ui-client/src/components/DotLeader.stories.tsx
Normal file
19
packages/ui-client/src/components/DotLeader.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
21
packages/ui-client/src/components/DotLeader.tsx
Normal file
21
packages/ui-client/src/components/DotLeader.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
10
packages/ui-client/src/components/EmojiPicker/index.ts
Normal file
10
packages/ui-client/src/components/EmojiPicker/index.ts
Normal 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';
|
||||
12
packages/ui-client/src/components/ExternalLink.tsx
Normal file
12
packages/ui-client/src/components/ExternalLink.tsx
Normal 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>
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
26
packages/ui-client/src/components/FilePreviewIcon.tsx
Normal file
26
packages/ui-client/src/components/FilePreviewIcon.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
108
packages/ui-client/src/components/GenericMenu/GenericMenu.tsx
Normal file
108
packages/ui-client/src/components/GenericMenu/GenericMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
3
packages/ui-client/src/components/GenericMenu/index.ts
Normal file
3
packages/ui-client/src/components/GenericMenu/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as GenericMenu } from './GenericMenu';
|
||||
export { default as GenericMenuItem, type GenericMenuItemProps } from './GenericMenuItem';
|
||||
export { useHandleMenuAction } from './hooks/useHandleMenuAction';
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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} />;
|
||||
@@ -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} />;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -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} />;
|
||||
26
packages/ui-client/src/components/GenericTable/SortIcon.tsx
Normal file
26
packages/ui-client/src/components/GenericTable/SortIcon.tsx
Normal 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;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './useCurrent';
|
||||
export * from './useItemsPerPage';
|
||||
export * from './useItemsPerPageLabel';
|
||||
export * from './usePagination';
|
||||
export * from './useShowingResultsLabel';
|
||||
export * from './useSort';
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
9
packages/ui-client/src/components/GenericTable/index.ts
Normal file
9
packages/ui-client/src/components/GenericTable/index.ts
Normal 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';
|
||||
224
packages/ui-client/src/components/Header/Header.stories.tsx
Normal file
224
packages/ui-client/src/components/Header/Header.stories.tsx
Normal 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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
35
packages/ui-client/src/components/Header/Header.tsx
Normal file
35
packages/ui-client/src/components/Header/Header.tsx
Normal 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;
|
||||
@@ -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;
|
||||
10
packages/ui-client/src/components/Header/HeaderContent.tsx
Normal file
10
packages/ui-client/src/components/Header/HeaderContent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Divider } from '@rocket.chat/fuselage';
|
||||
|
||||
const HeaderDivider = () => <Divider marginBlockStart={-2} marginBlockEnd={0} />;
|
||||
|
||||
export default HeaderDivider;
|
||||
14
packages/ui-client/src/components/Header/HeaderIcon.tsx
Normal file
14
packages/ui-client/src/components/Header/HeaderIcon.tsx
Normal 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;
|
||||
17
packages/ui-client/src/components/Header/HeaderState.tsx
Normal file
17
packages/ui-client/src/components/Header/HeaderState.tsx
Normal 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;
|
||||
10
packages/ui-client/src/components/Header/HeaderSubtitle.tsx
Normal file
10
packages/ui-client/src/components/Header/HeaderSubtitle.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Skeleton } from '@rocket.chat/fuselage';
|
||||
|
||||
const HeaderTagSkeleton = () => <Skeleton width='x48' />;
|
||||
|
||||
export default HeaderTagSkeleton;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as HeaderTag } from './HeaderTag';
|
||||
export { default as HeaderTagIcon } from './HeaderTagIcon';
|
||||
export { default as HeaderTagSkeleton } from './HeaderTagSkeleton';
|
||||
10
packages/ui-client/src/components/Header/HeaderTitle.tsx
Normal file
10
packages/ui-client/src/components/Header/HeaderTitle.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Divider } from '@rocket.chat/fuselage';
|
||||
|
||||
const HeaderToolbarDivider = () => <Divider marginInline={4} vertical />;
|
||||
|
||||
export default HeaderToolbarDivider;
|
||||
@@ -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';
|
||||
12
packages/ui-client/src/components/Header/index.ts
Normal file
12
packages/ui-client/src/components/Header/index.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user