Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

24
webapp/platform/components/README.md Обычный файл
Просмотреть файл

@@ -0,0 +1,24 @@
# Mattermost Components
The goal of this package is to be a place where components common to all products can be shared.
Currently a work in progress. Next steps involve implementing webpack module federation in the webapp and locking down how the development experience will work for the webapp multi product architecture.
## Usage
Coming soon with multi product architecture.
## Compilation
Building is done using rollup. This must be done so the webapp webpack will pick up the changes. (multi product development experience coming soon)
```bash
npm run build
```
or from the root of the webapp with
```bash
npm run build --workspace=packages/mattermost
```

42
webapp/platform/components/babel.config.js Обычный файл
Просмотреть файл

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const config = {
presets: [
['@babel/preset-env', {
targets: {
chrome: 66,
firefox: 60,
edge: 42,
safari: 12,
},
modules: false,
}],
['@babel/preset-react', {
useBuiltIns: true,
}],
['@babel/typescript', {
allExtensions: true,
isTSX: true,
}],
],
plugins: [
'@babel/plugin-transform-runtime',
[
'babel-plugin-styled-components',
{
ssr: false,
fileName: false,
},
],
[
'formatjs',
{
idInterpolationPattern: '[sha512:contenthash:base64:6]',
ast: true,
},
],
],
};
module.exports = config;

61
webapp/platform/components/package.json Обычный файл
Просмотреть файл

@@ -0,0 +1,61 @@
{
"name": "@mattermost/components",
"version": "7.4.0",
"module": "dist/index.esm.js",
"types": "dist/index.esm.d.ts",
"styles": "dist/index.esm.css",
"scripts": {
"build": "rollup -c",
"run": "rollup -c --watch",
"clean": "rm -rf node_modules dist"
},
"devDependencies": {
"@babel/cli": "^7.17.6",
"@babel/core": "^7.17.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-typescript": "^8.3.1",
"@types/lodash": "^4.14.178",
"@types/react": "^17.0.2",
"@types/react-bootstrap": "^0.32.22",
"@types/react-dom": "^17.0.2",
"@types/react-redux": "^7.1.21",
"@types/shallow-equals": "^1.0.0",
"@types/styled-components": "^5.1.19",
"babel-loader": "^8.2.3",
"babel-plugin-formatjs": "10.3.14",
"babel-plugin-styled-components": "^2.0.6",
"css-loader": "^6.7.1",
"rollup": "^2.75.7",
"rollup-plugin-auto-external": "^2.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-scss": "^3.0.0",
"rollup-plugin-ts": "^2.0.5",
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"typescript": "^4.3.4",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2"
},
"peerDependencies": {
"@babel/runtime-corejs3": "^7.17.8",
"@mui/base": "5.0.0-alpha.116",
"@mui/material": "5.11.7",
"@tippyjs/react": "^4.2.6",
"classnames": "^2.3.1",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-bootstrap": "github:mattermost/react-bootstrap#d821e2b1db1059bd36112d7587fd1b0912b27626",
"react-dom": "^17.0.2",
"react-intl": "^5.20.0",
"shallow-equals": "^1.0.0",
"styled-components": "^5.3.5",
"tippy.js": "^6.3.7"
}
}

44
webapp/platform/components/rollup.config.js Обычный файл
Просмотреть файл

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// eslint-disable-next-line import/no-unresolved
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import scss from 'rollup-plugin-scss';
import typescript from '@rollup/plugin-typescript';
import packagejson from './package.json';
const externals = [
...Object.keys(packagejson.dependencies || {}),
...Object.keys(packagejson.peerDependencies || {}),
'mattermost-redux',
'reselect',
];
export default [
{
input: 'src/index.tsx',
output: [
{
sourcemap: true,
file: packagejson.module,
format: 'es',
globals: {'styled-components': 'styled'},
},
],
plugins: [
scss(),
resolve({
browser: true,
extensions: ['.ts', '.tsx'],
}),
commonjs(),
typescript(),
],
external: (pkg) => externals.some((external) => pkg.startsWith(external)),
watch: {
clearScreen: false,
},
},
];

Просмотреть файл

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MutableRefObject, useEffect} from 'react';
export function useClickOutsideRef(ref: MutableRefObject<HTMLElement | null>, handler: (event: MouseEvent) => void): void {
useEffect(() => {
function onMouseDown(event: MouseEvent) {
const target = event.target as any;
if (ref.current && target instanceof Node && !ref.current.contains(target)) {
handler(event);
}
}
// Bind the event listener
document.addEventListener('mousedown', onMouseDown);
return () => {
// Unbind the event listener on clean up
document.removeEventListener('mousedown', onMouseDown);
};
}, [ref, handler]);
}

Просмотреть файл

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useRef, useState, useMemo} from 'react';
export function useElementAvailable(
elementIds: string[],
intervalMS = 250,
): boolean {
const checkAvailableInterval = useRef<NodeJS.Timeout | null>(null);
const [available, setAvailable] = useState(false);
useEffect(() => {
if (available) {
if (checkAvailableInterval.current) {
clearInterval(checkAvailableInterval.current);
checkAvailableInterval.current = null;
}
return;
} else if (checkAvailableInterval.current) {
return;
}
checkAvailableInterval.current = setInterval(() => {
if (elementIds.every((x) => document.getElementById(x))) {
setAvailable(true);
if (checkAvailableInterval.current) {
clearInterval(checkAvailableInterval.current);
checkAvailableInterval.current = null;
}
}
}, intervalMS);
}, []);
return useMemo(() => available, [available]);
}

Просмотреть файл

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useLayoutEffect, useMemo, useState} from 'react';
import throttle from 'lodash/throttle';
import {useElementAvailable} from './useElementAvailable';
export type Coords = {
x?: string;
y?: string;
}
export type PunchOutCoordsHeightAndWidth = Coords & {
width: string;
height: string;
}
type PunchOutOffset = {
x: number;
y: number;
width: number;
height: number;
}
export const useMeasurePunchouts = (elementIds: string[], additionalDeps: any[], offset?: PunchOutOffset): PunchOutCoordsHeightAndWidth | null => {
const elementsAvailable = useElementAvailable(elementIds);
const [size, setSize] = useState({x: window.innerWidth, y: window.innerHeight});
const updateSize = throttle(() => {
setSize({x: window.innerWidth, y: window.innerHeight});
}, 100, {trailing: true});
useLayoutEffect(() => {
window.addEventListener('resize', updateSize);
return () =>
window.removeEventListener('resize', updateSize);
}, []);
const channelPunchout = useMemo(() => {
let minX = Number.MAX_SAFE_INTEGER;
let minY = Number.MAX_SAFE_INTEGER;
let maxX = Number.MIN_SAFE_INTEGER;
let maxY = Number.MIN_SAFE_INTEGER;
for (let i = 0; i < elementIds.length; i++) {
const rectangle = document.getElementById(elementIds[i])?.getBoundingClientRect();
if (!rectangle) {
return null;
}
if (rectangle.x < minX) {
minX = rectangle.x;
}
if (rectangle.y < minY) {
minY = rectangle.y;
}
if (rectangle.x + rectangle.width > maxX) {
maxX = rectangle.x + rectangle.width;
}
if (rectangle.y + rectangle.height > maxY) {
maxY = rectangle.y + rectangle.height;
}
}
return {
x: `${minX + (offset ? offset.x : 0)}px`,
y: `${minY + (offset ? offset.y : 0)}px`,
width: `${(maxX - minX) + (offset ? offset.width : 0)}px`,
height: `${(maxY - minY) + (offset ? offset.height : 0)}px`,
};
}, [...elementIds, ...additionalDeps, size, elementsAvailable]);
return channelPunchout;
};

Просмотреть файл

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import MuiFocusTrap, {FocusTrapProps as MuiFocusTrapProps} from '@mui/base/FocusTrap';
export interface Props {
active: MuiFocusTrapProps['open'];
children: MuiFocusTrapProps['children'];
}
export const FocusTrap = ({active, children}: Props) => {
return (
<MuiFocusTrap open={active}>
{children}
</MuiFocusTrap>
);
};

Просмотреть файл

@@ -0,0 +1,226 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import classNames from 'classnames';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import {FocusTrap} from '../focus_trap';
export type Props = {
className?: string;
onExited: () => void;
modalHeaderText?: React.ReactNode;
show?: boolean;
handleCancel?: () => void;
handleConfirm?: () => void;
handleEnterKeyPress?: () => void;
handleKeydown?: (event?: React.KeyboardEvent<HTMLDivElement>) => void;
confirmButtonText?: React.ReactNode;
confirmButtonClassName?: string;
cancelButtonText?: React.ReactNode;
cancelButtonClassName?: string;
isConfirmDisabled?: boolean;
isDeleteModal?: boolean;
id: string;
autoCloseOnCancelButton?: boolean;
autoCloseOnConfirmButton?: boolean;
/**
* If false, bootrap's Modal will not enforce focus on the modal and will
* transfer the mechanism to the FocusTrap component instead.
*/
enforceFocus?: boolean;
container?: React.ReactNode | React.ReactNodeArray;
ariaLabel?: string;
errorText?: string;
compassDesign?: boolean;
backdrop?: boolean;
backdropClassName?: string;
headerButton?: React.ReactNode;
tabIndex?: number;
children: React.ReactNode;
keyboardEscape?: boolean;
};
type State = {
show: boolean;
isFocalTrapActive: boolean;
}
export class GenericModal extends React.PureComponent<Props, State> {
static defaultProps: Partial<Props> = {
show: true,
id: 'genericModal',
autoCloseOnCancelButton: true,
autoCloseOnConfirmButton: true,
enforceFocus: true,
keyboardEscape: true,
};
constructor(props: Props) {
super(props);
this.state = {
show: props.show!,
isFocalTrapActive: false,
};
}
onHide = () => {
this.setState({show: false});
}
handleCancel = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (this.props.autoCloseOnCancelButton) {
this.onHide();
}
if (this.props.handleCancel) {
this.props.handleCancel();
}
}
handleConfirm = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (this.props.autoCloseOnConfirmButton) {
this.onHide();
}
if (this.props.handleConfirm) {
this.props.handleConfirm();
}
}
private onEnterKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter') {
if (this.props.autoCloseOnConfirmButton) {
this.onHide();
}
if (this.props.handleEnterKeyPress) {
this.props.handleEnterKeyPress();
}
}
this.props.handleKeydown?.(event);
}
private handleShow = () => {
if (this.props.enforceFocus === false) {
this.setState({isFocalTrapActive: true});
}
}
render() {
let confirmButton;
if (this.props.handleConfirm) {
const isConfirmOrDeleteClassName = this.props.isDeleteModal ? 'delete' : 'confirm';
let confirmButtonText: React.ReactNode = (
<FormattedMessage
id='generic_modal.confirm'
defaultMessage='Confirm'
/>
);
if (this.props.confirmButtonText) {
confirmButtonText = this.props.confirmButtonText;
}
confirmButton = (
<button
type='submit'
className={classNames('GenericModal__button', isConfirmOrDeleteClassName, this.props.confirmButtonClassName, {
disabled: this.props.isConfirmDisabled,
})}
onClick={this.handleConfirm}
disabled={this.props.isConfirmDisabled}
>
{confirmButtonText}
</button>
);
}
let cancelButton;
if (this.props.handleCancel) {
let cancelButtonText: React.ReactNode = (
<FormattedMessage
id='generic_modal.cancel'
defaultMessage='Cancel'
/>
);
if (this.props.cancelButtonText) {
cancelButtonText = this.props.cancelButtonText;
}
cancelButton = (
<button
type='button'
className={classNames('GenericModal__button cancel', this.props.cancelButtonClassName)}
onClick={this.handleCancel}
>
{cancelButtonText}
</button>
);
}
const headerText = this.props.modalHeaderText && (
<div className='GenericModal__header'>
<h1 id='genericModalLabel'>
{this.props.modalHeaderText}
</h1>
{this.props.headerButton}
</div>
);
const isFocusTrapActive = this.props.enforceFocus === false ? this.state.isFocalTrapActive : false;
return (
<Modal
id={this.props.id}
role='dialog'
aria-label={this.props.ariaLabel}
aria-labelledby={this.props.ariaLabel ? undefined : 'genericModalLabel'}
dialogClassName={classNames('a11y__modal GenericModal', {GenericModal__compassDesign: this.props.compassDesign}, this.props.className)}
show={this.state.show}
onShow={this.handleShow}
restoreFocus={true}
enforceFocus={this.props.enforceFocus}
onHide={this.onHide}
onExited={this.props.onExited}
backdrop={this.props.backdrop}
backdropClassName={this.props.backdropClassName}
container={this.props.container}
keyboard={this.props.keyboardEscape}
>
<FocusTrap active={isFocusTrapActive}>
<div
onKeyDown={this.onEnterKeyDown}
tabIndex={this.props.tabIndex || 0}
className='GenericModal__wrapper-enter-key-press-catcher'
>
<Modal.Header closeButton={true}>
{this.props.compassDesign && headerText}
</Modal.Header>
<Modal.Body>
{this.props.compassDesign ? (
this.props.errorText && (
<div className='genericModalError'>
<i className='icon icon-alert-outline'/>
<span>{this.props.errorText}</span>
</div>
)
) : (
headerText
)}
<div className='GenericModal__body'>
{this.props.children}
</div>
</Modal.Body>
{(cancelButton || confirmButton) && <Modal.Footer>
{cancelButton}
{confirmButton}
</Modal.Footer>}
</div>
</FocusTrap>
</Modal>
);
}
}

18
webapp/platform/components/src/index.tsx Обычный файл
Просмотреть файл

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// type
export type {Props as GenericModalProps} from './generic_modal/generic_modal';
export type {CircleSkeletonLoaderProps, RectangleSkeletonLoaderProps} from './skeleton_loader';
export type {Props as FocusTrapProps} from './focus_trap';
// components
export {GenericModal} from './generic_modal/generic_modal';
export {CircleSkeletonLoader, RectangleSkeletonLoader} from './skeleton_loader';
export * from './tour_tip';
export * from './pulsating_dot';
export {FocusTrap} from './focus_trap';
// hooks
export * from './common/hooks/useMeasurePunchouts';
export {useElementAvailable} from './common/hooks/useElementAvailable';

Просмотреть файл

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Coords} from '../common/hooks/useMeasurePunchouts';
import './pulsating_dot.scss';
type Props = {
targetRef?: React.RefObject<HTMLImageElement>;
className?: string;
onClick?: (e: React.MouseEvent) => void;
coords?: Coords;
}
export class PulsatingDot extends React.PureComponent<Props> {
public render() {
let customStyles = {};
if (this.props?.coords) {
customStyles = {
transform: `translate(${this.props.coords?.x}px, ${this.props.coords?.y}px)`,
};
}
let effectiveClassName = 'pulsating_dot';
if (this.props.onClick) {
effectiveClassName += ' pulsating_dot-clickable';
}
if (this.props.className) {
effectiveClassName = effectiveClassName + ' ' + this.props.className;
}
return (
<span
className={effectiveClassName}
onClick={this.props.onClick}
ref={this.props.targetRef}
style={{...customStyles}}
data-testid={'pulsating_dot'}
/>
);
}
}

Просмотреть файл

@@ -0,0 +1,37 @@
.pulsating_dot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
&-clickable {
cursor: pointer;
}
&,
&::before,
&::after {
width: 12px;
height: 12px;
background-color: var(--online-indicator);
border-radius: 50%;
}
&::before,
&::after {
position: absolute;
top: 0;
left: 0;
display: block;
content: "";
}
&::after {
animation: pulse1 2s ease 0s infinite;
}
&::before {
animation: pulse2 2s ease 0s infinite;
}
}

Просмотреть файл

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import styled, {keyframes} from 'styled-components';
const skeletonFade = keyframes`
0% {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
50% {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
}
100% {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
`;
const BaseLoader = styled.div`
animation-duration: 1500ms;
animation-iteration-count: infinite;
animation-name: ${skeletonFade};
animation-timing-function: ease-in-out;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
`;
export interface CircleSkeletonLoaderProps {
size: string | number;
}
/**
* CircleSkeletonLoader is a component that renders a filled circle with a loading animation.
* It is used to indicate that the content is loading.
* @param props.size - The size of the circle. When in number, it is treated as pixels.
* @example
* <CircleSkeletonLoader size={20}/>
* <CircleSkeletonLoader size="50%"/>
*/
export const CircleSkeletonLoader = styled(BaseLoader)<CircleSkeletonLoaderProps>`
display: block;
border-radius: 50%;
height: ${(props) => getCorrectSizeDimension(props.size)};
width: ${(props) => getCorrectSizeDimension(props.size)};
`;
export interface RectangleSkeletonLoaderProps {
height: string | number;
width?: string | number;
borderRadius?: number;
margin?: string;
flex?: string;
}
/**
* RectangleSkeletonLoader is a component that renders a filled rectangle with a loading animation.
* It is used to indicate that the content is loading.
* @param props.height - The height of the rectangle eg. 20, "20em", "20%". When in number, it is treated as pixels.
* @param props.width - The width of the rectangle eg. 30, '100%'. When in number, it is treated as pixels.
* @param props.borderRadius - The border radius of the rectangle eg. 4
* @param props.margin - The margin of the rectangle eg. '0 10px', '10px 0 0 10px'
* @param props.flex - The flex short hand of flex grow, shrink, basis of the rectangle, under flex parent css eg. '1 1 auto'
* @default
* width: 100% , borderRadius: 8px
* @example
* <RectangleSkeletonLoader height='100px' />
* <RectangleSkeletonLoader height={40} width={100} borderRadius={4} margin='0 10px 0 0' flex='1' />
*/
export const RectangleSkeletonLoader = styled(BaseLoader)<RectangleSkeletonLoaderProps>`
height: ${(props) => getCorrectSizeDimension(props.height)};
width: ${(props) => getCorrectSizeDimension(props.width, '100%')};
border-radius: ${(props) => props?.borderRadius ?? 8}px;
margin: ${(props) => props?.margin ?? null};
flex: ${(props) => props?.flex ?? null};
`;
function getCorrectSizeDimension(size: number | string | undefined, fallback: string | null = null) {
if (size) {
return (typeof size === 'string') ? size : `${size}px`;
}
return fallback;
}

Просмотреть файл

@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export * from './tour_tip';
export * from './tour_tip_backdrop';

Просмотреть файл

@@ -0,0 +1,456 @@
.tour-tip {
display: flex;
&__box {
&.tippy-box {
padding: 18px 24px 24px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: var(--center-channel-bg);
border-radius: 4px;
color: var(--center-channel-color-rgb);
filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.12));
.tippy-content {
padding: 0;
}
.tippy-arrow {
width: 12px;
height: 12px;
border-color: rgba(var(--center-channel-color-rgb), 0.16);
color: var(--center-channel-bg);
}
.tippy-arrow::before {
width: 12px;
height: 12px;
border-color: rgba(var(--center-channel-color-rgb), 0.16);
background: var(--center-channel-bg);
color: var(--center-channel-bg);
transform-origin: center;
}
// fix for https://mattermost.atlassian.net/browse/MM-41711. This covers the current placements we use for the channels and other tools tour
&[data-placement^=right] > .tippy-arrow {
transform: translate3d(0, 14px, 0) !important;
}
&[data-placement^=top] > .tippy-arrow {
transform: translate3d(14px, 0, 0) !important;
}
&[data-placement^=bottom] > .tippy-arrow {
transform: translate3d(14px, 0, 0) !important;
}
&[data-placement=bottom-end] > .tippy-arrow {
transform: translate3d(317px, 0, 0) !important;
}
}
}
&__pulsating-dot-ctr {
position: absolute;
z-index: 3;
width: 14px;
height: 14px;
cursor: pointer;
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=top] {
top: 0;
left: calc(50% - 6px);
transform: translate(0, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=top-start] {
top: 0;
left: 0;
transform: translate(6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=top-end] {
top: 0;
right: 0;
transform: translate(-6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=bottom] {
bottom: 0;
left: calc(50% - 6px);
transform: translate(0, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=bottom-start] {
bottom: 0;
left: 0;
transform: translate(6px, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=bottom-end] {
right: 0;
bottom: 0;
transform: translate(-6px, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=left] {
top: calc(50% - 6px);
left: 0;
transform: translate(6px, 0);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=left-start] {
top: 0;
left: 0;
transform: translate(6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=left-end] {
bottom: 0;
left: 0;
transform: translate(6px, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=right] {
top: calc(50% - 6px);
right: 0;
transform: translate(-6px, 0);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=right-start] {
top: 0;
right: 0;
transform: translate(-6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=right-end] {
right: 0;
bottom: 0;
transform: translate(-6px, -6px);
}
&__overlay {
position: fixed;
z-index: 999;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&__header {
display: flex;
align-items: center;
justify-content: flex-start;
&__title {
flex: none;
flex-grow: 1;
order: 0;
margin: 0;
font-family: inherit;
font-size: 1.4rem;
font-style: normal;
font-weight: 600;
line-height: 2rem;
}
&__close {
display: flex;
overflow: hidden;
width: 3.2rem;
height: 3.2rem;
align-items: center;
justify-content: center;
border: unset;
margin-right: -8px;
margin-left: 1.2rem;
background: transparent;
border-radius: 4px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-weight: 600;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
&.active {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
i {
font-size: 1.8rem;
line-height: 1.8rem;
::before {
margin-right: 0;
}
}
}
}
&__body {
display: flex;
flex-direction: column;
margin-top: 0.6rem;
p,
div {
margin: 0 0 0.8rem;
font-size: 1.4rem;
line-height: 2rem;
&:last-child {
margin-bottom: 0;
}
}
}
&__body:last-child {
margin-bottom: 0;
}
&__image {
display: flex;
align-items: center;
justify-content: center;
margin-top: 2.4rem;
img {
width: 100%;
height: 136px;
border-radius: 4px;
object-fit: cover;
}
}
&__btn-ctr {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
&__btn {
display: flex;
height: 3.2rem;
align-items: center;
padding: 10px 16px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
line-height: 12px;
}
&__confirm-btn {
background: var(--button-bg);
color: var(--button-color);
&:hover:not(.disabled) {
background:
linear-gradient(
0deg,
rgba(var(--center-channel-color-rgb), 0.16),
rgba(var(--center-channel-color-rgb), 0.16)
),
var(--button-bg);
}
&:active {
background:
linear-gradient(
0deg,
rgba(var(--center-channel-color-rgb), 0.32),
rgba(var(--center-channel-color-rgb), 0.32)
),
var(--button-bg);
}
&:focus {
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
}
.icon-chevron-right::before {
margin-right: -7px;
}
}
&__cancel-btn {
margin-right: 4px;
background: rgba(var(--button-bg-rgb), 0.08);
border-radius: 4px;
color: var(--button-bg);
text-decoration: none;
&:hover {
background: rgba(var(--button-bg-rgb), 0.04);
}
&:active {
background: rgba(var(--button-bg-rgb), 0.08);
}
&:focus {
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
}
.icon-chevron-left::before {
margin-left: -7px;
}
}
&__dot-ctr {
display: flex;
align-items: center;
justify-content: flex-start;
}
&__dot-ring {
position: relative;
display: flex;
width: 12px;
height: 12px;
align-items: center;
justify-content: center;
margin-right: 4px;
background: transparent;
border-radius: 50%;
&:last-child {
margin-right: 0;
}
}
&__dot-ring-active {
background: rgba(var(--button-bg-rgb), 0.16);
}
&__dot {
position: absolute;
top: 3px;
left: 3px;
width: 6px;
height: 6px;
background: rgba(var(--button-bg-rgb), 0.32);
border-radius: 6px;
&.active {
background: rgba(var(--button-bg-rgb), 1);
}
}
&__footer {
display: flex;
flex-direction: column;
margin-top: 2.4rem;
&-buttons {
display: flex;
align-items: center;
justify-content: start;
}
}
&__opt {
align-self: flex-end;
margin-top: 1.2rem;
font-size: 12px;
span {
opacity: 0.9;
}
}
&__backdrop {
position: absolute;
z-index: 999;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
&--transparent {
background: none !important;
}
}
}
// adding important as temporary fix, will be removing tippy very soon (WIP)
.tippy-box[data-placement^=right] > .tippy-arrow::before {
top: -1px !important;
border-width: 1px 0 0 1px !important;
transform: rotate(-45deg) !important;
}
.tippy-box[data-placement^=left] > .tippy-arrow::before {
top: -1px !important;
border-width: 1px 1px 0 0 !important;
transform: rotate(45deg) !important;
}
.tippy-box[data-placement^=bottom] > .tippy-arrow::before {
left: 1px !important;
border-width: 1px 0 0 1px !important;
transform: rotate(45deg) !important;
}
.tippy-box[data-placement^=top] > .tippy-arrow::before {
left: 1px !important;
border-width: 0 0 1px 1px !important;
transform: rotate(-45deg) !important;
}
// this style is defined outside of the block scope because is intended to affect the tippy element
.tippy-blue-style {
background: var(--button-bg) !important;
color: var(--sidebar-text) !important;
.tippy-arrow {
border-color: var(--button-bg) !important;
color: var(--button-bg) !important;
&::before {
border-width: 0 !important;
border-color: var(--button-bg) !important;
border-left-color: initial;
background-color: var(--button-bg) !important;
transform-origin: unset !important;
}
}
.tour-tip__header {
font-weight: 600;
}
.icon-close {
color: var(--sidebar-text) !important;
}
// style buttons while in the blue style
.tour-tip {
&__btn {
background: var(--button-color);
color: var(--button-bg);
&:hover,
&:active,
&:focus {
background: var(--button-color);
color: var(--button-bg);
}
}
&__dot-ring {
.tour-tip__dot {
background: var(--offline-indicator);
}
}
&__dot-ring-active {
.active {
background: var(--button-color);
}
}
}
}

Просмотреть файл

@@ -0,0 +1,247 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react';
import {FormattedMessage} from 'react-intl';
import Tippy from '@tippyjs/react';
import {Placement} from 'tippy.js';
import classNames from 'classnames';
import {PunchOutCoordsHeightAndWidth} from '../common/hooks/useMeasurePunchouts';
import 'tippy.js/dist/tippy.css';
import 'tippy.js/themes/light-border.css';
import 'tippy.js/animations/scale-subtle.css';
import 'tippy.js/animations/perspective-subtle.css';
import {PulsatingDot} from '../pulsating_dot';
import {TourTipBackdrop} from './tour_tip_backdrop';
import './tour_tip.scss';
export type TourTipEventSource = 'next' | 'prev' | 'dismiss' | 'jump' | 'skipped' | 'open' | 'punchOut'
type Props = {
show: boolean;
screen: JSX.Element;
title: JSX.Element;
step: number;
tourSteps?: Record<string, number>;
nextBtn?: JSX.Element;
prevBtn?: JSX.Element;
imageURL?: string;
singleTip?: boolean;
showOptOut?: boolean;
placement?: Placement;
pulsatingDotPlacement?: Omit<Placement, 'auto'| 'auto-end'>;
pulsatingDotTranslate?: {x: number; y: number};
offset?: [number, number];
width?: string | number;
zIndex?: number;
className?: string;
hideBackdrop?: boolean;
tippyBlueStyle?: boolean;
// if you don't want punchOut just assign null, keep null as hook may return null first than actual value
overlayPunchOut: PunchOutCoordsHeightAndWidth | null;
// if we want to interact with element visible via punchOut
interactivePunchOut?: boolean;
handleOpen?: (e: React.MouseEvent) => void;
handleNext?: (e: React.MouseEvent) => void;
handlePrevious?: (e: React.MouseEvent) => void;
handleJump?: (e: React.MouseEvent, jumpToStep: number) => void;
handleSkip?: (e: React.MouseEvent) => void;
handleDismiss?: (e: React.MouseEvent) => void;
handlePunchOut?: (e: React.MouseEvent) => void;
}
export const TourTip = ({
title,
screen,
imageURL,
overlayPunchOut,
singleTip,
step,
show,
interactivePunchOut,
tourSteps,
handleOpen,
handleDismiss,
handleNext,
handlePrevious,
handleSkip,
handleJump,
handlePunchOut,
pulsatingDotTranslate,
pulsatingDotPlacement,
nextBtn,
prevBtn,
className,
offset = [-18, 4],
placement = 'right-start',
showOptOut = true,
width = 352,
zIndex = 999,
hideBackdrop = false,
tippyBlueStyle = false,
}: Props) => {
const FIRST_STEP_INDEX = 0;
const triggerRef = useRef(null);
const onJump = (event: React.MouseEvent, jumpToStep: number) => {
if (handleJump) {
handleJump(event, jumpToStep);
}
};
// This needs to be changed if root-portal node isn't available to maybe body
const rootPortal = document.getElementById('root-portal');
const dots = [];
if (!singleTip && tourSteps) {
for (let dot = FIRST_STEP_INDEX; dot < (Object.values(tourSteps).length - 1); dot++) {
let className = 'tour-tip__dot';
let circularRing = 'tour-tip__dot-ring';
if (dot === step) {
className += ' active';
circularRing += ' tour-tip__dot-ring-active';
}
dots.push(
<div className={circularRing}>
<a
href='#'
key={'dotactive' + dot}
className={className}
data-screen={dot}
onClick={(e) => onJump(e, dot)}
/>
</div>,
);
}
}
const content = (
<>
<div
className='tour-tip__header'
data-testid={'current_tutorial_tip'}
>
<h4 className='tour-tip__header__title'>
{title}
</h4>
<button
className='tour-tip__header__close'
onClick={handleDismiss}
data-testid={'close_tutorial_tip'}
>
<i className='icon icon-close'/>
</button>
</div>
<div className='tour-tip__body'>
{screen}
</div>
{imageURL && (
<div className='tour-tip__image'>
<img
src={imageURL}
alt={'tutorial tour tip product image'}
/>
</div>
)}
{(nextBtn || prevBtn || showOptOut) && (<div className='tour-tip__footer'>
<div className='tour-tip__footer-buttons'>
<div className='tour-tip__dot-ctr'>{dots}</div>
<div className={'tour-tip__btn-ctr'}>
{step !== 0 && prevBtn && (
<button
id='tipPreviousButton'
className='tour-tip__btn tour-tip__cancel-btn'
onClick={handlePrevious}
>
{prevBtn}
</button>
)}
{nextBtn && (
<button
id='tipNextButton'
className='tour-tip__btn tour-tip__confirm-btn'
onClick={handleNext}
>
{nextBtn}
</button>
)}
</div>
</div>
{showOptOut && (
<div className='tour-tip__opt'>
<FormattedMessage
id='tutorial_tip.seen'
defaultMessage='Seen this before? '
/>
<a
href='#'
onClick={handleSkip}
>
<FormattedMessage
id='tutorial_tip.out'
defaultMessage='Opt out of these tips.'
/>
</a>
</div>
)}
</div>
)}
</>
);
return (
<>
<div
id='tipButton'
ref={triggerRef}
onClick={handleOpen}
className='tour-tip__pulsating-dot-ctr'
data-pulsating-dot-placement={pulsatingDotPlacement || 'right'}
style={{
transform: `translate(${pulsatingDotTranslate?.x}px, ${pulsatingDotTranslate?.y}px)`,
}}
>
<PulsatingDot/>
</div>
<TourTipBackdrop
show={show}
onDismiss={handleDismiss}
onPunchOut={handlePunchOut}
interactivePunchOut={interactivePunchOut}
overlayPunchOut={overlayPunchOut}
appendTo={rootPortal!}
transparent={hideBackdrop}
/>
{show && (
<Tippy
showOnCreate={show}
content={content}
animation='scale-subtle'
trigger='click'
duration={[250, 150]}
maxWidth={width}
aria={{content: 'labelledby'}}
allowHTML={true}
zIndex={zIndex}
reference={triggerRef}
interactive={true}
appendTo={rootPortal!}
offset={offset}
className={classNames(
'tour-tip__box',
className,
{'tippy-blue-style': tippyBlueStyle},
)}
placement={placement}
/>
)}
</>
);
};

Просмотреть файл

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ReactDOM from 'react-dom';
import {PunchOutCoordsHeightAndWidth} from '../common/hooks/useMeasurePunchouts';
type Props = {
overlayPunchOut: PunchOutCoordsHeightAndWidth | null;
show: boolean;
interactivePunchOut?: boolean;
onDismiss?: (e: React.MouseEvent) => void;
onPunchOut?: (e: React.MouseEvent) => void;
appendTo: HTMLElement;
transparent?: boolean;
}
const TourTipRootPortal = ({children, show, element}: {children: React.ReactNode ; show: boolean; element: Element}) =>
(show ? ReactDOM.createPortal(
children,
element,
) : null);
export const TourTipBackdrop = ({
show,
overlayPunchOut,
interactivePunchOut,
onDismiss,
onPunchOut,
appendTo,
transparent,
}: Props) => {
const vertices = [];
if (overlayPunchOut) {
const {x, y, width, height} = overlayPunchOut;
// draw to top left of punch out
vertices.push('0% 0%');
vertices.push('0% 100%');
vertices.push('100% 100%');
vertices.push('100% 0%');
vertices.push(`${x} 0%`);
vertices.push(`${x} ${y}`);
// draw punch out
vertices.push(`calc(${x} + ${width}) ${y}`);
vertices.push(`calc(${x} + ${width}) calc(${y} + ${height})`);
vertices.push(`${x} calc(${y} + ${height})`);
vertices.push(`${x} ${y}`);
// close off punch out
vertices.push(`${x} 0%`);
vertices.push('0% 0%');
}
const backdrop = (
<div
onClick={onDismiss}
className={`tour-tip__backdrop ${transparent ? 'tour-tip__backdrop--transparent' : ''}`}
style={{
clipPath: vertices.length ? `polygon(${vertices.join(', ')})` : undefined,
}}
/>
);
const overlay = interactivePunchOut ? backdrop : (
<>
<div
className={'tour-tip__overlay'}
onClick={onPunchOut || onDismiss}
/>
{backdrop}
</>
);
return (
<TourTipRootPortal
show={show}
element={appendTo}
>
{overlay}
</TourTipRootPortal>
);
};

21
webapp/platform/components/tsconfig.json Обычный файл
Просмотреть файл

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"strictNullChecks": true,
"isolatedModules": true,
"noEmit": true,
"declaration": true,
"outDir": "dist",
"paths": {
"mattermost-redux/*": ["./node_modules/mattermost-redux/src/*"],
"@mattermost/types/*": ["./node_modules/@mattermost/types/src/*"]
}
}
}