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

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

10
packages/livechat/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
/node_modules
/build
/storybook-static
/*.log
/.vscode
/size-plugin.json
coverage
package-lock.json
build.tar.gz
.DS_Store

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,85 @@
import type { Decorator } from '@storybook/preact';
import { loremIpsum as originalLoremIpsum } from 'lorem-ipsum';
import { action } from 'storybook/actions';
import gazzoAvatar from './assets/gazzo.jpg';
import martinAvatar from './assets/martin.jpg';
import tassoAvatar from './assets/tasso.jpg';
import type { ScreenContextValue } from '../src/components/Screen/ScreenProvider';
import { ScreenContext } from '../src/components/Screen/ScreenProvider';
import store, { StoreContext, type StoreState } from '../src/store';
export const screenDecorator: Decorator = (storyFn) => (
<div style={{ display: 'flex', width: 365, height: 500 }}>
<ScreenContext.Provider value={screenProps()}>{storyFn()}</ScreenContext.Provider>
</div>
);
export const screenProps = (): ScreenContextValue => ({
hideWatermark: false,
livechatLogo: undefined,
notificationsEnabled: true,
minimized: false,
expanded: false,
windowed: false,
sound: undefined,
alerts: [],
modal: null,
onEnableNotifications: action('enableNotifications'),
onDisableNotifications: action('disableNotifications'),
onMinimize: action('minimize'),
onRestore: action('restore'),
onOpenWindow: action('openWindow'),
onDismissAlert: action('dismissAlert'),
dismissNotification: action('dismissNotification'),
theme: {
color: '',
fontColor: '',
iconColor: '',
hideAgentAvatar: false,
hideGuestAvatar: true,
hideExpandChat: false,
},
});
// Feeds arbitrary store data from a story's args into StoreContext, so container components that
// read from `useStore()` render against it. Args are treated as a partial store state override.
export const storeDecorator: Decorator<Partial<StoreState>> = (storyFn, { args }) => (
<StoreContext.Provider
value={{
...store.state,
...args,
dispatch: store.setState.bind(store),
on: store.on.bind(store),
off: store.off.bind(store),
}}
>
{storyFn()}
</StoreContext.Provider>
);
export const avatarResolver = (username: string) =>
({
'guilherme.gazzo': gazzoAvatar,
'martin.schoeler': martinAvatar,
'tasso.evangelista': tassoAvatar,
})[username];
export const attachmentResolver = (url: string) => url;
const createRandom = (s: number) => () => {
s = Math.sin(s) * 10000;
return s - Math.floor(s);
};
const loremIpsumRandom = createRandom(42);
export const loremIpsum = (options: Parameters<typeof originalLoremIpsum>[0]) =>
originalLoremIpsum({ random: loremIpsumRandom, ...options });
export { gazzoAvatar, martinAvatar, tassoAvatar };
export { default as sampleAudio } from './assets/sample-audio.mp3';
export { default as sampleImage } from './assets/sample-image.jpg';
export { default as sampleVideo } from './assets/sample-video.mp4';
export { default as accessoryImage } from './assets/accessoryImage.png';
export { default as imageBlock } from './assets/imageBlock.png';
export { default as beepAudio } from './assets/beep.mp3';

View File

@@ -0,0 +1,127 @@
import { dirname, join } from 'path';
import type { StorybookConfig } from '@storybook/preact-webpack5';
import webpack, { type RuleSetRule } from 'webpack';
const config: StorybookConfig = {
stories: ['../src/**/{*.story,story,*.stories,stories}.tsx'],
addons: [
'@storybook/addon-docs',
{
name: '@storybook/addon-styling-webpack',
options: {
rules: [
{
test: /\.css$/,
sideEffects: true,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
sourceMap: true,
},
},
{
loader: 'postcss-loader',
options: {
implementation: 'postcss',
},
},
],
},
{
test: /\.scss$/,
sideEffects: true,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[local]__[hash:base64:5]',
},
importLoaders: 1,
sourceMap: true,
},
},
{
loader: 'postcss-loader',
options: {
implementation: 'postcss',
},
},
{
loader: 'sass-loader',
options: {
implementation: 'sass',
sourceMap: true,
sassOptions: {},
},
},
],
},
],
},
},
'@storybook/addon-webpack5-compiler-swc',
'@rocket.chat/storybook-dark-mode',
],
framework: {
name: '@storybook/preact-webpack5',
options: {},
},
typescript: {},
webpackFinal: async (config) => {
const dateFnsRoot = dirname(require.resolve('date-fns/package.json'));
config.resolve = {
...config.resolve,
alias: {
...config.resolve?.alias,
[require.resolve('../src/lib/uiKit')]: require.resolve('./mocks/uiKit.ts'),
// date-fns v4's `exports` map doesn't expose the `./locale` directory
// for enumeration, so webpack's dynamic `import()` context fails. Alias
// to the on-disk folder to bypass `exports`.
'date-fns/locale$': join(dateFnsRoot, 'locale'),
},
};
const isRuleSetRule = (rule: any): rule is RuleSetRule => typeof rule === 'object';
config.module?.rules?.filter(isRuleSetRule).forEach((rule) => {
if (String(rule.test) === String(/\.(svg|ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/)) {
rule.test = /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/;
}
});
config.module?.rules?.push({
test: /\.svg$/,
exclude: /logo\.svg/,
use: ['desvg-loader/preact', 'svg-loader'],
});
config.module?.rules?.push({
test: /logo\.svg(\?.*)?$/,
type: 'asset/resource',
generator: { filename: 'static/media/[path][name][ext]' },
});
// webpack 5 doesn't handle the `node:` scheme, so `node:stream` (pulled in by the
// preact `react-dom/server` shim) bypasses the `stream: false` resolve fallback and
// throws. Strip the prefix so those specifiers hit the existing node-core fallbacks.
config.plugins?.push(
new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => {
resource.request = resource.request.replace(/^node:/, '');
}),
);
return config;
},
};
export default config;

View File

@@ -0,0 +1,24 @@
import { action } from 'storybook/actions';
export const UIKitInteractionType = {
MODAL_OPEN: 'modal.open',
MODAL_CLOSE: 'modal.close',
MODAL_UPDATE: 'modal.update',
ERRORS: 'errors',
};
export const UIKitIncomingInteractionType = {
BLOCK: 'blockAction',
VIEW_SUBMIT: 'viewSubmit',
VIEW_CLOSED: 'viewClosed',
};
export const UIKitIncomingInteractionContainerType = {
MESSAGE: 'message',
VIEW: 'view',
};
export const triggerAction = async (payload: unknown) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
action('dispatchAction')(payload);
};

View File

@@ -0,0 +1,12 @@
import type { Preview } from '@storybook/preact';
import 'emoji-mart/css/emoji-mart.css';
import '../src/styles/index.scss';
const preview: Preview = {
parameters: {
layout: 'fullscreen',
},
};
export default preview;

View File

@@ -0,0 +1,300 @@
{
"customSyntax": "postcss-scss",
"plugins": [
"stylelint-order"
],
"rules": {
"at-rule-empty-line-before": [
"always",
{
"except": [
"blockless-after-same-name-blockless",
"first-nested"
],
"ignore": [
"after-comment"
]
}
],
"block-no-empty": true,
"color-hex-length": "long",
"color-no-invalid-hex": true,
"comment-empty-line-before": [
"always",
{
"except": [
"first-nested"
],
"ignore": [
"stylelint-commands"
]
}
],
"comment-no-empty": true,
"comment-whitespace-inside": "always",
"custom-property-empty-line-before": "never",
"declaration-block-no-duplicate-properties": [
true,
{
"ignore": [
"consecutive-duplicates-with-different-values"
]
}
],
"declaration-block-no-redundant-longhand-properties": true,
"declaration-block-no-shorthand-property-overrides": true,
"declaration-block-single-line-max-declarations": 1,
"font-family-no-duplicate-names": true,
"function-linear-gradient-no-nonstandard-direction": true,
"function-name-case": "lower",
"keyframe-declaration-no-important": true,
"length-zero-no-unit": true,
"media-feature-name-no-unknown": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"property-no-unknown": true,
"rule-empty-line-before": [
"always",
{
"except": [
"first-nested"
],
"ignore": [
"after-comment"
]
}
],
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": [
"global"
]
}
],
"selector-pseudo-element-colon-notation": "double",
"selector-pseudo-element-no-unknown": true,
"selector-type-case": "lower",
"selector-type-no-unknown": true,
"shorthand-property-no-redundant-values": true,
"string-no-newline": true,
"unit-no-unknown": true,
"order/properties-order": [
[
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"position",
"z-index",
"top",
"right",
"bottom",
"left"
]
},
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"display",
"visibility",
"float",
"clear",
"overflow",
"overflow-x",
"overflow-y",
"clip",
"zoom",
"flex-direction",
"flex-order",
"flex-pack",
"flex-align",
"flex"
]
},
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"box-sizing",
"width",
"min-width",
"max-width",
"height",
"min-height",
"max-height",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left"
]
},
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"table-layout",
"empty-cells",
"caption-side",
"border-spacing",
"border-collapse",
"list-style",
"list-style-position",
"list-style-type",
"list-style-image"
]
},
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"content",
"quotes",
"counter-reset",
"counter-increment",
"resize",
"cursor",
"user-select",
"nav-index",
"nav-up",
"nav-right",
"nav-down",
"nav-left",
"transition",
"transition-delay",
"transition-timing-function",
"transition-duration",
"transition-property",
"transform",
"transform-origin",
"animation",
"animation-name",
"animation-duration",
"animation-play-state",
"animation-timing-function",
"animation-delay",
"animation-iteration-count",
"animation-direction",
"text-align",
"text-align-last",
"vertical-align",
"white-space",
"text-decoration",
"text-emphasis",
"text-emphasis-color",
"text-emphasis-style",
"text-emphasis-position",
"text-indent",
"text-justify",
"text-transform",
"letter-spacing",
"word-spacing",
"text-outline",
"text-transform",
"text-wrap",
"text-overflow",
"text-overflow-ellipsis",
"text-overflow-mode",
"word-wrap",
"word-break",
"tab-size",
"hyphens",
"pointer-events"
]
},
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"opacity",
"filter:progid:DXImageTransform.Microsoft.Alpha(Opacity",
"color",
"border",
"border-collapse",
"border-width",
"border-style",
"border-color",
"border-top",
"border-top-width",
"border-top-style",
"border-top-color",
"border-right",
"border-right-width",
"border-right-style",
"border-right-color",
"border-bottom",
"border-bottom-width",
"border-bottom-style",
"border-bottom-color",
"border-left",
"border-left-width",
"border-left-style",
"border-left-color",
"border-radius",
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
"border-image",
"border-image-source",
"border-image-slice",
"border-image-width",
"border-image-outset",
"border-image-repeat",
"outline",
"outline-width",
"outline-style",
"outline-color",
"outline-offset",
"background",
"filter:progid:DXImageTransform.Microsoft.AlphaImageLoader",
"background-color",
"background-image",
"background-repeat",
"background-attachment",
"background-position",
"background-position-x",
"background-position-y",
"background-clip",
"background-origin",
"background-size",
"box-decoration-break",
"box-shadow",
"filter:progid:DXImageTransform.Microsoft.gradient",
"text-shadow"
]
},
{
"emptyLineBefore": "always",
"order": "strict",
"properties": [
"font",
"font-family",
"font-size",
"font-weight",
"font-style",
"font-variant",
"font-size-adjust",
"font-stretch",
"font-effect",
"font-emphasize",
"font-emphasize-position",
"font-emphasize-style",
"font-smooth",
"line-height"
]
}
],
{
"unspecified": "bottomAlphabetical"
}
]
}
}

File diff suppressed because it is too large Load Diff

21
packages/livechat/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Rocket.Chat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,89 @@
# Rocket.Chat.Livechat
[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/RocketChat/Rocket.Chat.Livechat.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/RocketChat/Rocket.Chat.Livechat/context:javascript)
[![Total alerts](https://img.shields.io/lgtm/alerts/g/RocketChat/Rocket.Chat.Livechat.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/RocketChat/Rocket.Chat.Livechat/alerts/)
[![Storybook](https://cdn.jsdelivr.net/gh/storybooks/brand@master/badge/badge-storybook.svg)](https://rocketchat.github.io/Rocket.Chat.Livechat)
Currently, it's very common to find chat pop-ups when you're browsing websites.
Those widgets, at Rocket.Chat, are called **LiveChat**.
**LiveChat** is a small and lightweight application designed to provide B2C (Business-to-customer) communication between Agents and website visitors and is developed with [Preact](https://preactjs.com).
## Running a development environment
With your **Rocket.chat** running locally at http://localhost:3000
<br />
1. Install all node dependencies.
``` bash
yarn
```
2. Build preact application to `/build` folder
``` bash
yarn dev
```
3. In another terminal, run webpack with hot reload at http://localhost:8080
``` bash
yarn start
```
4. Open this file below in your browser
``` bash
widget-demo.html
```
*OBS: For a better performance, you can run this `widget-demo.html` on a [http server](https://github.com/http-party/http-server).*
## Different host
To select a different host on your local widget, check this configuration at `/src/api.js` file.
``` javascript
const host = window.SERVER_URL
|| queryString.parse(window.location.search).serverUrl
|| (process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null);
```
Here, you can change to your new configuration.
``` javascript
const host = window.SERVER_URL
|| queryString.parse(window.location.search).serverUrl
|| (process.env.NODE_ENV === 'development' ? 'https://your.rocketserver.com' : null);
```
## Available CLI Commands
``` bash
# install dependencies
yarn
# serve with hot reload at localhost:8080
yarn start
# build preact application to "build" folder
yarn dev
# build for production with minification
yarn build
# test the production build locally
yarn serve
# run tests with jest and preact-render-spy
yarn test
# run the storybook
yarn storybook
```
## Screens:
![image](https://user-images.githubusercontent.com/5263975/44279585-497b2980-a228-11e8-81a2-36bc3389549e.png)
![image](https://user-images.githubusercontent.com/5263975/44279599-5730af00-a228-11e8-8873-553ef53ee25a.png)
![image](https://user-images.githubusercontent.com/5263975/44279626-6f083300-a228-11e8-8886-c430b28a8e75.png)
![image](https://user-images.githubusercontent.com/5263975/44279634-74657d80-a228-11e8-9583-bf8079972696.png)
![image](https://user-images.githubusercontent.com/5263975/44279639-7b8c8b80-a228-11e8-9815-1a0e3540c4f5.png)
![image](https://user-images.githubusercontent.com/5263975/44279643-847d5d00-a228-11e8-804e-27b973dee8b2.png)
![image](https://user-images.githubusercontent.com/5263975/44279655-90691f00-a228-11e8-8511-4a328a77e5bb.png)

View File

@@ -0,0 +1,15 @@
module.exports = {
presets: [['@babel/preset-env', { loose: true }], '@babel/preset-typescript'],
plugins: [
['@babel/plugin-transform-class-properties', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-transform-private-property-in-object', { loose: true }],
[
'@babel/plugin-transform-react-jsx',
{
runtime: 'automatic',
importSource: 'preact',
},
],
],
};

View File

@@ -0,0 +1,127 @@
{
"name": "@rocket.chat/livechat",
"version": "2.1.7",
"homepage": "https://rocket.chat",
"repository": {
"type": "git",
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
"directory": "packages/livechat"
},
"license": "MIT",
"files": [
"/build"
],
"scripts": {
"build": "yarn clean && cross-env TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack-cli --mode production",
"build-storybook": "storybook build",
"clean": "rimraf dist",
"dev": "yarn build",
"eslint": "eslint .",
"lint": "yarn run eslint && yarn run stylelint",
"start": "cross-env TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack-dev-server --mode development",
"storybook": "storybook dev -p 9001 -c .storybook",
"stylelint": "stylelint 'src/**/*.scss'",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"browserslist": [
"last 2 versions",
"Firefox ESR"
],
"dependencies": {
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/gazzodown": "workspace:^",
"@rocket.chat/message-parser": "workspace:^",
"@rocket.chat/random": "workspace:~",
"@rocket.chat/ui-kit": "workspace:~",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"css-vars-ponyfill": "^2.4.9",
"date-fns": "~4.1.0",
"dompurify": "~3.4.14",
"emoji-mart": "^3.0.1",
"history": "~5.3.0",
"i18next": "~23.4.9",
"mem": "^8.1.1",
"path-to-regexp": "^6.3.0",
"preact": "~10.25.4",
"preact-render-to-string": "~6.7.0",
"preact-router": "^4.1.2",
"query-string": "^7.1.3",
"react-hook-form": "~7.54.2",
"react-i18next": "~13.2.2",
"whatwg-fetch": "^3.6.20"
},
"devDependencies": {
"@babel/core": "~7.29.7",
"@babel/preset-env": "~7.29.7",
"@babel/preset-react": "~7.28.5",
"@babel/preset-typescript": "~7.28.5",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/ddp-client": "workspace:^",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/logo": "^0.33.2",
"@rocket.chat/storybook-dark-mode": "workspace:~",
"@rocket.chat/ui-contexts": "workspace:^",
"@storybook/addon-docs": "^9.1.20",
"@storybook/addon-styling-webpack": "^1.0.1",
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
"@storybook/preact": "9.0.0-alpha.11",
"@storybook/preact-webpack5": "9.0.0-alpha.11",
"@types/crypto-js": "~4.2.2",
"@types/emoji-mart": "~3.0.14",
"@types/mini-css-extract-plugin": "~2.5.1",
"@types/react": "~19.2.18",
"@types/webpack-env": "~1.18.8",
"autoprefixer": "^10.5.4",
"babel-loader": "~10.0.0",
"cross-env": "^7.0.3",
"css-loader": "^6.11.0",
"cssnano": "^7.1.9",
"desvg-loader": "^0.1.0",
"eslint": "~9.39.5",
"file-loader": "^6.2.0",
"html-webpack-plugin": "~5.6.8",
"if-env": "^1.0.4",
"lorem-ipsum": "^2.0.10",
"mini-css-extract-plugin": "~2.9.4",
"npm-run-all": "^4.1.5",
"postcss": "8.5.26",
"postcss-css-variables": "^0.19.0",
"postcss-dir-pseudo-class": "^9.0.1",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-loader": "^8.1.1",
"postcss-logical": "^8.0.0",
"postcss-scss": "^4.0.9",
"postcss-selector-not": "^8.0.1",
"react": "~19.2.8",
"react-dom": "~19.2.8",
"resolve-url-loader": "~5.0.0",
"rimraf": "^6.0.1",
"sass": "~1.80.7",
"sass-loader": "~16.0.8",
"storybook": "^9.1.20",
"style-loader": "^1.3.0",
"stylelint": "^16.10.0",
"stylelint-order": "^6.0.4",
"svg-loader": "^0.0.2",
"terser-webpack-plugin": "~5.3.17",
"typescript": "~5.9.3",
"url-loader": "^4.1.1",
"webpack": "~5.104.1",
"webpack-cli": "~5.1.4",
"webpack-dev-server": "~5.2.6"
},
"peerDependencies": {
"@rocket.chat/fuselage-tokens": "*",
"@rocket.chat/logo": "*"
},
"volta": {
"extends": "../../package.json"
},
"houston": {
"updateFiles": [
"package.json"
]
}
}

View File

@@ -0,0 +1,10 @@
module.exports = {
plugins: {
'autoprefixer': {},
'postcss-selector-not': {},
'postcss-flexbugs-fixes': {},
'postcss-logical': {},
'postcss-dir-pseudo-class': {},
'cssnano': {},
},
};

View File

@@ -0,0 +1,6 @@
import type { CSSProperties } from 'preact/compat';
export type Theme = {
color: CSSProperties['backgroundColor'];
fontColor: CSSProperties['color'];
};

View File

@@ -0,0 +1,21 @@
import { LivechatClientImpl } from '@rocket.chat/ddp-client';
import { parse } from 'query-string';
const host =
window.SERVER_URL ?? parse(window.location.search).serverUrl ?? (process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null);
export const useSsl = Boolean((Array.isArray(host) ? host[0] : host)?.match(/^https:/));
export const Livechat = LivechatClientImpl.create(host.replace(/^http/, 'ws'));
Livechat.rest.use(async function (request, next) {
try {
return await next(...request);
} catch (error) {
if (error instanceof Response) {
const e = await error.json();
throw e;
}
throw error;
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,68 @@
import type { ComponentChildren, JSX as JSXInternal } from 'preact';
import { useCallback, useEffect } from 'preact/hooks';
import { useTranslation } from 'react-i18next';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
import CloseIcon from '../../icons/close.svg';
export type AlertProps = {
id?: string;
onDismiss?: (id?: string) => void;
success?: boolean;
warning?: boolean;
error?: boolean;
color?: string;
hideCloseButton?: boolean;
className?: string;
style?: JSXInternal.CSSProperties;
children?: ComponentChildren;
timeout?: number;
};
const Alert = ({
id,
onDismiss,
success,
warning,
error,
color,
hideCloseButton = false,
className,
style = {},
children,
timeout = 3000,
}: AlertProps) => {
const { t } = useTranslation();
const handleDismiss = useCallback(() => {
onDismiss?.(id);
}, [id, onDismiss]);
useEffect(() => {
let dismissTimeout: ReturnType<typeof setTimeout> | undefined;
if (Number.isFinite(timeout) && timeout > 0) {
dismissTimeout = setTimeout(handleDismiss, timeout);
}
return () => clearTimeout(dismissTimeout);
}, [handleDismiss, timeout]);
return (
<div
role='alert'
className={createClassName(styles, 'alert', { success, warning, error }, [className])}
style={{
...style,
...(color && { backgroundColor: color }),
}}
>
<div className={createClassName(styles, 'alert__content')}>{children}</div>
{!hideCloseButton && (
<button onClick={handleDismiss} className={createClassName(styles, 'alert__close')} aria-label={t('dismiss_this_alert')}>
<CloseIcon width={20} height={20} />
</button>
)}
</div>
);
};
export default Alert;

View File

@@ -0,0 +1,66 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import Alert from '.';
import { loremIpsum, screenDecorator } from '../../../.storybook/helpers';
export default {
title: 'Components/Alert',
component: Alert,
args: {
success: false,
warning: false,
error: false,
color: '',
timeout: 5000,
onDismiss: action('dismiss'),
children: loremIpsum({ count: 3, units: 'words' }),
},
parameters: {
layout: 'centered',
},
decorators: [screenDecorator],
} satisfies Meta<ComponentProps<typeof Alert>>;
const Template: StoryFn<ComponentProps<typeof Alert>> = (args) => <Alert {...args} />;
export const Default = Template.bind({});
Default.storyName = 'default';
Default.args = {};
export const Success = Template.bind({});
Success.storyName = 'success';
Success.args = {
success: true,
};
export const Warning = Template.bind({});
Warning.storyName = 'warning';
Warning.args = {
warning: true,
};
export const Error = Template.bind({});
Error.storyName = 'error';
Error.args = {
error: true,
};
export const CustomColor = Template.bind({});
CustomColor.storyName = 'custom color';
CustomColor.args = {
color: '#175CC4',
};
export const WithLongTextContent = Template.bind({});
WithLongTextContent.storyName = 'with long text content';
WithLongTextContent.args = {
children: loremIpsum({ count: 30, units: 'words' }),
};
export const WithoutTimeout = Template.bind({});
WithoutTimeout.storyName = 'without timeout';
WithoutTimeout.args = {
timeout: 0,
};

View File

@@ -0,0 +1,65 @@
@use '../../styles/colors';
@use '../../styles/helpers';
@use '../../styles/variables';
$alert-color: colors.$color-text-lighter;
$alert-font-family: variables.$font-family;
$alert-background-color: colors.$bg-color-darker;
$alert-success-background-color: colors.$color-green;
$alert-warning-background-color: colors.$color-yellow;
$alert-error-background-color: colors.$color-red;
.alert {
display: flex;
overflow: hidden;
width: 100%;
height: 28px;
padding: 6px 16px;
letter-spacing: 0;
color: $alert-color;
background-color: $alert-background-color;
font-family: $alert-font-family;
font-size: 12px;
font-weight: 600;
line-height: 16px;
align-items: center;
justify-content: space-between;
&__content {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__close {
display: flex;
padding: 0;
cursor: pointer;
color: $alert-color;
border: none;
outline: none;
background: none;
@include helpers.pressable-button(1px);
}
&--success {
background-color: $alert-success-background-color;
}
&--warning {
background-color: $alert-warning-background-color;
}
&--error {
background-color: $alert-error-background-color;
}
}

View File

@@ -0,0 +1,189 @@
import type { ILivechatTrigger } from '@rocket.chat/core-typings';
import { useLayoutEffect, useState } from 'preact/hooks';
import Router, { route } from 'preact-router';
import { useTranslation } from 'react-i18next';
import type { Department } from '../../definitions/departments';
import { setInitCookies } from '../../helpers/cookies';
import { isRTL } from '../../helpers/isRTL';
import { visibility } from '../../helpers/visibility';
import history from '../../history';
import Connection from '../../lib/connection';
import CustomFields from '../../lib/customFields';
import Hooks from '../../lib/hooks';
import { parentCall } from '../../lib/parentCall';
import Triggers from '../../lib/triggers';
import userPresence from '../../lib/userPresence';
import Chat from '../../routes/Chat';
import ChatFinished from '../../routes/ChatFinished';
import GDPRAgreement from '../../routes/GDPRAgreement';
import LeaveMessage from '../../routes/LeaveMessage';
import Register from '../../routes/Register';
import SwitchDepartment from '../../routes/SwitchDepartment';
import TriggerMessage from '../../routes/TriggerMessage';
import store, { type Dispatch, type StoreState } from '../../store';
import { ScreenProvider } from '../Screen/ScreenProvider';
type AppProps = {
config: {
settings: StoreState['config']['settings'];
theme: StoreState['config']['theme'];
online?: boolean;
departments: Department[];
enabled?: boolean;
triggers: ILivechatTrigger[];
};
gdpr: {
accepted: boolean;
};
triggered?: boolean;
user?: {
token: string;
};
dispatch: Dispatch;
sound: {
enabled: boolean;
};
minimized: boolean;
undocked?: boolean;
expanded: boolean;
modal: boolean;
alerts: {
id: string;
}[];
iframe: StoreState['iframe'];
};
export const App = ({ dispatch }: AppProps) => {
const { t } = useTranslation();
const [initialized, setInitialized] = useState(false);
const handleRoute = async ({ url }: { url: string }) => {
setTimeout(() => {
// Read the current store state, not the render-closure props: route('/')
// fires synchronously from the room store subscription before this
// component re-renders, so the closure would still hold the pre-login
// user and wrongly redirect a token-logged-in guest to /register.
const { config, gdpr, user } = store.state;
const {
settings: {
registrationForm,
nameFieldRegistrationForm,
emailFieldRegistrationForm,
forceAcceptDataProcessingConsent: gdprRequired,
},
online,
departments,
} = config;
const { accepted: gdprAccepted } = gdpr;
setInitCookies();
if (gdprRequired && !gdprAccepted) {
route('/gdpr');
return;
}
if (!online) {
parentCall('callback', 'no-agent-online');
route('/leave-message');
return;
}
const showDepartment = departments.some((dept) => dept.showOnRegistration);
const isAnyFieldVisible = nameFieldRegistrationForm || emailFieldRegistrationForm || showDepartment;
const showRegistrationForm = !user?.token && registrationForm && isAnyFieldVisible && !Triggers.hasTriggersBeforeRegistration();
if (url === '/' && showRegistrationForm) {
route('/register');
}
}, 100);
};
// Emulates componentDidMount / componentWillUnmount. useLayoutEffect (not
// useEffect) keeps the init/teardown synchronous with the commit, matching
// the class lifecycle these behaviors were ported from.
useLayoutEffect(() => {
const handleVisibilityChange = async () => {
dispatch({ visible: !visibility.hidden });
};
// Reads from the store at call time (after Connection.init has loaded the
// config), not from the mount-render closure whose config is still the
// initial empty state — otherwise Triggers.init() never runs.
const handleTriggers = () => {
const { config } = store.state;
if (config.online && config.enabled) {
Triggers.init();
}
void Triggers.processTriggers();
};
const initWidget = () => {
const { config, minimized, iframe, undocked } = store.state;
if (!undocked) {
parentCall(minimized ? 'minimizeWindow' : 'restoreWindow');
parentCall(iframe.visible ? 'showWidget' : 'hideWidget');
parentCall('setWidgetPosition', config.theme.position || 'right');
}
visibility.addListener(handleVisibilityChange);
void handleVisibilityChange();
window.addEventListener('beforeunload', () => {
visibility.removeListener(handleVisibilityChange);
dispatch({ minimized: true, undocked: false });
});
};
void (async () => {
// TODO: split these behaviors into composable components
await Connection.init();
CustomFields.init();
userPresence.init();
Hooks.init();
handleTriggers();
initWidget();
setInitialized(true);
parentCall('ready');
})();
return () => {
CustomFields.reset();
userPresence.reset();
visibility.removeListener(handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Emulates componentDidUpdate: keep the document direction in sync with the
// active language (react-i18next re-renders on languageChanged).
useLayoutEffect(() => {
if (t) {
document.dir = isRTL(t('yes')) ? 'rtl' : 'ltr';
}
}, [t]);
if (!initialized) {
return null;
}
return (
<ScreenProvider>
<Router history={history} onChange={handleRoute}>
<Chat path='/' default />
<ChatFinished path='/chat-finished' />
<GDPRAgreement path='/gdpr' />
<LeaveMessage path='/leave-message' />
<Register path='/register' />
<SwitchDepartment path='/switch-department' />
<TriggerMessage path='/trigger-messages' />
</Router>
</ScreenProvider>
);
};
export default App;

View File

@@ -0,0 +1,66 @@
import i18next from 'i18next';
import { Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks';
import { parse } from 'query-string';
import { I18nextProvider } from 'react-i18next';
import App from './App';
import SDKProvider from '../../providers/SDKProvider';
import ServerProvider from '../../providers/ServerProvider';
import { Provider as StoreProvider, Consumer as StoreConsumer } from '../../store';
const serverURL =
window.SERVER_URL ?? parse(window.location.search).serverUrl ?? (process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null);
const AppConnector = () => {
useEffect(() => {
void import('../../i18next');
}, []);
return (
<div id='app'>
<Suspense fallback={null}>
<I18nextProvider i18n={i18next}>
<StoreProvider>
<SDKProvider serverURL={serverURL}>
<ServerProvider serverURL={serverURL}>
<StoreConsumer>
{({
config,
user,
triggered,
gdpr,
sound,
undocked,
minimized = true,
expanded = false,
alerts,
modal,
dispatch,
iframe,
}) => (
<App
config={config}
gdpr={gdpr}
triggered={triggered}
user={user}
sound={sound}
undocked={undocked}
minimized={minimized}
expanded={expanded}
alerts={alerts}
modal={modal}
dispatch={dispatch}
iframe={iframe}
/>
)}
</StoreConsumer>
</ServerProvider>
</SDKProvider>
</StoreProvider>
</I18nextProvider>
</Suspense>
</div>
);
};
export default AppConnector;

View File

@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { Avatar } from '.';
import { gazzoAvatar } from '../../../.storybook/helpers';
export default {
component: Avatar,
args: {
src: gazzoAvatar,
description: 'user description',
status: undefined,
large: false,
},
argTypes: {
status: {
control: {
type: 'select',
options: [undefined, 'offline', 'away', 'busy', 'online'],
},
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof Avatar>>;
type Story = StoryObj<ComponentProps<typeof Avatar>>;
export const Default: Story = {
name: 'default',
};
export const Large: Story = {
name: 'large',
args: {
large: true,
},
};
export const Small: Story = {
name: 'small',
args: {
small: true,
},
};
export const AsPlaceholder: Story = {
name: 'as placeholder',
args: {
src: '',
},
render: (args) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-around' }}>
<Avatar {...args} style={{ margin: '0.5rem' }} large />
<Avatar {...args} style={{ margin: '0.5rem' }} />
<Avatar {...args} style={{ margin: '0.5rem' }} small />
</div>
),
};
export const WithStatusIndicator: Story = {
name: 'with status indicator',
render: (args) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-around' }}>
<Avatar {...args} style={{ margin: '0.5rem' }} status='offline' />
<Avatar {...args} style={{ margin: '0.5rem' }} status='away' />
<Avatar {...args} style={{ margin: '0.5rem' }} status='busy' />
<Avatar {...args} style={{ margin: '0.5rem' }} status='online' />
</div>
),
};

View File

@@ -0,0 +1,51 @@
import { Component } from 'preact';
import type { CSSProperties } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
type AvatarProps = {
small?: boolean;
large?: boolean;
src?: string;
description?: string;
status?: string;
className?: string;
style?: CSSProperties;
};
type AvatarState = {
errored: boolean;
};
export class Avatar extends Component<AvatarProps, AvatarState> {
static override getDerivedStateFromProps(props: AvatarProps) {
if (props.src) {
return { errored: false };
}
return null;
}
override state = {
errored: false,
};
handleError = () => {
this.setState({ errored: true });
};
render = ({ small, large, src, description, status, className, style }: AvatarProps, { errored }: AvatarState) => (
<div
aria-label='User picture'
className={createClassName(styles, 'avatar', { small, large, nobg: src && !errored }, [className])}
style={style}
>
{src && !errored && (
<img src={src} alt={description} className={createClassName(styles, 'avatar__image')} onError={this.handleError} />
)}
{status && <span className={createClassName(styles, 'avatar__status', { small, large, status })} />}
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,93 @@
@use '../../styles/colors';
@use '../../styles/variables';
$avatar-size-small: 20px;
$avatar-status-indicator-size-small: 10px;
$avatar-size-medium: 32px;
$avatar-status-indicator-size-medium: 12px;
$avatar-size-large: 46px;
$avatar-status-indicator-size-large: 14px;
.avatar {
position: relative;
flex: 0 0 auto;
width: $avatar-size-medium;
height: $avatar-size-medium;
border-radius: variables.$default-border-radius;
background-color: #000000;
background-image: url(./profile.png);
background-repeat: no-repeat;
background-position: right;
background-size: contain;
&__image {
width: 100%;
height: 100%;
color: transparent;
border-radius: variables.$default-border-radius;
object-fit: cover;
}
&__status {
position: absolute;
right: -2px;
bottom: -3px;
overflow: hidden;
width: $avatar-status-indicator-size-medium;
height: $avatar-status-indicator-size-medium;
border: 2px solid var(--color, transparent);
border-radius: 50%;
background-color: colors.$bg-color-grey;
&--small {
right: -2px;
bottom: -2px;
width: $avatar-status-indicator-size-small;
height: $avatar-status-indicator-size-small;
}
&--large {
right: -2px;
bottom: -4px;
width: $avatar-status-indicator-size-large;
height: $avatar-status-indicator-size-large;
}
&--status {
&-online {
background-color: colors.$color-green;
}
&-away {
background-color: colors.$color-yellow;
}
&-busy {
background-color: colors.$color-red;
}
}
}
&--nobg {
background: none;
}
&--small {
width: $avatar-size-small;
height: $avatar-size-small;
}
&--large {
width: $avatar-size-large;
height: $avatar-size-large;
}
}

View File

@@ -0,0 +1,98 @@
import type { ComponentChildren, JSX as JSXInternal } from 'preact';
import type { CSSProperties } from 'preact/compat';
import { useTranslation } from 'react-i18next';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
const handleMouseUp: JSXInternal.EventHandler<JSXInternal.TargetedMouseEvent<HTMLButtonElement>> = ({ target }) =>
(target as HTMLButtonElement)?.blur();
export type ButtonProps = {
children?: ComponentChildren;
submit?: boolean;
form?: string;
disabled?: boolean;
outline?: boolean;
nude?: boolean;
danger?: boolean;
secondary?: boolean;
stack?: boolean;
small?: boolean;
loading?: boolean;
badge?: number;
icon?: ComponentChildren;
className?: string;
style?: CSSProperties;
img?: string;
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>;
onMouseUp?: JSXInternal.MouseEventHandler<HTMLButtonElement>;
full?: boolean;
};
export const Button = ({
submit,
form,
disabled,
outline,
nude,
danger,
secondary,
stack,
small,
loading,
badge,
icon,
onClick,
className,
style = {},
children,
img,
full,
...props
}: ButtonProps) => {
const { t } = useTranslation();
return (
<button
type={submit ? 'submit' : 'button'}
form={form}
disabled={disabled}
onClick={onClick}
onMouseUp={handleMouseUp}
aria-label={icon && Array.isArray(children) ? children[0] : children}
className={createClassName(
styles,
'button',
{
disabled,
outline,
nude,
danger,
secondary,
stack,
small,
loading,
icon: !!icon,
img,
full,
},
[className],
)}
style={Object.assign(
{},
style,
img && {
backgroundImage: `url(${img})`,
},
)}
{...props}
>
{badge ? (
<span role='status' aria-label={t('unread_messages_count', { count: badge })} className={createClassName(styles, 'button__badge')}>
{badge}
</span>
) : null}
{!img && (icon || children)}
</button>
);
};

View File

@@ -0,0 +1,111 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { Button } from '.';
import { gazzoAvatar } from '../../../.storybook/helpers';
import ChatIcon from '../../icons/chat.svg';
export default {
title: 'components/Button',
component: Button,
args: {
disabled: false,
outline: false,
nude: false,
danger: false,
secondary: false,
stack: false,
small: false,
loading: false,
badge: undefined,
icon: null,
img: undefined,
children: 'Powered by Rocket.Chat',
onClick: action('clicked'),
},
argTypes: {
icon: {
options: ['none', 'chat'],
mapping: {
none: null,
chat: <ChatIcon />,
},
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof Button>>;
const Template: StoryFn<ComponentProps<typeof Button>> = (args) => <Button {...args} />;
export const Normal = Template.bind({});
Normal.storyName = 'normal';
export const Disabled = Template.bind({});
Disabled.storyName = 'disabled';
Disabled.args = {
disabled: true,
};
export const Outline = Template.bind({});
Outline.storyName = 'outline';
Outline.args = {
outline: true,
};
export const Nude = Template.bind({});
Nude.storyName = 'nude';
Nude.args = {
nude: true,
};
export const Danger = Template.bind({});
Danger.storyName = 'danger';
Danger.args = {
danger: true,
};
export const Secondary = Template.bind({});
Secondary.storyName = 'secondary';
Secondary.args = {
secondary: true,
};
export const Stack = Template.bind({});
Stack.storyName = 'stack';
Stack.args = {
stack: true,
};
export const Small = Template.bind({});
Small.storyName = 'small';
Small.args = {
small: true,
};
export const Loading = Template.bind({});
Loading.storyName = 'loading';
Loading.args = {
loading: true,
};
export const WithBadge = Template.bind({});
WithBadge.storyName = 'with badge';
WithBadge.args = {
badge: 123,
};
export const WithIcon = Template.bind({});
WithIcon.storyName = 'with icon';
WithIcon.args = {
icon: <ChatIcon />,
};
export const TransparentWithBackgroundImage = Template.bind({});
TransparentWithBackgroundImage.storyName = 'transparent with background image';
TransparentWithBackgroundImage.args = {
img: gazzoAvatar,
icon: <ChatIcon />,
};

View File

@@ -0,0 +1,220 @@
@use 'sass:math';
@use '../../styles/colors';
@use '../../styles/helpers';
@use '../../styles/variables';
$button-border-width: variables.$default-border;
$button-border-radius: variables.$default-border-radius;
$button-padding: (0.75 * variables.$default-gap - variables.$default-border) (1.5 * variables.$default-gap - variables.$default-border);
$button-small-padding: (0.25 * variables.$default-gap - math.div(variables.$default-border, 2)) (1.5 * variables.$default-gap - variables.$default-border);
$button-active-displacement: 2px;
$button-color: colors.$color-text-lighter;
$button-background-color: colors.$color-blue;
$button-danger-background-color: colors.$color-dark-red;
$button-secondary-background-color: colors.$color-text-grey;
$button-font-family: variables.$font-family;
$button-font-size: 0.875rem;
$button-font-weight: 500;
$button-line-height: 1.25rem;
$button-disabled-opacity: variables.$disabled-opacity;
$button-loading-border-width: (2 * variables.$default-border);
$button-loading-gap: math.div(variables.$default-gap, 2);
$button-loading-size: $button-line-height;
$button-loading-color: #ffffff;
$button-badge-size: 1.5rem;
$button-badge-background-color: colors.$color-red;
$button-badge-color: colors.$color-text-lighter;
$button-badge-font-family: variables.$font-family;
$button-badge-font-size: 0.8125rem;
$button-badge-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2);
$button-icon-size: 54px;
$button-icon-border-radius: 4px;
$button-icon-padding: 10px;
.button {
@mixin state($color, $alpha-color: rgba($color, 0.5)) {
border-color: $color;
background: $color;
&.button--outline,
&.button--nude {
color: $color;
&.button--loading::after {
border-color: $color $alpha-color $alpha-color $alpha-color;
}
}
}
position: relative;
display: flex;
flex-direction: row;
box-sizing: border-box;
padding: $button-padding;
cursor: pointer;
user-select: none;
transition:
color variables.$default-time-animation,
background-color variables.$default-time-animation,
border-color variables.$default-time-animation,
transform math.div(variables.$default-time-animation, 2);
white-space: nowrap;
text-decoration: none;
color: var(--font-color, $button-color);
border: $button-border-width solid;
border-radius: $button-border-radius;
font-family: $button-font-family;
font-size: $button-font-size;
font-weight: $button-font-weight;
line-height: $button-line-height;
justify-content: center;
@include state(var(--color, $button-background-color), transparent);
@include helpers.pressable-button($button-active-displacement, $button-border-width);
&:focus {
box-shadow: 0 0 5px rgba(#000000, 0.5);
}
&--danger {
color: $button-color;
@include state($button-danger-background-color);
}
&--secondary {
color: $button-color;
@include state($button-secondary-background-color);
}
&--outline {
background: none;
}
&--nude {
border-color: transparent;
background: none;
&:focus {
box-shadow: none;
}
}
&--img {
border: 0;
background-position: center;
background-size: cover;
box-shadow: 0 0 5px rgba(#000000, 0.5) !important;
&:focus {
box-shadow: none;
}
}
&--stack {
width: 100%;
}
&--small {
padding: $button-small-padding;
}
&--full {
width: 100%;
}
&--disabled {
cursor: not-allowed;
opacity: $button-disabled-opacity;
}
&--loading {
&::after {
position: relative;
left: $button-loading-gap;
display: inline-flex;
box-sizing: border-box;
width: $button-line-height;
height: $button-line-height;
content: "";
animation: button-loading-rotation 1s linear infinite;
border: $button-loading-border-width solid;
border-color: var(--font-color, $button-loading-color) transparent transparent transparent;
border-radius: 50%;
}
&.button--danger::after,
&.button--secondary::after {
border-color:
$button-loading-color
rgba($button-loading-color, 0.5)
rgba($button-loading-color, 0.5)
rgba($button-loading-color, 0.5);
}
}
&--icon {
width: $button-icon-size;
height: $button-icon-size;
padding: $button-icon-padding;
border-radius: $button-icon-border-radius;
}
&__badge {
position: absolute;
top: math.div(-$button-badge-size, 3);
right: math.div(-$button-badge-size, 3);
min-width: $button-badge-size;
height: $button-badge-size;
padding: 0 math.div($button-badge-font-size, 2);
text-align: center;
letter-spacing: 0;
color: $button-badge-color;
border-radius: math.div($button-badge-size, 2);
background-color: $button-badge-background-color;
box-shadow: $button-badge-shadow;
font-family: $button-badge-font-family;
font-size: $button-badge-font-size;
font-weight: bold;
line-height: $button-badge-size;
}
svg {
flex: 1;
}
}
@keyframes button-loading-rotation {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { ButtonGroup } from '.';
import { Button } from '../Button';
export default {
component: ButtonGroup,
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof ButtonGroup>>;
type Story = StoryObj<ComponentProps<typeof ButtonGroup>>;
export const WithButtonsOfSameSize: Story = {
name: 'with buttons of same size',
render: (args) => (
<ButtonGroup {...args}>
<Button>Yes</Button>
<Button outline>Cancel</Button>
<Button danger>No</Button>
</ButtonGroup>
),
};
export const WithButtonsOfDifferentSizes: Story = {
name: 'with buttons of different sizes',
render: (args) => (
<ButtonGroup {...args}>
<Button small>Yes</Button>
<Button outline>Cancel</Button>
<Button small danger>
No
</Button>
</ButtonGroup>
),
};
export const WithOnlySmallButtons: Story = {
name: 'with only small buttons',
render: (args) => (
<ButtonGroup {...args}>
<Button small>Yes</Button>
<Button small outline>
Cancel
</Button>
<Button small danger>
No
</Button>
</ButtonGroup>
),
};
export const WithStackedButtons: Story = {
name: 'with stacked buttons',
render: (args) => (
<ButtonGroup {...args}>
<Button small outline>
Rename
</Button>
<Button small outline>
Share
</Button>
<Button stack danger>
Delete
</Button>
</ButtonGroup>
),
};

View File

@@ -0,0 +1,14 @@
import type { ComponentChildren } from 'preact';
import { cloneElement, isValidElement, toChildArray } from 'preact';
import { memo } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export const ButtonGroup = memo(({ children, full = false }: { children: ComponentChildren; full?: boolean }) => (
<div className={createClassName(styles, 'button-group', { full })}>
{toChildArray(children).map((child) =>
isValidElement(child) ? cloneElement(child, { className: createClassName(styles, 'button-group__item') }) : child,
)}
</div>
));

View File

@@ -0,0 +1,21 @@
@use 'sass:math';
@use '../../styles/variables';
$button-group-margin: math.div(variables.$default-gap, 4);
.button-group {
display: flex;
margin: -$button-group-margin;
align-items: center;
flex-flow: row wrap;
&--full {
width: 100%;
}
&__item {
margin: $button-group-margin;
flex-grow: 1;
}
}

View File

@@ -0,0 +1,26 @@
import type { ComponentChildren } from 'preact';
import type { CSSProperties } from 'preact/compat';
import { memo } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type ComposerActionProps = {
text: string;
onClick: () => void;
className?: string;
style?: CSSProperties;
children?: ComponentChildren;
};
export const ComposerAction = memo(({ text, onClick, className, style = {}, children }: ComposerActionProps) => (
<button
type='button'
aria-label={text}
onClick={onClick}
className={createClassName(styles, 'composer__action', {}, [className])}
style={style}
>
{children}
</button>
));

View File

@@ -0,0 +1,26 @@
@use '../../../styles/helpers';
$composer-action-margin: 0 6px;
.composer__action {
flex: 0 0 auto;
margin: $composer-action-margin;
padding: 0;
cursor: pointer;
border: none;
background: none;
font-size: 0;
&:focus {
transform: scale(1.05);
outline: none;
}
@include helpers.pressable-button(1px);
}

View File

@@ -0,0 +1,18 @@
import type { ComponentChildren } from 'preact';
import type { CSSProperties } from 'preact/compat';
import { memo } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type ComposerActionsProps = {
className?: string;
style?: CSSProperties;
children?: ComponentChildren;
};
export const ComposerActions = memo(({ className, style = {}, children }: ComposerActionsProps) => (
<div className={createClassName(styles, 'composer__actions', {}, [className])} style={style}>
{children}
</div>
));

View File

@@ -0,0 +1,6 @@
.composer__actions {
display: flex;
flex: 0 0 auto;
font-size: 0;
}

View File

@@ -0,0 +1,300 @@
import DOMPurify from 'dompurify';
import type { ComponentChildren } from 'preact';
import { Component } from 'preact';
import type { CSSProperties } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
import { parse } from '../../helpers/parse';
const findLastTextNode = (node: Node): Node | null => {
if (node.nodeType === Node.TEXT_NODE) {
return node;
}
const children = node.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
const textNode = findLastTextNode(children[i]);
if (textNode !== null) {
return textNode;
}
}
return null;
};
const replaceCaret = (el: Element) => {
// Place the caret at the end of the element
const target = findLastTextNode(el);
// do not move caret if element was not focused
const isTargetFocused = document.activeElement === el;
if (!!target?.nodeValue && isTargetFocused) {
const range = document.createRange();
const sel = window.getSelection();
range.setStart(target, target.nodeValue.length);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
if (el instanceof HTMLElement) {
el.focus();
}
}
};
type ComposerProps = {
className?: string;
style?: CSSProperties;
value?: string;
onChange?: (value: string) => void;
onSubmit?: (value: string) => void;
onUpload?: (files: (File | null)[]) => void;
handleEmojiClick?: () => void;
placeholder?: string;
pre?: ComponentChildren;
post?: ComponentChildren;
notifyEmojiSelect?: (cb: (emoji: string) => void) => void;
limitTextLength?: number;
};
type ComposerState = {
inputLock: boolean;
};
export class Composer extends Component<ComposerProps, ComposerState> {
private el: HTMLElement | null = null;
handleRef = (el: HTMLDivElement | null) => {
this.el = el;
};
handleInput = (onChange?: (value: string) => void) => () => {
if (this.state.inputLock) {
return;
}
onChange?.(this.el?.innerText ?? '');
};
handleKeypress = (onSubmit?: (value: string) => void) => (event: KeyboardEvent) => {
if (event.which === 13 && !event.shiftKey) {
event.preventDefault();
if (this.el) {
onSubmit?.(this.el.innerText);
this.el.innerText = '';
}
}
};
handlePaste = (onUpload?: (files: (File | null)[]) => void) => async (event: ClipboardEvent) => {
if (!event.clipboardData?.items) {
return;
}
event.preventDefault();
const items = Array.from(event.clipboardData.items);
const files = items.filter((item) => item.kind === 'file' && /^image\//.test(item.type)).map((item) => item.getAsFile());
if (files.length) {
onUpload?.(files);
return;
}
const texts = await Promise.all(
items
.filter((item) => item.kind === 'string' && /^text\/plain/.test(item.type))
.map((item) => new Promise<string>((resolve) => item.getAsString(resolve))),
);
texts.forEach((text) => this.pasteText(parse(text)));
};
handleDrop = (onUpload?: (files: (File | null)[]) => void) => async (event: DragEvent) => {
if (!event.dataTransfer?.items) {
return;
}
event.preventDefault();
const items = Array.from(event.dataTransfer.items);
const files = items.filter((item) => item.kind === 'file' && /^image\//.test(item.type)).map((item) => item.getAsFile());
if (files.length) {
onUpload?.(files);
return;
}
const texts = await Promise.all(
items
.filter((item) => item.kind === 'string' && /^text\/plain/.test(item.type))
.map((item) => new Promise<string>((resolve) => item.getAsString(resolve))),
);
texts.forEach((text) => this.pasteText(parse(text)));
};
handleClick = () => {
const { handleEmojiClick } = this.props;
handleEmojiClick?.();
};
pasteText = (plainText: string) => {
this.el?.focus();
if (document.queryCommandSupported('insertText')) {
document.execCommand('insertText', false, plainText);
return;
}
const range = document.getSelection()?.getRangeAt(0);
if (!range) {
return;
}
range.deleteContents();
const textNode = document.createTextNode(plainText);
range.insertNode(textNode);
range.selectNodeContents(textNode);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
};
private value: string | undefined;
constructor(props: ComposerProps) {
super(props);
this.state = {
inputLock: false,
};
this.value = this.props.value;
this.handleNotifyEmojiSelect = this.handleNotifyEmojiSelect.bind(this);
if (typeof this.props.notifyEmojiSelect === 'function') {
this.props.notifyEmojiSelect(this.handleNotifyEmojiSelect);
}
}
// we only update composer if value length changed from 0 to 1 or 1 to 0
// everything else is managed by this.el
override shouldComponentUpdate({ value: nextValue = '' }: ComposerProps) {
const { value = '', limitTextLength } = this.props;
const nextValueEmpty = !nextValue || nextValue.length === 0;
const valueEmpty = !value || value.length === 0;
if (nextValueEmpty !== valueEmpty) {
return true;
}
if (nextValue.length === limitTextLength || value.length === limitTextLength) {
return true;
}
return false;
}
override componentDidUpdate() {
const { el } = this;
if (!el) {
return;
}
if (this.props.value !== el.innerHTML) {
this.value = this.props.value ?? '';
el.innerHTML = this.value;
}
replaceCaret(el);
}
handleNotifyEmojiSelect(emoji: string) {
if (!this.el) {
throw new Error('Composer: handleNotifyEmojiSelect called but el is not defined');
}
const { onChange } = this.props;
const caretPosition = this.getCaretPosition(this.el);
const oldText = this.el?.innerText ?? '';
const newText = `${oldText.slice(0, caretPosition)}${emoji}&nbsp;${oldText.slice(caretPosition)}`;
this.el.innerHTML = DOMPurify.sanitize(newText);
this.moveCursorToEndAndFocus(caretPosition + emoji.length + 1);
onChange?.(this.el.innerText);
}
moveCursorToEndAndFocus(endIndex: number) {
if (!this.el) {
throw new Error('Composer: moveCursorToEndAndFocus called but el is not defined');
}
const setPos = document.createRange();
const set = window.getSelection();
setPos.setStart(this.el.childNodes[0], endIndex);
setPos.collapse(true);
set?.removeAllRanges();
set?.addRange(setPos);
}
getCaretPosition(element: HTMLElement) {
const doc = element.ownerDocument || element.document;
const win = doc.defaultView || doc.parentWindow;
if (!win) {
throw new Error('Composer: getCaretPosition called but win is not defined');
}
if (typeof win.getSelection !== 'undefined' && (win.getSelection()?.rangeCount ?? 0) > 0) {
const range = win.getSelection()?.getRangeAt(0) as Range;
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
}
if (doc.selection && doc.selection.type !== 'Control') {
const textRange = doc.selection.createRange?.();
const preCaretTextRange = doc.body.createTextRange?.();
preCaretTextRange?.moveToElementText?.(element);
preCaretTextRange?.setEndPoint?.('EndToEnd', textRange as Range);
return preCaretTextRange?.text?.length ?? 0;
}
return 0;
}
handleInputLock(locked: boolean) {
this.setState({ inputLock: locked });
return 0;
}
render = ({ pre, post, value, placeholder, onChange, onSubmit, onUpload, className, style }: ComposerProps) => (
<div className={createClassName(styles, 'composer', {}, [className])} style={style}>
{pre}
<div
ref={this.handleRef}
role='textbox'
contentEditable
tabIndex={0}
data-placeholder={placeholder}
data-qa='livechat-composer'
onInput={this.handleInput(onChange)}
onKeyPress={this.handleKeypress(onSubmit)}
onPaste={this.handlePaste(onUpload)}
onDrop={this.handleDrop(onUpload)}
onClick={this.handleClick}
onCompositionStart={() => {
this.handleInputLock(true);
}}
onCompositionEnd={() => {
this.handleInputLock(false);
onChange?.(this.el?.innerText ?? '');
}}
className={createClassName(styles, 'composer__input')}
>
{value}
</div>
{post}
</div>
);
}
export { ComposerAction } from './ComposerAction';
export { ComposerActions } from './ComposerActions';

View File

@@ -0,0 +1,77 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { Composer, ComposerActions, ComposerAction } from '.';
import PlusIcon from '../../icons/plus.svg';
import SendIcon from '../../icons/send.svg';
import SmileIcon from '../../icons/smile.svg';
const defaultPlaceholder = 'Insert your text here';
export default {
title: 'Components/Composer',
component: Composer,
args: {
value: '',
placeholder: 'Insert your text here',
onChange: action('change'),
onSubmit: action('submit'),
onUpload: action('upload'),
},
decorators: [(storyFn) => <div style={{ width: 365 }}>{storyFn()}</div>],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof Composer>>;
const Template: StoryFn<ComponentProps<typeof Composer>> = (args) => <Composer {...args} />;
export const Default = Template.bind({});
Default.storyName = 'default';
export const WithLargePlaceholder = Template.bind({});
WithLargePlaceholder.storyName = 'with large placeholder';
WithLargePlaceholder.args = {
placeholder: new Array(5).fill(defaultPlaceholder).join(' '),
};
export const WithPlainText = Template.bind({});
WithPlainText.storyName = 'with plain text';
WithPlainText.args = {
value: 'Should I use &amp; or &?',
};
export const WithEmojis = Template.bind({});
WithEmojis.storyName = 'with emojis';
WithEmojis.args = {
value: ":heart: :smile: :'(",
};
export const WithMentions = Template.bind({});
WithMentions.storyName = 'with mentions';
WithMentions.args = {
value: "@all, I'm @here with @user.",
};
export const WithActions = Template.bind({});
WithActions.storyName = 'with actions';
WithActions.args = {
pre: (
<ComposerActions>
<ComposerAction text='Add emoji' onClick={action('click smile')}>
<SmileIcon width='20' />
</ComposerAction>
<ComposerAction text='Send' onClick={action('click send')}>
<SendIcon color='info' width='20' />
</ComposerAction>
</ComposerActions>
),
post: (
<ComposerActions>
<ComposerAction text='Add attachment' onClick={action('click plus')}>
<PlusIcon width='20' />
</ComposerAction>
</ComposerActions>
),
};

View File

@@ -0,0 +1,81 @@
@use '../../styles/colors';
@use '../../styles/helpers';
@use '../../styles/variables';
$composer-padding: 12px 6px;
$composer-border-width: variables.$default-border;
$composer-border-color: colors.$color-text-light;
$composer-border-radius: variables.$default-border-radius;
$composer-background-color: colors.$bg-color-white;
$composer-focused-border-color: colors.$color-text-grey;
$composer-input-min-height: 20px;
$composer-input-max-height: 150px;
$composer-input-padding: 2px 6px;
$composer-input-color: colors.$bg-color-dark;
$composer-input-font-size: 0.875rem;
$composer-input-font-weight: 500;
$composer-input-line-height: 1.25rem;
$composer-input-placeholder-color: colors.$color-text-light;
.composer {
display: flex;
width: 100%;
padding: $composer-padding;
transition: all variables.$default-time-animation;
border: $composer-border-width solid $composer-border-color;
border-radius: $composer-border-radius;
background-color: $composer-background-color;
align-items: flex-end;
&:focus-within {
border-color: $composer-focused-border-color;
}
&__input {
overflow-y: auto;
flex: 1 1 auto;
min-height: $composer-input-min-height;
max-height: $composer-input-max-height;
padding: $composer-input-padding;
resize: none;
cursor: text;
word-wrap: break-word;
color: $composer-input-color;
border: none;
outline: none;
font-size: $composer-input-font-size;
font-weight: $composer-input-font-weight;
line-height: $composer-input-line-height;
&:empty::before {
display: block; /* For Firefox */
overflow: hidden;
max-width: 100%;
content: attr(data-placeholder);
white-space: nowrap;
text-overflow: ellipsis;
color: $composer-input-placeholder-color;
}
}
&--connecting {
.composer__input {
cursor: default;
}
}
}

View File

@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { createRef } from 'preact';
import { action } from 'storybook/actions';
import { FilesDropTarget } from '.';
import { Button } from '../Button';
const DummyContent = () => (
<div
style={{
display: 'flex',
width: '100vw',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}}
>
Drop files here
<span style={{ border: '1px solid', padding: '1rem', margin: '1rem' }}>Or into this span</span>
</div>
);
export default {
component: FilesDropTarget,
args: {
overlayed: false,
overlayText: '',
accept: '',
multiple: false,
onUpload: action('upload'),
},
parameters: {
layout: 'fullscreen',
},
render: (args) => (
<FilesDropTarget {...args}>
<DummyContent />
</FilesDropTarget>
),
} satisfies Meta<ComponentProps<typeof FilesDropTarget>>;
type Story = StoryObj<ComponentProps<typeof FilesDropTarget>>;
export const Default: Story = {
name: 'default',
args: {},
};
export const Overlayed: Story = {
name: 'overlayed',
args: {
overlayed: true,
},
};
export const OverlayedWithText: Story = {
name: 'overlayed with text',
args: {
overlayed: true,
overlayText: 'You can release your files now',
},
};
export const AcceptingOnlyImages: Story = {
name: 'accepting only images',
args: {
accept: 'image/*',
},
};
export const AcceptingMultipleFiles: Story = {
name: 'accepting multiple files',
args: {
multiple: true,
},
};
const inputRef = createRef();
export const TriggeringBrowseAction: Story = {
name: 'triggering browse action',
args: {
children: (
<div
style={{
display: 'flex',
width: '100vw',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<Button onClick={() => inputRef.current.browse()}>Browse</Button>
</div>
),
inputRef,
},
};

View File

@@ -0,0 +1,121 @@
import type { ComponentChildren, Ref } from 'preact';
import { useState, type CSSProperties, type ChangeEvent, type TargetedEvent } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
const escapeForRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export type FilesDropTargetProps = {
overlayed?: boolean;
overlayText?: string;
accept?: string;
multiple?: boolean;
className?: string;
style?: CSSProperties;
children?: ComponentChildren;
inputRef?: Ref<HTMLInputElement>;
onUpload?: (files: File[]) => void;
};
export const FilesDropTarget = ({
overlayed,
overlayText,
accept,
multiple,
className,
style = {},
children,
inputRef,
onUpload,
}: FilesDropTargetProps) => {
const [dragLevel, setDragLevel] = useState(0);
const handleDragOver = (event: TargetedEvent<HTMLElement, DragEvent>) => {
event.preventDefault();
};
const handleDragEnter = (event: TargetedEvent<HTMLElement, DragEvent>) => {
event.preventDefault();
setDragLevel(dragLevel + 1);
};
const handleDragLeave = (event: TargetedEvent<HTMLElement, DragEvent>) => {
event.preventDefault();
setDragLevel(dragLevel - 1);
};
const handleDrop = (event: TargetedEvent<HTMLElement, DragEvent>) => {
event.preventDefault();
if (dragLevel === 0 || !event?.dataTransfer?.files?.length) {
return;
}
setDragLevel(0);
handleUpload(event?.dataTransfer?.files);
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!event?.currentTarget?.files?.length) {
return;
}
handleUpload(event.currentTarget.files);
};
const handleUpload = (files: FileList) => {
if (!onUpload) {
return;
}
let filteredFiles = Array.from(files);
if (accept) {
const acceptMatchers = accept.split(',').map((acceptString) => {
if (acceptString.charAt(0) === '.') {
return ({ name }: { name: string }) => new RegExp(`${escapeForRegExp(acceptString)}$`, 'i').test(name);
}
const matchTypeOnly = /^(.+)\/\*$/i.exec(acceptString);
if (matchTypeOnly) {
return ({ type }: { type: string }) => new RegExp(`^${escapeForRegExp(matchTypeOnly[1])}/.*$`, 'i').test(type);
}
return ({ type }: { type: string }) => new RegExp(`^s${escapeForRegExp(acceptString)}$`, 'i').test(type);
});
filteredFiles = filteredFiles.filter((file) => acceptMatchers.some((acceptMatcher) => acceptMatcher(file)));
}
if (!multiple) {
filteredFiles = filteredFiles.slice(0, 1);
}
if (filteredFiles.length) onUpload(filteredFiles);
};
return (
<div
data-overlay-text={overlayText}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={createClassName(styles, 'drop', { overlayed, dragover: dragLevel > 0 }, [className])}
style={style}
id='files-drop-target'
>
<input
ref={inputRef}
type='file'
accept={accept}
multiple={multiple}
onChange={handleInputChange}
className={createClassName(styles, 'drop__input')}
/>
{children}
</div>
);
};

View File

@@ -0,0 +1,71 @@
@use '../../styles/colors';
@use '../../styles/variables';
$drop-overlay-animation-time: variables.$default-time-animation;
$drop-overlay-background-color: rgba(colors.$bg-color-white, 0.9);
$drop-overlay-gap: variables.$default-padding;
$drop-overlay-border-width: 4px;
$drop-overlay-border-style: dashed;
$drop-overlay-text-font-size: 1.375rem;
.drop {
position: relative;
display: flex;
overflow: hidden;
flex-direction: column;
flex: 1 1 auto;
&.drop--overlayed.drop--dragover {
&::before {
position: absolute;
z-index: 10;
content: "";
animation: fadein $drop-overlay-animation-time;
pointer-events: none;
background-color: $drop-overlay-background-color;
inset: 0;
}
&::after {
position: absolute;
z-index: 10;
display: flex;
box-sizing: border-box;
padding: $drop-overlay-gap;
content: attr(data-overlay-text) "";
animation: fadein $drop-overlay-animation-time;
text-align: center;
pointer-events: none;
color: var(--color, colors.$color-blue);
border: $drop-overlay-border-width var(--color, colors.$color-blue) $drop-overlay-border-style;
font-size: $drop-overlay-text-font-size;
font-weight: 500;
align-items: center;
inset: $drop-overlay-gap $drop-overlay-gap $drop-overlay-gap $drop-overlay-gap;
justify-content: center;
}
}
&__input {
display: none;
}
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,19 @@
import type { CSSProperties } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type CharCounterProps = {
className?: string;
style?: CSSProperties;
textLength: number;
limitTextLength: number;
};
const CharCounter = ({ className, style = {}, textLength, limitTextLength }: CharCounterProps) => (
<span className={createClassName(styles, 'footer__remainder', { highlight: textLength === limitTextLength }, [className])} style={style}>
{textLength} / {limitTextLength}
</span>
);
export default CharCounter;

View File

@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import ChangeIcon from '../../icons/change.svg';
import FinishIcon from '../../icons/finish.svg';
import RemoveIcon from '../../icons/remove.svg';
import { Composer } from '../Composer';
import { MenuGroup, MenuItem } from '../Menu';
import { PopoverContainer } from '../Popover';
import Footer from './Footer';
import FooterContent from './FooterContent';
import FooterOptions from './FooterOptions';
import PoweredBy from './PoweredBy';
import '../../i18next';
export default {
component: Footer,
decorators: [
(storyFn) => (
<div style={{ display: 'flex', width: '100vw', height: '100vh' }}>
<PopoverContainer>
<div style={{ flex: '1' }} />
{storyFn()}
</PopoverContainer>
</div>
),
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<ComponentProps<typeof Footer>>;
type Story = StoryObj<ComponentProps<typeof Footer>>;
export const Simple: Story = {
name: 'simple',
render: (args) => (
<Footer {...args}>
<FooterContent>
<PoweredBy />
</FooterContent>
</Footer>
),
};
export const WithComposerAndOptions: Story = {
name: 'with Composer and options',
render: (args) => (
<Footer {...args}>
<FooterContent>
<Composer placeholder='Insert your text here' />
</FooterContent>
<FooterContent>
<FooterOptions>
<MenuGroup>
<MenuItem onClick={action('change-department')} icon={ChangeIcon}>
Change department
</MenuItem>
<MenuItem onClick={action('remove-user-data')} icon={RemoveIcon}>
Forget/Remove my personal data
</MenuItem>
<MenuItem danger onClick={action('finish-chat')} icon={FinishIcon}>
Finish this chat
</MenuItem>
</MenuGroup>
</FooterOptions>
<PoweredBy />
</FooterContent>
</Footer>
),
};

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type FooterProps = {
children: ComponentChildren;
className?: string;
};
const Footer = ({ children, className, ...props }: FooterProps) => (
<footer className={createClassName(styles, 'footer', {}, [className])} {...props}>
{children}
</footer>
);
export default Footer;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type FooterContentProps = {
children: ComponentChildren;
className?: string;
};
const FooterContent = ({ children, className, ...props }: FooterContentProps) => (
<div className={createClassName(styles, 'footer__content', {}, [className])} {...props}>
{children}
</div>
);
export default FooterContent;

View File

@@ -0,0 +1,16 @@
import type { ComponentChildren } from 'preact';
import { MenuPopover } from '../Menu';
import OptionsTrigger from './OptionsTrigger';
export type FooterOptionsProps = {
children: ComponentChildren;
};
const FooterOptions = ({ children }: FooterOptionsProps) => (
<MenuPopover trigger={OptionsTrigger} overlayed>
{children}
</MenuPopover>
);
export default FooterOptions;

View File

@@ -0,0 +1,23 @@
import { type MouseEventHandler } from 'preact/compat';
import { useTranslation } from 'react-i18next';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
const handleMouseUp: MouseEventHandler<HTMLButtonElement> = ({ target }) => (target as HTMLButtonElement | null)?.blur();
export type OptionsTriggerProps = {
pop: () => void;
};
const OptionsTrigger = ({ pop }: OptionsTriggerProps) => {
const { t } = useTranslation();
return (
<button className={createClassName(styles, 'footer__options')} onClick={pop} onMouseUp={handleMouseUp}>
{t('options')}
</button>
);
};
export default OptionsTrigger;

View File

@@ -0,0 +1,25 @@
import { RocketChatLogo } from '@rocket.chat/logo';
import { useTranslation } from 'react-i18next';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type PoweredByProps = {
className?: string;
};
export const PoweredBy = ({ className, ...props }: PoweredByProps) => {
const { t } = useTranslation();
return (
<h3 data-qa='livechat-watermark' className={createClassName(styles, 'powered-by', {}, [className])} {...props}>
{t('powered_by_rocket_chat').split('Rocket.Chat')[0]}
<a className={createClassName(styles, 'powered-by__logo')} href='https://rocket.chat' target='_blank' rel='noopener noreferrer'>
<RocketChatLogo />
</a>
{t('powered_by_rocket_chat').split('Rocket.Chat')[1]}
</h3>
);
};
export default PoweredBy;

View File

@@ -0,0 +1,6 @@
export { default as Footer } from './Footer';
export { default as FooterContent } from './FooterContent';
export { default as PoweredBy } from './PoweredBy';
export { default as OptionsTrigger } from './OptionsTrigger';
export { default as FooterOptions } from './FooterOptions';
export { default as CharCounter } from './CharCounter';

View File

@@ -0,0 +1,96 @@
@use '../../styles/colors';
@use '../../styles/helpers';
@use '../../styles/variables';
.footer {
z-index: 2;
display: flex;
flex-direction: column;
flex: 0 0 auto;
width: 100%;
padding: 4px 8px;
color: colors.$color-text-grey;
background-color: colors.$bg-color-white;
font-size: 0.625rem;
align-items: stretch;
justify-content: space-between;
&__content {
display: flex;
padding: 4px 8px;
}
&__options {
padding: 0;
cursor: pointer;
user-select: none;
transition: trasform variables.$default-time-animation;
text-align: left;
letter-spacing: 0.2px;
color: colors.$color-text-grey;
border: none;
background: none;
font-size: 0.625rem;
font-weight: bold;
line-height: 1;
&:hover,
&:focus {
color: black;
}
@include helpers.pressable-button(2px);
}
&__remainder {
min-width: 100px;
margin-left: 10px;
font-weight: bold;
&--highlight {
transition: color 0.2s;
color: colors.$color-red;
}
}
}
.powered-by {
width: 100%;
margin: 0;
user-select: none;
text-align: end;
font-size: 10px;
font-weight: 500;
align-self: flex-end;
.powered-by__logo {
display: inline-flex;
height: 10px;
margin: 0 5px;
vertical-align: middle;
align-items: center;
svg {
fill: #{colors.$color-text-grey};
&:hover {
fill: #db2323;
}
}
}
}

View File

@@ -0,0 +1,71 @@
import type { Control, FieldErrors, FieldValues } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { RegisterFormValues } from '../../../routes/Register';
import { FormField } from '../FormField';
import { SelectInput } from '../SelectInput';
import { TextInput } from '../TextInput';
export type CustomField = {
_id: string;
required?: boolean;
label?: string;
type: 'input' | 'select';
options?: string[];
defaultValue?: string;
regexp?: RegExp;
};
type RenderCustomFieldsProps = {
customFields: CustomField[];
loading: boolean;
control: Control<RegisterFormValues>;
errors: FieldErrors<FieldValues>;
};
export const CustomFields = ({ customFields, loading, control, errors }: RenderCustomFieldsProps) => {
const { t } = useTranslation();
const customFieldsList = customFields.map(({ _id, required = false, label, type, options, regexp, defaultValue }) => {
const rules =
type === 'select'
? { required }
: {
required,
...(regexp && {
pattern: {
value: regexp,
message: t('invalid', { field: label }),
},
}),
};
return (
<FormField label={label} required={required} key={_id} error={errors?.[_id]?.message?.toString()}>
<Controller
name={_id}
control={control}
defaultValue={defaultValue}
rules={rules}
render={({ field }) => {
switch (type) {
case 'input':
return <TextInput placeholder={t('insert_your_field_here', { field: label })} disabled={loading} {...field} />;
case 'select':
return (
<SelectInput
placeholder={t('choose_an_option')}
options={options?.map((option: string) => ({ value: option, label: option })) ?? []}
disabled={loading}
{...field}
/>
);
}
}}
/>
</FormField>
);
});
return <>{customFieldsList}</>;
};

View File

@@ -0,0 +1,51 @@
import type { Ref, JSX as JSXInternal } from 'preact';
import { memo, type TargetedEvent } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type DateInputProps = {
name?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
small?: boolean;
error?: boolean;
onChange?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
onInput?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
onBlur?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
className?: string;
style?: JSXInternal.CSSProperties;
ref?: Ref<HTMLInputElement>;
};
const DateInput = ({
name,
value,
placeholder,
disabled,
small,
error,
onChange,
onInput,
onBlur,
className,
style = {},
ref,
}: DateInputProps) => (
<input
type='date'
name={name}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={onChange}
onInput={onInput}
onBlur={onBlur}
className={createClassName(styles, 'date-input', { disabled, error, small }, [className])}
style={style}
ref={ref}
/>
);
export default memo(DateInput);

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import DateInput from '.';
import { Form, FormField } from '..';
export default {
title: 'Forms/DateInput',
component: DateInput,
args: {
value: '',
placeholder: 'Placeholder',
disabled: false,
small: false,
error: false,
onChange: action('change'),
onInput: action('input'),
},
decorators: [
(storyFn) => (
<Form>
<FormField>{storyFn()}</FormField>
</Form>
),
],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof DateInput>>;
const Template: StoryFn<ComponentProps<typeof DateInput>> = (args) => <DateInput {...args} />;
export const Default = Template.bind({});
Default.storyName = 'default';
export const Filled = Template.bind({});
Filled.storyName = 'filled';
Filled.args = {
value: 'Value',
};
export const Disabled = Template.bind({});
Disabled.storyName = 'disabled';
Disabled.args = {
disabled: true,
};
export const Small = Template.bind({});
Small.storyName = 'small';
Small.args = {
small: true,
};
export const WithError = Template.bind({});
WithError.storyName = 'with error';
WithError.args = {
error: true,
};

View File

@@ -0,0 +1,5 @@
@use '../mixins';
.date-input {
@include mixins.form__input-box;
}

View File

@@ -0,0 +1,27 @@
import { cloneElement } from 'preact';
import type { ComponentChildren, JSX as JSXInternal } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type FormFieldProps = {
required?: boolean;
label?: string;
description?: string;
error?: string;
className?: string;
style?: JSXInternal.CSSProperties;
children: ComponentChildren;
};
export const FormField = ({ required, label, description, error, className, style = {}, children }: FormFieldProps) => (
<div className={createClassName(styles, 'form-field', { required, error: !!error }, [className])} style={style}>
<label className={createClassName(styles, 'form-field__label-wrapper')}>
{label ? <span className={createClassName(styles, 'form-field__label')}>{label}</span> : null}
<span className={createClassName(styles, 'form-field__input')}>
{error ? (Array.isArray(children) ? children : [children]).map((child) => cloneElement(child, { error: !!error })) : children}
</span>
</label>
<small className={createClassName(styles, 'form-field__description')}>{error || description}</small>
</div>
);

View File

@@ -0,0 +1,42 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { FormField } from '.';
import { Form, TextInput } from '..';
import { loremIpsum } from '../../../../.storybook/helpers';
export default {
title: 'Forms/FormField',
component: FormField,
args: {
required: false,
label: 'Label',
description: 'Description',
error: '',
},
decorators: [(storyFn) => <Form>{storyFn()}</Form>],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof FormField>>;
const Template: StoryFn<ComponentProps<typeof FormField>> = (args) => (
<FormField {...args}>
<TextInput value={loremIpsum({ count: 3, units: 'words' })} />
</FormField>
);
export const Normal = Template.bind({});
Normal.storyName = 'normal';
export const Required = Template.bind({});
Required.storyName = 'required';
Required.args = {
required: true,
};
export const WithError = Template.bind({});
WithError.storyName = 'with error';
WithError.args = {
error: 'Error',
};

View File

@@ -0,0 +1,85 @@
@use '../../../styles/colors';
@use '../../../styles/variables';
$form-field-label-color: colors.$color-text-dark;
$form-field-label-error-color: colors.$color-red;
$form-field-label-font-size: 0.75rem;
$form-field-label-font-weight: 600;
$form-field-label-line-height: 1rem;
$form-field-description-color: colors.$color-text-grey;
$form-field-description-font-size: 0.75rem;
$form-field-description-font-weight: 500;
$form-field-description-line-height: 1rem;
$form-field-error-color: colors.$color-red;
$form-field-error-border-color: colors.$color-red;
.form-field {
display: flex;
width: 100%;
margin: 5px 0;
flex-flow: column nowrap;
&__label-wrapper {
display: flex;
flex: 1 0 auto;
flex-flow: column nowrap;
}
&__label,
&__input,
&__description {
margin: 3px 0;
}
&__label {
flex: 0 0 auto;
transition: color variables.$default-time-animation;
text-align: left;
white-space: nowrap;
letter-spacing: 0;
text-overflow: ellipsis;
color: $form-field-label-color;
font-size: $form-field-label-font-size;
font-weight: $form-field-label-font-weight;
line-height: $form-field-label-line-height;
}
&__input {
display: flex;
flex: 1 0 auto;
}
&__description {
flex: 0 0 auto;
min-height: $form-field-description-line-height;
transition: color variables.$default-time-animation;
color: $form-field-description-color;
font-size: $form-field-description-font-size;
font-weight: $form-field-description-font-weight;
line-height: $form-field-description-line-height;
}
&--error {
.form-field__label,
.form-field__input,
.form-field__description {
color: $form-field-error-color;
}
}
&--required {
.form-field__label::after {
content: " *";
}
}
}

View File

@@ -0,0 +1,51 @@
import type { ComponentChildren, RefObject } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export const FormScrollShadow = ({
topRef,
bottomRef,
children,
}: {
children: ComponentChildren;
topRef: RefObject<HTMLDivElement>;
bottomRef: RefObject<HTMLDivElement>;
}) => {
const [atTop, setAtTop] = useState(true);
const [atBottom, setAtBottom] = useState(false);
const callback: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
entry.target.id === 'top' && setAtTop(entry.isIntersecting);
entry.target.id === 'bottom' && setAtBottom(entry.isIntersecting);
});
};
useEffect(() => {
if (!topRef?.current || !bottomRef?.current) {
return;
}
const observer = new IntersectionObserver(callback, {
root: document.getElementById('scrollShadow'),
rootMargin: '0px',
threshold: 0.1,
});
if (topRef.current) {
observer.observe(topRef.current);
}
if (bottomRef.current) {
observer.observe(bottomRef.current);
}
return () => {
observer.disconnect();
};
}, [bottomRef, topRef]);
return (
<div id='scrollShadow' className={createClassName(styles, 'scrollShadow', { atTop, atBottom })}>
{children}
</div>
);
};

View File

@@ -0,0 +1,27 @@
.scrollShadow {
overflow: hidden;
&::before,
&::after {
position: absolute;
z-index: 0;
width: 100%;
height: 1px;
content: "";
transition: all 0.3s;
background-color: rgba(47, 52, 61, 0.9);
box-shadow: 0 0 12px 1px rgba(47, 52, 61, 0.9);
}
&--atTop::before {
box-shadow: none;
}
&--atBottom::after {
box-shadow: none;
}
}

View File

@@ -0,0 +1,67 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps, JSX as JSXInternal } from 'preact';
import { Controller, useForm } from 'react-hook-form';
import { action } from 'storybook/actions';
import { Form, PasswordInput, SelectInput, TextInput, FormField } from '..';
import { Button } from '../../Button';
import { ButtonGroup } from '../../ButtonGroup';
export default {
title: 'Forms/HookFormExample',
component: Form,
args: {
onSubmit: (event) => {
action('submit')(event);
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof Form>>;
export const Default: StoryFn<ComponentProps<typeof Form>> = (args) => {
const {
handleSubmit,
formState: { errors },
control,
reset,
} = useForm();
return (
<Form onSubmit={handleSubmit(args.onSubmit as any) as unknown as JSXInternal.GenericEventHandler<HTMLFormElement>}>
<FormField label='Text' description='Input field for plain text' error={errors?.text?.message?.toString()}>
<Controller name='text' control={control} rules={{ required: true }} render={({ field }) => <TextInput {...field} />} />
</FormField>
<FormField label='Password' description='Input field for secret text' error={errors?.password?.message?.toString()}>
<Controller name='password' control={control} rules={{ minLength: 3 }} render={({ field }) => <PasswordInput {...field} />} />
</FormField>
<FormField label='Select' description='Input field for secret text' error={errors?.options?.message?.toString()}>
<Controller
name='options'
control={control}
rules={{ required: true }}
render={({ field }) => (
<SelectInput
options={[
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
]}
{...field}
/>
)}
/>
</FormField>
<ButtonGroup>
<Button submit stack>
Submit
</Button>
<Button onClick={() => reset()} nude secondary stack>
Cancel
</Button>
</ButtonGroup>
</Form>
);
};
Default.storyName = 'default';

View File

@@ -0,0 +1,51 @@
import type { Ref, JSX as JSXInternal } from 'preact';
import type { TargetedEvent } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type MultilineTextInputProps = {
rows?: number;
name?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
small?: boolean;
error?: boolean;
onChange?: JSXInternal.EventHandler<TargetedEvent<HTMLTextAreaElement, Event>>;
onInput?: JSXInternal.EventHandler<TargetedEvent<HTMLTextAreaElement, Event>>;
onBlur?: JSXInternal.EventHandler<TargetedEvent<HTMLTextAreaElement, Event>>;
ref?: Ref<HTMLTextAreaElement>;
className?: string;
style?: JSXInternal.CSSProperties;
};
export const MultilineTextInput = ({
name,
placeholder,
disabled,
small,
rows = 1,
error,
className,
style = {},
onChange,
onInput,
onBlur,
ref,
value,
}: MultilineTextInputProps) => (
<textarea
rows={rows}
name={name}
placeholder={placeholder}
disabled={disabled}
className={createClassName(styles, 'textarea-input', { disabled, error, small }, [className])}
style={style}
onChange={onChange}
onInput={onInput}
onBlur={onBlur}
ref={ref}
value={value}
/>
);

View File

@@ -0,0 +1,53 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { MultilineTextInput } from '.';
import { Form, FormField } from '..';
export default {
title: 'Forms/MultilineTextInput',
component: MultilineTextInput,
args: {
value: '',
placeholder: 'Placeholder',
disabled: false,
small: false,
rows: 1,
error: false,
onInput: action('input'),
},
decorators: [
(storyFn) => (
<Form>
<FormField>{storyFn()}</FormField>
</Form>
),
],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof MultilineTextInput>>;
const Template: StoryFn<ComponentProps<typeof MultilineTextInput>> = (args) => <MultilineTextInput {...args} />;
export const Empty = Template.bind({});
Empty.storyName = 'empty';
export const Filled = Template.bind({});
Filled.storyName = 'filled';
Filled.args = {
value: 'Value',
};
export const Disabled = Template.bind({});
Disabled.storyName = 'disabled';
Disabled.args = {
disabled: true,
};
export const WithError = Template.bind({});
WithError.storyName = 'with error';
WithError.args = {
error: true,
};

View File

@@ -0,0 +1,7 @@
@use '../mixins';
.textarea-input {
resize: none;
@include mixins.form__input-box;
}

View File

@@ -0,0 +1,47 @@
import type { Ref, JSX as JSXInternal } from 'preact';
import type { TargetedEvent } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type PasswordInputProps = {
name?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
small?: boolean;
error?: boolean;
onChange?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
onInput?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
onBlur?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
ref?: Ref<HTMLInputElement>;
className?: string;
style?: JSXInternal.CSSProperties;
};
export const PasswordInput = ({
name,
placeholder,
disabled,
small,
error,
className,
style = {},
onBlur,
onChange,
onInput,
ref,
}: PasswordInputProps) => (
<input
name={name}
type='password'
placeholder={placeholder}
disabled={disabled}
className={createClassName(styles, 'password-input', { disabled, error, small }, [className])}
style={style}
onBlur={onBlur}
onChange={onChange}
onInput={onInput}
ref={ref}
/>
);

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { PasswordInput } from '.';
import { Form, FormField } from '..';
export default {
title: 'Forms/PasswordInput',
component: PasswordInput,
args: {
value: '',
placeholder: 'Placeholder',
disabled: false,
small: false,
error: false,
onChange: action('change'),
onInput: action('input'),
},
decorators: [
(storyFn) => (
<Form>
<FormField>{storyFn()}</FormField>
</Form>
),
],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof PasswordInput>>;
const Template: StoryFn<ComponentProps<typeof PasswordInput>> = (args) => <PasswordInput {...args} />;
export const Default = Template.bind({});
Default.storyName = 'default';
export const Filled = Template.bind({});
Filled.storyName = 'filled';
Filled.args = {
value: 'Value',
};
export const Disabled = Template.bind({});
Disabled.storyName = 'disabled';
Disabled.args = {
disabled: true,
};
export const Small = Template.bind({});
Small.storyName = 'small';
Small.args = {
small: true,
};
export const WithError = Template.bind({});
WithError.storyName = 'with error';
WithError.args = {
error: true,
};

View File

@@ -0,0 +1,5 @@
@use '../mixins';
.password-input {
@include mixins.form__input-box;
}

View File

@@ -0,0 +1,72 @@
import type { ComponentChild, Ref, JSX as JSXInternal } from 'preact';
import type { TargetedEvent } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
import ArrowIcon from '../../../icons/arrowDown.svg';
export type SelectInputProps = {
name?: string;
placeholder?: ComponentChild;
options: { value: string; label: ComponentChild }[];
disabled?: boolean;
small?: boolean;
error?: boolean;
onChange?: JSXInternal.EventHandler<TargetedEvent<HTMLSelectElement, Event>>;
onInput?: JSXInternal.EventHandler<TargetedEvent<HTMLSelectElement, Event>>;
onBlur?: JSXInternal.EventHandler<TargetedEvent<HTMLSelectElement, Event>>;
ref?: Ref<HTMLSelectElement>;
className?: string;
style?: JSXInternal.CSSProperties;
value?: string;
};
export const SelectInput = ({
name,
placeholder,
options = [],
disabled,
small,
error,
onInput,
onBlur,
onChange = () => undefined,
className,
style = {},
value,
ref,
}: SelectInputProps) => {
const SelectOptions = Array.from(options).map(({ value, label }, key) => (
<option key={key} value={value} className={createClassName(styles, 'select-input__option')}>
{label}
</option>
));
return (
<div className={createClassName(styles, 'select-input', {}, [className])} style={style}>
<select
name={name}
value={value}
disabled={disabled}
onChange={onChange}
onBlur={onBlur}
onInput={onInput}
className={createClassName(styles, 'select-input__select', {
disabled,
error,
small,
placeholder: !value,
})}
ref={ref}
>
{placeholder && (
<option selected value='' disabled hidden>
{placeholder}
</option>
)}
{SelectOptions}
</select>
<ArrowIcon className={createClassName(styles, 'select-input__arrow')} />
</div>
);
};

View File

@@ -0,0 +1,64 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { SelectInput } from '.';
import { Form, FormField } from '..';
export default {
title: 'Forms/SelectInput',
component: SelectInput,
args: {
value: '',
options: [
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
],
placeholder: 'Placeholder',
disabled: false,
small: false,
error: false,
onChange: action('change'),
onInput: action('input'),
},
decorators: [
(storyFn) => (
<Form>
<FormField>{storyFn()}</FormField>
</Form>
),
],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof SelectInput>>;
const Template: StoryFn<ComponentProps<typeof SelectInput>> = (args) => <SelectInput {...args} />;
export const Empty = Template.bind({});
Empty.storyName = 'empty';
export const Selected = Template.bind({});
Selected.storyName = 'selected';
Selected.args = {
value: '2',
};
export const Disabled = Template.bind({});
Disabled.storyName = 'disabled';
Disabled.args = {
disabled: true,
};
export const Small = Template.bind({});
Small.storyName = 'small';
Small.args = {
small: true,
};
export const WithError = Template.bind({});
WithError.storyName = 'with error';
WithError.args = {
error: true,
};

View File

@@ -0,0 +1,57 @@
@use '../mixins';
@use "../../../styles/colors";
$form-input-select-arrow-size: mixins.$form-input-padding;
$form-input-select-arrow-padding: mixins.$form-input-padding;
$form-input-select-arrow-color: colors.$color-text-light;
.select-input {
position: relative;
display: flex;
flex: 1;
&__select {
flex: 1;
padding-right: (3 * $form-input-select-arrow-padding + $form-input-select-arrow-size);
color: mixins.$form-input-color;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
&::-ms-expand {
display: none;
}
&--placeholder {
color: mixins.$form-input-placeholder-color;
}
&--small {
padding-right: (3 * $form-input-select-arrow-padding + $form-input-select-arrow-size);
}
@include mixins.form__input-box;
}
&__option {
color: mixins.$form-input-color;
}
&__arrow {
position: absolute;
top: 50%;
right: $form-input-select-arrow-padding;
width: $form-input-select-arrow-size;
height: $form-input-select-arrow-size;
transform: translateY(-50%) translateY(2px);
pointer-events: none;
color: $form-input-select-arrow-color;
}
}

View File

@@ -0,0 +1,37 @@
import type { Ref, JSX as JSXInternal } from 'preact';
import type { TargetedEvent } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../../helpers/createClassName';
export type TextInputProps = {
name?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
small?: boolean;
error?: boolean;
onChange?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
onInput?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
onBlur?: JSXInternal.EventHandler<TargetedEvent<HTMLInputElement, Event>>;
className?: string;
style?: JSXInternal.CSSProperties;
ref?: Ref<HTMLInputElement>;
};
const TextInput = ({ name, value, placeholder, disabled, small, error, onChange, onInput, className, style = {}, ref }: TextInputProps) => (
<input
type='text'
name={name}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={onChange}
onInput={onInput}
className={createClassName(styles, 'text-input', { disabled, error, small }, [className])}
ref={ref}
style={style}
/>
);
export { TextInput };

View File

@@ -0,0 +1,58 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { TextInput } from '.';
import { Form, FormField } from '..';
export default {
title: 'Forms/TextInput',
component: TextInput,
args: {
value: '',
placeholder: 'Placeholder',
disabled: false,
small: false,
error: false,
onInput: action('input'),
},
decorators: [
(storyFn) => (
<Form>
<FormField>{storyFn()}</FormField>
</Form>
),
],
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof TextInput>>;
const Template: StoryFn<ComponentProps<typeof TextInput>> = (args) => <TextInput {...args} />;
export const Empty = Template.bind({});
Empty.storyName = 'empty';
export const Filled = Template.bind({});
Filled.storyName = 'filled';
Filled.args = {
value: 'Value',
};
export const Disabled = Template.bind({});
Disabled.storyName = 'disabled';
Disabled.args = {
disabled: true,
};
export const Small = Template.bind({});
Small.storyName = 'small';
Small.args = {
small: true,
};
export const WithError = Template.bind({});
WithError.storyName = 'with error';
WithError.args = {
error: true,
};

View File

@@ -0,0 +1,5 @@
@use '../mixins';
.text-input {
@include mixins.form__input-box;
}

View File

@@ -0,0 +1,29 @@
import i18next from 'i18next';
import type { ComponentChildren, JSX as JSXInternal } from 'preact';
export type FormProps = {
onSubmit?: JSXInternal.GenericEventHandler<HTMLFormElement>;
className?: string;
style?: JSXInternal.CSSProperties;
id?: string;
children?: ComponentChildren;
};
export const Form = ({ onSubmit, className, style = {}, id, children }: FormProps) => (
<form noValidate id={id} onSubmit={onSubmit} className={className} style={style}>
{children}
</form>
);
export const Validations = {
nonEmpty: ({ value }: { value: string }) => (!value.trim() ? i18next.t('field_required') : undefined),
custom: ({ value, pattern }: { value: string; pattern: string }) =>
new RegExp(pattern, 'i').test(String(value)) ? null : i18next.t('invalid_value'),
};
export { FormField } from './FormField';
export { TextInput } from './TextInput';
export { MultilineTextInput } from './MultilineTextInput';
export { PasswordInput } from './PasswordInput';
export { SelectInput } from './SelectInput';
export { CustomFields } from './CustomFields';

View File

@@ -0,0 +1,76 @@
@use 'sass:math';
@use '../../styles/variables';
@use '../../styles/colors';
$form-input-border-width: variables.$default-border;
$form-input-border-radius: variables.$default-border-radius;
$form-input-padding: (0.75 * variables.$default-gap - variables.$default-border);
$form-input-small-padding: (0.25 * variables.$default-gap - math.div(variables.$default-border, 2)) (0.75 * variables.$default-gap - variables.$default-border);
$form-input-color: colors.$color-text-dark;
$form-input-placeholder-color: colors.$color-text-light;
$form-input-background-color: colors.$bg-color-white;
$form-input-border-color: colors.$bg-color-grey;
$form-input-focus-border-color: colors.$color-text-dark;
$form-input-hover-border-color: colors.$color-text-light;
$form-input-disabled-background-color: colors.$bg-color-grey;
$form-input-disabled-color: colors.$color-text-light;
$form-input-error-color: colors.$color-red;
$form-input-error-border-color: colors.$color-red;
$form-input-font-family: variables.$font-family;
$form-input-font-size: 0.875rem;
$form-input-font-weight: 500;
$form-input-line-height: 1.25rem;
$form-input-disabled-opacity: variables.$disabled-opacity;
@mixin form__input-box {
& {
flex: 1 1 auto;
padding: $form-input-padding;
transition:
border-color variables.$default-time-animation,
color variables.$default-time-animation,
background-color variables.$default-time-animation,
trasform variables.$default-time-animation;
color: $form-input-color;
border: $form-input-border-width solid $form-input-border-color;
border-radius: $form-input-border-radius;
outline: none;
background-color: $form-input-background-color;
font-family: $form-input-font-family;
font-size: $form-input-font-size;
font-weight: $form-input-font-weight;
line-height: $form-input-line-height;
}
&:focus {
border-color: $form-input-focus-border-color;
}
&:hover {
border-color: $form-input-hover-border-color;
}
&--small {
padding: $form-input-small-padding;
}
&--disabled {
cursor: not-allowed;
opacity: $form-input-disabled-opacity;
color: $form-input-disabled-color;
border-color: $form-input-border-color;
background-color: $form-input-disabled-background-color;
}
&--error,
&--error:focus,
&--error:hover {
color: $form-input-error-color;
border-color: $form-input-error-border-color;
}
}

View File

@@ -0,0 +1,51 @@
import type { Meta, StoryFn } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { Form, PasswordInput, SelectInput, TextInput } from '.';
import { Button } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
import { FormField } from './FormField';
export default {
title: 'Forms/Form',
component: Form,
args: {
onSubmit: (event: Event) => {
event.preventDefault();
action('submit')(event);
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof Form>>;
export const Default: StoryFn<ComponentProps<typeof Form>> = (args) => (
<Form {...args}>
<FormField label='Text' description='Input field for plain text'>
<TextInput />
</FormField>
<FormField label='Password' description='Input field for secret text'>
<PasswordInput />
</FormField>
<FormField label='Select' description='Input field for secret text'>
<SelectInput
options={[
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
]}
/>
</FormField>
<ButtonGroup>
<Button submit stack>
Submit
</Button>
<Button nude secondary stack>
Cancel
</Button>
</ButtonGroup>
</Form>
);
Default.storyName = 'default';

View File

@@ -0,0 +1,253 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import {
Header,
HeaderPicture,
HeaderContent,
HeaderSubTitle,
HeaderTitle,
HeaderActions,
HeaderAction,
HeaderPost,
HeaderCustomField,
} from '.';
import { gazzoAvatar } from '../../../.storybook/helpers';
import Arrow from '../../icons/arrowDown.svg';
import Bell from '../../icons/bell.svg';
import NewWindow from '../../icons/newWindow.svg';
import Alert from '../Alert';
import { Avatar } from '../Avatar';
export default {
component: Header,
args: {
theme: {
color: '',
fontColor: '',
},
onClick: action('clicked'),
},
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<ComponentProps<typeof Header>>;
type Story = StoryObj<ComponentProps<typeof Header>>;
export const WithTextContent: Story = {
name: 'with text content',
render: (args) => (
<Header {...args}>
<HeaderContent>Need Help?</HeaderContent>
</Header>
),
};
export const WithLongTextContent: Story = {
name: 'with long text content',
render: (args) => (
<Header {...args}>
<HeaderContent>{'Need Help? '.repeat(100)}</HeaderContent>
</Header>
),
};
export const WithTitleAndSubtitle: Story = {
name: 'with title and subtitle',
render: (args) => (
<Header {...args}>
<HeaderContent>
<HeaderTitle>Rocket.Chat</HeaderTitle>
<HeaderSubTitle>Livechat</HeaderSubTitle>
</HeaderContent>
</Header>
),
};
export const WithPicture: Story = {
name: 'with picture',
render: (args) => (
<Header {...args}>
<HeaderPicture>
<Bell width={20} height={20} />
</HeaderPicture>
<HeaderContent>Notification settings</HeaderContent>
</Header>
),
};
export const WithActions: Story = {
name: 'with actions',
render: (args) => (
<Header {...args}>
<HeaderContent>Chat finished</HeaderContent>
<HeaderActions>
<HeaderAction onClick={action('notifications')}>
<Bell width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('minimize')}>
<Arrow width={20} height={20} />
</HeaderAction>
</HeaderActions>
</Header>
),
};
export const WithMultiplesAlerts: Story = {
name: 'with multiples alerts',
render: (args) => (
<Header
{...args}
post={
<HeaderPost>
<Alert success>Success</Alert>
<Alert warning>Warning</Alert>
<Alert error>Error</Alert>
<Alert error color='#175CC4'>
Custom color
</Alert>
</HeaderPost>
}
>
<HeaderContent>Chat finished</HeaderContent>
<HeaderActions>
<HeaderAction onClick={action('notifications')}>
<Bell width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('minimize')}>
<Arrow width={20} height={20} />
</HeaderAction>
</HeaderActions>
</Header>
),
};
export const ForUserChat: Story = {
name: 'for user chat',
render: (args) => (
<Header {...args}>
<HeaderPicture>
<Avatar src={gazzoAvatar} status='busy' />
</HeaderPicture>
<HeaderContent>
<HeaderTitle>@guilherme.gazzo</HeaderTitle>
<HeaderSubTitle>guilherme.gazzo@rocket.chat</HeaderSubTitle>
</HeaderContent>
<HeaderActions>
<HeaderAction onClick={action('notifications')}>
<Bell width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('minimize')}>
<Arrow width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('fullscreen')}>
<NewWindow width={20} height={20} />
</HeaderAction>
</HeaderActions>
</Header>
),
};
export const WithCustomField: Story = {
name: 'with custom field',
args: {
large: true,
},
render: (args) => (
<Header {...args} large>
<HeaderPicture>
<Avatar src={gazzoAvatar} large status='away' />
</HeaderPicture>
<HeaderContent>
<HeaderTitle>Guilherme Gazzo</HeaderTitle>
<HeaderSubTitle>guilherme.gazzo@rocket.chat</HeaderSubTitle>
<HeaderCustomField>+ 55 42423 24242</HeaderCustomField>
</HeaderContent>
<HeaderActions>
<HeaderAction onClick={action('notifications')}>
<Bell width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('minimize')}>
<Arrow width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('fullscreen')}>
<NewWindow width={20} height={20} />
</HeaderAction>
</HeaderActions>
</Header>
),
};
export const WithCustomFieldAndAlert: Story = {
name: 'with custom field and alert',
args: {
large: true,
},
render: (args) => (
<Header
{...args}
post={
<HeaderPost>
<Alert success>Success</Alert>
<Alert warning>Warning</Alert>
</HeaderPost>
}
>
<HeaderPicture>
<Avatar src={gazzoAvatar} large status='online' />
</HeaderPicture>
<HeaderContent>
<HeaderTitle>Guilherme Gazzo</HeaderTitle>
<HeaderSubTitle>guilherme.gazzo@rocket.chat</HeaderSubTitle>
<HeaderCustomField>+ 55 42423 24242</HeaderCustomField>
</HeaderContent>
<HeaderActions>
<HeaderAction onClick={action('notifications')}>
<Bell width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('minimize')}>
<Arrow width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('fullscreen')}>
<NewWindow width={20} height={20} />
</HeaderAction>
</HeaderActions>
</Header>
),
};
export const WithTheme: Story = {
name: 'with theme',
args: {
large: true,
theme: {
color: 'darkred',
fontColor: 'peachpuff',
},
},
render: (args) => (
<Header {...args}>
<HeaderPicture>
<Avatar src={gazzoAvatar} large status='away' />
</HeaderPicture>
<HeaderContent>
<HeaderTitle>Guilherme Gazzo</HeaderTitle>
<HeaderSubTitle>guilherme.gazzo@rocket.chat</HeaderSubTitle>
<HeaderCustomField>+ 55 42423 24242</HeaderCustomField>
</HeaderContent>
<HeaderActions>
<HeaderAction onClick={action('notifications')}>
<Bell width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('minimize')}>
<Arrow width={20} height={20} />
</HeaderAction>
<HeaderAction onClick={action('fullscreen')}>
<NewWindow width={20} height={20} />
</HeaderAction>
</HeaderActions>
</Header>
),
};

View File

@@ -0,0 +1,38 @@
import type { ComponentChildren, Ref, JSX as JSXInternal } from 'preact';
import type { MouseEventHandler } from 'preact/compat';
import styles from './styles.scss';
import type { Theme } from '../../Theme';
import { createClassName } from '../../helpers/createClassName';
export type HeaderProps = {
children?: ComponentChildren;
theme?: Partial<Theme>;
className?: string;
post?: ComponentChildren;
large?: boolean;
style?: JSXInternal.CSSProperties;
ref?: Ref<HTMLElement>;
onClick?: MouseEventHandler<HTMLElement>;
};
const Header = ({
children,
theme: { color: backgroundColor, fontColor: color } = {},
className,
post,
large,
style,
...props
}: HeaderProps) => (
<header
className={createClassName(styles, 'header', { large }, [className])}
style={style || backgroundColor || color ? { ...(style || {}), backgroundColor, color } : undefined}
{...props}
>
{children}
{post}
</header>
);
export default Header;

View File

@@ -0,0 +1,18 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderActionProps = {
children?: ComponentChildren;
className?: string;
onClick?: () => void;
};
const HeaderAction = ({ children, className = undefined, ...props }: HeaderActionProps) => (
<button className={createClassName(styles, 'header__action', {}, [className])} {...props}>
{children}
</button>
);
export default HeaderAction;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderActionsProps = {
children?: ComponentChildren;
className?: string;
};
const HeaderActions = ({ children, className = undefined, ...props }: HeaderActionsProps) => (
<nav className={createClassName(styles, 'header__actions', {}, [className])} {...props}>
{children}
</nav>
);
export default HeaderActions;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderContentProps = {
children?: ComponentChildren;
className?: string;
};
const HeaderContent = ({ children, className = undefined, ...props }: HeaderContentProps) => (
<div className={createClassName(styles, 'header__content', {}, [className])} {...props}>
{children}
</div>
);
export default HeaderContent;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderCustomFieldProps = {
children?: ComponentChildren;
className?: string;
};
const HeaderCustomField = ({ children, className = undefined, ...props }: HeaderCustomFieldProps) => (
<div className={createClassName(styles, 'header__custom-field', {}, [className])} {...props}>
{children}
</div>
);
export default HeaderCustomField;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderPictureProps = {
children?: ComponentChildren;
className?: string;
};
export const HeaderPicture = ({ children, className = undefined, ...props }: HeaderPictureProps) => (
<div className={createClassName(styles, 'header__picture', {}, [className])} {...props}>
{children}
</div>
);
export default HeaderPicture;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderPostProps = {
children?: ComponentChildren;
className?: string;
};
export const HeaderPost = ({ children, className = undefined, ...props }: HeaderPostProps) => (
<div className={createClassName(styles, 'header__post', {}, [className])} {...props}>
{children}
</div>
);
export default HeaderPost;

View File

@@ -0,0 +1,28 @@
import type { ComponentChildren } from 'preact';
import { toChildArray } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderSubTitleProps = {
children?: ComponentChildren;
className?: string;
};
const HeaderSubTitle = ({ children, className = undefined, ...props }: HeaderSubTitleProps) => (
<div
className={createClassName(
styles,
'header__subtitle',
{
children: toChildArray(children).length > 0,
},
[className],
)}
{...props}
>
{children}
</div>
);
export default HeaderSubTitle;

View File

@@ -0,0 +1,17 @@
import type { ComponentChildren } from 'preact';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type HeaderTitleProps = {
children?: ComponentChildren;
className?: string;
};
const HeaderTitle = ({ children, className = undefined, ...props }: HeaderTitleProps) => (
<div className={createClassName(styles, 'header__title', {}, [className])} data-qa='header-title' {...props}>
{children}
</div>
);
export default HeaderTitle;

View File

@@ -0,0 +1,9 @@
export { default as Header } from './Header';
export { default as HeaderPicture } from './HeaderPicture';
export { default as HeaderContent } from './HeaderContent';
export { default as HeaderTitle } from './HeaderTitle';
export { default as HeaderSubTitle } from './HeaderSubTitle';
export { default as HeaderActions } from './HeaderActions';
export { default as HeaderAction } from './HeaderAction';
export { default as HeaderPost } from './HeaderPost';
export { default as HeaderCustomField } from './HeaderCustomField';

View File

@@ -0,0 +1,125 @@
@use 'sass:math';
@use '../../styles/colors';
@use '../../styles/helpers';
@use '../../styles/variables';
$header-background-color: colors.$color-dark-blue;
$header-color: colors.$color-text-lighter;
$header-padding: variables.$default-padding;
$header-height: 56px;
$header-height-large: 77px;
$header-action-active-displacement: 2px;
.header {
z-index: 1;
display: flex;
flex: 0 0 auto;
width: 100%;
height: $header-height;
padding: 0 math.div($header-padding, 2);
color: var(--font-color, $header-color);
background-color: var(--color, $header-background-color);
box-shadow: 0 0 16px colors.$bg-color-grey;
font-size: 16px;
align-items: center;
justify-content: space-between;
&__item {
flex: 0 0 auto;
margin: 0 math.div($header-padding, 2);
}
&__picture {
@extend .header__item;
}
&__content {
@extend .header__item;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 1;
flex-shrink: 1;
.header__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 500;
line-height: 1.5;
}
.header__subtitle {
overflow: hidden;
letter-spacing: normal;
text-overflow: ellipsis;
opacity: 0.7;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
}
.header__custom-field {
overflow: hidden;
letter-spacing: normal;
text-overflow: ellipsis;
opacity: 0.7;
font-size: 12px;
font-weight: 500;
line-height: 1.67;
}
}
&__actions {
@extend .header__item;
display: flex;
.header__action {
display: flex;
flex: 0 0 auto;
cursor: pointer;
color: inherit;
border: none;
outline: none;
background: none;
justify-content: center;
@include helpers.pressable-button($header-action-active-displacement, 0);
}
}
&__post {
position: absolute;
z-index: 10;
top: $header-height;
right: 0;
left: 0;
width: 100%;
}
&--large {
height: $header-height-large;
.header__post {
top: $header-height-large;
}
}
}

View File

@@ -0,0 +1,16 @@
import { parse } from '@rocket.chat/message-parser';
import { Suspense, lazy } from 'preact/compat';
const Markup = lazy(() => import('@rocket.chat/gazzodown/dist/Markup'));
export type MarkdownBlockProps = { text: string; emoticons?: boolean };
const MarkdownBlock = ({ text, emoticons }: MarkdownBlockProps) => {
return (
<Suspense fallback={<div>loading...</div>}>
<Markup tokens={parse(text, { emoticons })} />
</Suspense>
);
};
export default MarkdownBlock;

View File

@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { Menu, MenuGroup, MenuItem } from '.';
export default {
component: MenuGroup,
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof MenuGroup>>;
type Story = StoryObj<ComponentProps<typeof MenuGroup>>;
export const Single: Story = {
name: 'single',
render: (args) => (
<Menu>
<MenuGroup {...args}>
<MenuItem>A menu item</MenuItem>
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};
export const Multiple: Story = {
name: 'multiple',
render: (args) => (
<Menu>
<MenuGroup {...args}>
<MenuItem>A menu item</MenuItem>
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem>Report</MenuItem>
</MenuGroup>
</Menu>
),
};
export const WithTitle: Story = {
name: 'with title',
args: {
title: 'Are you sure?',
},
render: (args) => (
<Menu>
<MenuGroup {...args}>
<MenuItem>A menu item</MenuItem>
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};

View File

@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { Menu, MenuGroup, MenuItem } from '.';
import BellIcon from '../../icons/bell.svg';
import ChangeIcon from '../../icons/change.svg';
import CloseIcon from '../../icons/close.svg';
import FinishIcon from '../../icons/finish.svg';
export default {
component: MenuItem,
args: {
children: 'A menu item',
onClick: action('clicked'),
},
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof MenuItem>>;
type Story = StoryObj<ComponentProps<typeof MenuItem>>;
export const Simple: Story = {
name: 'simple',
render: (args) => (
<Menu>
<MenuGroup>
<MenuItem {...args} />
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};
export const Primary: Story = {
name: 'primary',
args: {
primary: true,
},
render: (args) => (
<Menu>
<MenuGroup>
<MenuItem {...args} />
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};
export const Danger: Story = {
name: 'danger',
args: {
danger: true,
},
render: (args) => (
<Menu>
<MenuGroup>
<MenuItem {...args} />
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};
export const Disabled: Story = {
name: 'disabled',
args: {
disabled: true,
},
render: (args) => (
<Menu>
<MenuGroup>
<MenuItem {...args} />
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};
export const WithIcon: Story = {
name: 'with icon',
args: {
children: 'Simple',
icon: CloseIcon,
},
render: (args) => (
<Menu>
<MenuGroup>
<MenuItem {...args} />
<MenuItem primary icon={ChangeIcon} onClick={action('clicked')}>
Primary
</MenuItem>
<MenuItem danger icon={FinishIcon} onClick={action('clicked')}>
Danger
</MenuItem>
<MenuItem disabled icon={BellIcon} onClick={action('clicked')}>
Disabled
</MenuItem>
</MenuGroup>
</Menu>
),
};

View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/preact';
import type { ComponentProps } from 'preact';
import { action } from 'storybook/actions';
import { Menu, MenuGroup, MenuItem } from '.';
import { Button } from '../Button';
export default {
component: Menu,
args: {
hidden: false,
},
argTypes: {
placement: {
control: {
type: 'select',
options: ['left-top', 'right-top', 'right-bottom', 'left-bottom'],
},
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<ComponentProps<typeof Menu>>;
type Story = StoryObj<ComponentProps<typeof Menu>>;
export const Empty: Story = {
name: 'empty',
};
export const Simple: Story = {
name: 'simple',
args: {
children: (
<MenuGroup>
<MenuItem onClick={action('clicked')}>A menu item</MenuItem>
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
),
},
render: (args) => (
<Menu {...args}>
<MenuGroup>
<MenuItem onClick={action('clicked')}>A menu item</MenuItem>
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
),
};
export const Placement: Story = {
name: 'placement',
args: {
placement: 'right-bottom',
},
render: (args) => (
<div style={{ position: 'relative' }}>
<Button>Button</Button>
<Menu {...args}>
<MenuGroup>
<MenuItem onClick={action('clicked')}>A menu item</MenuItem>
<MenuItem>Another menu item</MenuItem>
</MenuGroup>
</Menu>
</div>
),
};

View File

@@ -0,0 +1,18 @@
import type { HTMLAttributes } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
export type MenuProps = {
hidden?: boolean;
placement?: string;
ref?: any; // FIXME: remove this
} & Omit<HTMLAttributes<HTMLDivElement>, 'ref'>;
const Menu = ({ children, hidden, placement = '', ...props }: MenuProps) => (
<div className={createClassName(styles, 'menu', { hidden, placement })} {...props}>
{children}
</div>
);
export default Menu;

View File

@@ -0,0 +1,17 @@
import type { HTMLAttributes } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
type GroupProps = {
title?: string;
} & HTMLAttributes<HTMLDivElement>;
const MenuGroup = ({ children, title = '', ...props }: GroupProps) => (
<div className={createClassName(styles, 'menu__group')} {...props}>
{title && <div className={createClassName(styles, 'menu__group-title')}>{title}</div>}
{children}
</div>
);
export default MenuGroup;

View File

@@ -0,0 +1,21 @@
import type { ComponentChildren } from 'preact';
import type { HTMLAttributes } from 'preact/compat';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
type ItemProps = {
primary?: boolean;
danger?: boolean;
disabled?: boolean;
icon?: () => ComponentChildren;
} & HTMLAttributes<HTMLButtonElement>;
const MenuItem = ({ children, primary = false, danger = false, disabled = false, icon = undefined, ...props }: ItemProps) => (
<button className={createClassName(styles, 'menu__item', { primary, danger, disabled })} disabled={disabled} {...props}>
{icon && <div className={createClassName(styles, 'menu__item__icon')}>{icon()}</div>}
{children}
</button>
);
export default MenuItem;

View File

@@ -0,0 +1,29 @@
import type { ComponentChildren } from 'preact';
import PopoverMenuWrapper from './PopoverMenuWrapper';
import styles from './styles.scss';
import { createClassName } from '../../helpers/createClassName';
import { PopoverTrigger } from '../Popover';
type PopoverMenuProps = {
children?: ComponentChildren;
trigger: (contextValue: { pop: () => void }) => ComponentChildren;
overlayed?: boolean;
};
const MenuPopover = ({ children = null, trigger, overlayed }: PopoverMenuProps) => (
<PopoverTrigger
overlayProps={{
className: overlayed ? createClassName(styles, 'popover-menu__overlay') : null,
}}
>
{trigger}
{({ dismiss, triggerBounds, overlayBounds }) => (
<PopoverMenuWrapper dismiss={dismiss} triggerBounds={triggerBounds} overlayBounds={overlayBounds}>
{children}
</PopoverMenuWrapper>
)}
</PopoverTrigger>
);
export default MenuPopover;

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