snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
10
packages/livechat/.gitignore
vendored
Normal file
10
packages/livechat/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/node_modules
|
||||
/build
|
||||
/storybook-static
|
||||
/*.log
|
||||
/.vscode
|
||||
/size-plugin.json
|
||||
coverage
|
||||
package-lock.json
|
||||
build.tar.gz
|
||||
.DS_Store
|
||||
BIN
packages/livechat/.storybook/assets/accessoryImage.png
Normal file
BIN
packages/livechat/.storybook/assets/accessoryImage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
packages/livechat/.storybook/assets/beep.mp3
Normal file
BIN
packages/livechat/.storybook/assets/beep.mp3
Normal file
Binary file not shown.
BIN
packages/livechat/.storybook/assets/gazzo.jpg
Normal file
BIN
packages/livechat/.storybook/assets/gazzo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/livechat/.storybook/assets/martin.jpg
Normal file
BIN
packages/livechat/.storybook/assets/martin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
packages/livechat/.storybook/assets/sample-audio.mp3
Normal file
BIN
packages/livechat/.storybook/assets/sample-audio.mp3
Normal file
Binary file not shown.
BIN
packages/livechat/.storybook/assets/tasso.jpg
Normal file
BIN
packages/livechat/.storybook/assets/tasso.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
85
packages/livechat/.storybook/helpers.tsx
Normal file
85
packages/livechat/.storybook/helpers.tsx
Normal 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';
|
||||
127
packages/livechat/.storybook/main.ts
Normal file
127
packages/livechat/.storybook/main.ts
Normal 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;
|
||||
24
packages/livechat/.storybook/mocks/uiKit.ts
Normal file
24
packages/livechat/.storybook/mocks/uiKit.ts
Normal 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);
|
||||
};
|
||||
12
packages/livechat/.storybook/preview.tsx
Normal file
12
packages/livechat/.storybook/preview.tsx
Normal 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;
|
||||
300
packages/livechat/.stylelintrc.json
Normal file
300
packages/livechat/.stylelintrc.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
2914
packages/livechat/CHANGELOG.md
Normal file
2914
packages/livechat/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
packages/livechat/LICENSE
Normal file
21
packages/livechat/LICENSE
Normal 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.
|
||||
89
packages/livechat/README.md
Normal file
89
packages/livechat/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Rocket.Chat.Livechat
|
||||
[](https://lgtm.com/projects/g/RocketChat/Rocket.Chat.Livechat/context:javascript)
|
||||
[](https://lgtm.com/projects/g/RocketChat/Rocket.Chat.Livechat/alerts/)
|
||||
[](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:
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
15
packages/livechat/babel.config.js
Normal file
15
packages/livechat/babel.config.js
Normal 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',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
127
packages/livechat/package.json
Normal file
127
packages/livechat/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
packages/livechat/postcss.config.js
Normal file
10
packages/livechat/postcss.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'autoprefixer': {},
|
||||
'postcss-selector-not': {},
|
||||
'postcss-flexbugs-fixes': {},
|
||||
'postcss-logical': {},
|
||||
'postcss-dir-pseudo-class': {},
|
||||
'cssnano': {},
|
||||
},
|
||||
};
|
||||
6
packages/livechat/src/Theme.ts
Normal file
6
packages/livechat/src/Theme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CSSProperties } from 'preact/compat';
|
||||
|
||||
export type Theme = {
|
||||
color: CSSProperties['backgroundColor'];
|
||||
fontColor: CSSProperties['color'];
|
||||
};
|
||||
21
packages/livechat/src/api.ts
Normal file
21
packages/livechat/src/api.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
BIN
packages/livechat/src/assets/favicon.ico
Normal file
BIN
packages/livechat/src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
68
packages/livechat/src/components/Alert/index.tsx
Normal file
68
packages/livechat/src/components/Alert/index.tsx
Normal 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;
|
||||
66
packages/livechat/src/components/Alert/stories.tsx
Normal file
66
packages/livechat/src/components/Alert/stories.tsx
Normal 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,
|
||||
};
|
||||
65
packages/livechat/src/components/Alert/styles.scss
Normal file
65
packages/livechat/src/components/Alert/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
189
packages/livechat/src/components/App/App.tsx
Normal file
189
packages/livechat/src/components/App/App.tsx
Normal 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;
|
||||
66
packages/livechat/src/components/App/index.tsx
Normal file
66
packages/livechat/src/components/App/index.tsx
Normal 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;
|
||||
72
packages/livechat/src/components/Avatar/Avatar.stories.tsx
Normal file
72
packages/livechat/src/components/Avatar/Avatar.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
51
packages/livechat/src/components/Avatar/index.tsx
Normal file
51
packages/livechat/src/components/Avatar/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
packages/livechat/src/components/Avatar/profile.png
Normal file
BIN
packages/livechat/src/components/Avatar/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
93
packages/livechat/src/components/Avatar/styles.scss
Normal file
93
packages/livechat/src/components/Avatar/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
98
packages/livechat/src/components/Button/index.tsx
Normal file
98
packages/livechat/src/components/Button/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
packages/livechat/src/components/Button/stories.tsx
Normal file
111
packages/livechat/src/components/Button/stories.tsx
Normal 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 />,
|
||||
};
|
||||
220
packages/livechat/src/components/Button/styles.scss
Normal file
220
packages/livechat/src/components/Button/styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
14
packages/livechat/src/components/ButtonGroup/index.tsx
Normal file
14
packages/livechat/src/components/ButtonGroup/index.tsx
Normal 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>
|
||||
));
|
||||
21
packages/livechat/src/components/ButtonGroup/styles.scss
Normal file
21
packages/livechat/src/components/ButtonGroup/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
));
|
||||
@@ -0,0 +1,6 @@
|
||||
.composer__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
font-size: 0;
|
||||
}
|
||||
300
packages/livechat/src/components/Composer/index.tsx
Normal file
300
packages/livechat/src/components/Composer/index.tsx
Normal 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} ${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';
|
||||
77
packages/livechat/src/components/Composer/stories.tsx
Normal file
77
packages/livechat/src/components/Composer/stories.tsx
Normal 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 & 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>
|
||||
),
|
||||
};
|
||||
81
packages/livechat/src/components/Composer/styles.scss
Normal file
81
packages/livechat/src/components/Composer/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
121
packages/livechat/src/components/FilesDropTarget/index.tsx
Normal file
121
packages/livechat/src/components/FilesDropTarget/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
packages/livechat/src/components/FilesDropTarget/styles.scss
Normal file
71
packages/livechat/src/components/FilesDropTarget/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
19
packages/livechat/src/components/Footer/CharCounter.tsx
Normal file
19
packages/livechat/src/components/Footer/CharCounter.tsx
Normal 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;
|
||||
72
packages/livechat/src/components/Footer/Footer.stories.tsx
Normal file
72
packages/livechat/src/components/Footer/Footer.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
17
packages/livechat/src/components/Footer/Footer.tsx
Normal file
17
packages/livechat/src/components/Footer/Footer.tsx
Normal 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;
|
||||
17
packages/livechat/src/components/Footer/FooterContent.tsx
Normal file
17
packages/livechat/src/components/Footer/FooterContent.tsx
Normal 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;
|
||||
16
packages/livechat/src/components/Footer/FooterOptions.tsx
Normal file
16
packages/livechat/src/components/Footer/FooterOptions.tsx
Normal 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;
|
||||
23
packages/livechat/src/components/Footer/OptionsTrigger.tsx
Normal file
23
packages/livechat/src/components/Footer/OptionsTrigger.tsx
Normal 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;
|
||||
25
packages/livechat/src/components/Footer/PoweredBy.tsx
Normal file
25
packages/livechat/src/components/Footer/PoweredBy.tsx
Normal 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;
|
||||
6
packages/livechat/src/components/Footer/index.ts
Normal file
6
packages/livechat/src/components/Footer/index.ts
Normal 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';
|
||||
96
packages/livechat/src/components/Footer/styles.scss
Normal file
96
packages/livechat/src/components/Footer/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/livechat/src/components/Form/CustomFields/index.tsx
Normal file
71
packages/livechat/src/components/Form/CustomFields/index.tsx
Normal 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}</>;
|
||||
};
|
||||
51
packages/livechat/src/components/Form/DateInput/index.tsx
Normal file
51
packages/livechat/src/components/Form/DateInput/index.tsx
Normal 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);
|
||||
59
packages/livechat/src/components/Form/DateInput/stories.tsx
Normal file
59
packages/livechat/src/components/Form/DateInput/stories.tsx
Normal 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,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@use '../mixins';
|
||||
|
||||
.date-input {
|
||||
@include mixins.form__input-box;
|
||||
}
|
||||
27
packages/livechat/src/components/Form/FormField/index.tsx
Normal file
27
packages/livechat/src/components/Form/FormField/index.tsx
Normal 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>
|
||||
);
|
||||
42
packages/livechat/src/components/Form/FormField/stories.tsx
Normal file
42
packages/livechat/src/components/Form/FormField/stories.tsx
Normal 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',
|
||||
};
|
||||
85
packages/livechat/src/components/Form/FormField/styles.scss
Normal file
85
packages/livechat/src/components/Form/FormField/styles.scss
Normal 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: " *";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../mixins';
|
||||
|
||||
.textarea-input {
|
||||
resize: none;
|
||||
|
||||
@include mixins.form__input-box;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@use '../mixins';
|
||||
|
||||
.password-input {
|
||||
@include mixins.form__input-box;
|
||||
}
|
||||
72
packages/livechat/src/components/Form/SelectInput/index.tsx
Normal file
72
packages/livechat/src/components/Form/SelectInput/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
37
packages/livechat/src/components/Form/TextInput/index.tsx
Normal file
37
packages/livechat/src/components/Form/TextInput/index.tsx
Normal 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 };
|
||||
58
packages/livechat/src/components/Form/TextInput/stories.tsx
Normal file
58
packages/livechat/src/components/Form/TextInput/stories.tsx
Normal 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,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@use '../mixins';
|
||||
|
||||
.text-input {
|
||||
@include mixins.form__input-box;
|
||||
}
|
||||
29
packages/livechat/src/components/Form/index.tsx
Normal file
29
packages/livechat/src/components/Form/index.tsx
Normal 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';
|
||||
76
packages/livechat/src/components/Form/mixins.scss
Normal file
76
packages/livechat/src/components/Form/mixins.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
51
packages/livechat/src/components/Form/stories.tsx
Normal file
51
packages/livechat/src/components/Form/stories.tsx
Normal 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';
|
||||
253
packages/livechat/src/components/Header/Header.stories.tsx
Normal file
253
packages/livechat/src/components/Header/Header.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
38
packages/livechat/src/components/Header/Header.tsx
Normal file
38
packages/livechat/src/components/Header/Header.tsx
Normal 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;
|
||||
18
packages/livechat/src/components/Header/HeaderAction.tsx
Normal file
18
packages/livechat/src/components/Header/HeaderAction.tsx
Normal 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;
|
||||
17
packages/livechat/src/components/Header/HeaderActions.tsx
Normal file
17
packages/livechat/src/components/Header/HeaderActions.tsx
Normal 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;
|
||||
17
packages/livechat/src/components/Header/HeaderContent.tsx
Normal file
17
packages/livechat/src/components/Header/HeaderContent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
17
packages/livechat/src/components/Header/HeaderPicture.tsx
Normal file
17
packages/livechat/src/components/Header/HeaderPicture.tsx
Normal 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;
|
||||
17
packages/livechat/src/components/Header/HeaderPost.tsx
Normal file
17
packages/livechat/src/components/Header/HeaderPost.tsx
Normal 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;
|
||||
28
packages/livechat/src/components/Header/HeaderSubTitle.tsx
Normal file
28
packages/livechat/src/components/Header/HeaderSubTitle.tsx
Normal 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;
|
||||
17
packages/livechat/src/components/Header/HeaderTitle.tsx
Normal file
17
packages/livechat/src/components/Header/HeaderTitle.tsx
Normal 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;
|
||||
9
packages/livechat/src/components/Header/index.ts
Normal file
9
packages/livechat/src/components/Header/index.ts
Normal 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';
|
||||
125
packages/livechat/src/components/Header/styles.scss
Normal file
125
packages/livechat/src/components/Header/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/livechat/src/components/MarkdownBlock/index.tsx
Normal file
16
packages/livechat/src/components/MarkdownBlock/index.tsx
Normal 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;
|
||||
55
packages/livechat/src/components/Menu/Group.stories.tsx
Normal file
55
packages/livechat/src/components/Menu/Group.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
103
packages/livechat/src/components/Menu/Item.stories.tsx
Normal file
103
packages/livechat/src/components/Menu/Item.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
68
packages/livechat/src/components/Menu/Menu.stories.tsx
Normal file
68
packages/livechat/src/components/Menu/Menu.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
18
packages/livechat/src/components/Menu/Menu.tsx
Normal file
18
packages/livechat/src/components/Menu/Menu.tsx
Normal 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;
|
||||
17
packages/livechat/src/components/Menu/MenuGroup.tsx
Normal file
17
packages/livechat/src/components/Menu/MenuGroup.tsx
Normal 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;
|
||||
21
packages/livechat/src/components/Menu/MenuItem.tsx
Normal file
21
packages/livechat/src/components/Menu/MenuItem.tsx
Normal 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;
|
||||
29
packages/livechat/src/components/Menu/MenuPopover.tsx
Normal file
29
packages/livechat/src/components/Menu/MenuPopover.tsx
Normal 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
Reference in New Issue
Block a user