snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
30
webapp/platform/components/CLAUDE.OPTIONAL.md
Normal file
30
webapp/platform/components/CLAUDE.OPTIONAL.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# CLAUDE: `platform/components/` (`@mattermost/components`)
|
||||
|
||||
## Purpose
|
||||
- Cross-product React components (GenericModal, tour tips, loaders, hooks) shared by Channels, Boards, Playbooks, and plugins.
|
||||
- Ensures consistent UX, theming, and accessibility across Mattermost surfaces.
|
||||
|
||||
## Implementation Guidelines
|
||||
- Follow `webapp/STYLE_GUIDE.md → React Component Structure`, `Styling & Theming`, `Accessibility`.
|
||||
- Components must be framework-agnostic: accept data/handlers via props, avoid direct Redux or routing dependencies.
|
||||
- Keep styles local via SCSS modules or styled components already used in this package; expose classNames for host overrides when necessary.
|
||||
- Include Storybook/README snippets where helpful (see package README).
|
||||
|
||||
## Testing
|
||||
- All components need RTL tests (`*.test.tsx`) demonstrating accessibility behaviors.
|
||||
- Provide test utils (`testUtils.tsx`) for consumers who need to mock these components.
|
||||
|
||||
## Key Modules
|
||||
- `generic_modal/` – baseline modal with focus trapping and keyboard handling.
|
||||
- `tour_tip/` – coach marks with backdrop management.
|
||||
- `skeleton_loader/`, `pulsating_dot/` – loading indicators.
|
||||
- `hooks/` – shared hooks like `useFocusTrap`.
|
||||
|
||||
## Release Notes
|
||||
- Breaking props or behavior requires updating `platform/components/README.md` and notifying dependent teams.
|
||||
|
||||
## References
|
||||
- `generic_modal/generic_modal.tsx`, `tour_tip/tour_tip.tsx`, `hooks/useFocusTrap.test.tsx`.
|
||||
|
||||
|
||||
|
||||
24
webapp/platform/components/README.md
Normal file
24
webapp/platform/components/README.md
Normal file
@@ -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=platform/components
|
||||
```
|
||||
|
||||
57
webapp/platform/components/babel.config.js
Normal file
57
webapp/platform/components/babel.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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,
|
||||
corejs: {
|
||||
version: '3.26',
|
||||
proposals: true,
|
||||
},
|
||||
debug: false,
|
||||
shippedProposals: true,
|
||||
useBuiltIns: 'usage',
|
||||
}],
|
||||
['@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,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
config.env = {
|
||||
test: {
|
||||
presets: config.presets,
|
||||
plugins: config.plugins,
|
||||
},
|
||||
};
|
||||
config.env.test.presets[0][1].modules = 'auto';
|
||||
|
||||
module.exports = config;
|
||||
8
webapp/platform/components/eslint.config.mjs
Normal file
8
webapp/platform/components/eslint.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import eslintPlugin from '@mattermost/eslint-plugin';
|
||||
|
||||
export default [
|
||||
...eslintPlugin.configs.base,
|
||||
];
|
||||
30
webapp/platform/components/jest.config.js
Normal file
30
webapp/platform/components/jest.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
|
||||
const config = {
|
||||
moduleDirectories: ['src', 'node_modules'],
|
||||
testEnvironment: 'jsdom',
|
||||
testPathIgnorePatterns: ['/node_modules/', '/.rollup.cache/', '/dist/'],
|
||||
clearMocks: true,
|
||||
moduleNameMapper: {
|
||||
'^@mattermost/(components)$': '<rootDir>/../platform/$1/src',
|
||||
'^@mattermost/(client)$': '<rootDir>/../platform/$1/src',
|
||||
'^@mattermost/(types)/(.*)$': '<rootDir>/../platform/$1/src/$2',
|
||||
'^.+\\.(css|scss)$': 'identity-obj-proxy',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
setupFilesAfterEnv: ['<rootDir>/setup_jest.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/.rollup.cache/',
|
||||
'/dist/',
|
||||
],
|
||||
coverageReporters: ['json', 'lcov', 'text-summary'],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
57
webapp/platform/components/package.json
Normal file
57
webapp/platform/components/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@mattermost/components",
|
||||
"version": "12.0.0",
|
||||
"private": true,
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"styles": "dist/index.esm.css",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"check": "eslint ./src eslint.config.mjs --quiet",
|
||||
"check-types": "tsc -b",
|
||||
"fix": "eslint ./src eslint.config.mjs --quiet --fix",
|
||||
"run": "rollup -c --watch",
|
||||
"test": "cross-env TZ=Etc/UTC jest",
|
||||
"test:updatesnapshot": "cross-env TZ=Etc/UTC jest --updateSnapshot",
|
||||
"test-ci": "cross-env TZ=Etc/UTC jest --ci --forceExit --detectOpenHandles --maxWorkers=100% --logHeapUsage",
|
||||
"clean": "rm -rf dist node_modules tsconfig.tsbuildinfo .rollup.cache"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.17.0",
|
||||
"@rollup/plugin-babel": "^5.3.1",
|
||||
"@rollup/plugin-commonjs": "^21.0.2",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"@rollup/plugin-typescript": "11.1.6",
|
||||
"@testing-library/jest-dom": "6.8.0",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-bootstrap": "^0.32.35",
|
||||
"@types/react-dom": "19.2.6",
|
||||
"@types/scheduler": "0.16.8",
|
||||
"@types/shallow-equals": "^1.0.0",
|
||||
"@types/styled-components": "^5.1.36",
|
||||
"babel-plugin-styled-components": "2.1.4",
|
||||
"jest-environment-jsdom": "30.1.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"rollup": "^2.75.7",
|
||||
"rollup-plugin-scss": "4.0.0",
|
||||
"rollup-plugin-ts": "^2.0.5",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime-corejs3": "^7.17.8",
|
||||
"@mattermost/compass-icons": "0.1.61",
|
||||
"@mattermost/shared": "12.0.0",
|
||||
"classnames": "^2.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-intl": "*",
|
||||
"shallow-equals": "^1.0.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tippyjs/react": "4.2.6",
|
||||
"react-bootstrap": "github:mattermost/react-bootstrap#d693ec8082954f0e6d3e826ca6161979b29001cd",
|
||||
"styled-components": "5.3.7"
|
||||
}
|
||||
}
|
||||
54
webapp/platform/components/rollup.config.js
Normal file
54
webapp/platform/components/rollup.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import scss from 'rollup-plugin-scss';
|
||||
|
||||
import packagejson from './package.json';
|
||||
|
||||
const externals = [
|
||||
...Object.keys(packagejson.dependencies || {}),
|
||||
...Object.keys(packagejson.peerDependencies || {}),
|
||||
'lodash/throttle',
|
||||
'react',
|
||||
|
||||
// react-dom is only a devDependency here, so without this it gets bundled into dist and the
|
||||
// web app ends up running two copies of the renderer.
|
||||
'react-dom',
|
||||
'mattermost-redux',
|
||||
'reselect',
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'src/index.tsx',
|
||||
output: [
|
||||
{
|
||||
sourcemap: true,
|
||||
file: packagejson.module,
|
||||
format: 'es',
|
||||
globals: {'styled-components': 'styled'},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
scss({
|
||||
fileName: 'index.esm.css',
|
||||
outputToFilesystem: true,
|
||||
}),
|
||||
resolve({
|
||||
browser: true,
|
||||
extensions: ['.ts', '.tsx'],
|
||||
}),
|
||||
commonjs(),
|
||||
typescript({
|
||||
outputToFilesystem: true,
|
||||
}),
|
||||
],
|
||||
external: externals,
|
||||
watch: {
|
||||
clearScreen: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
4
webapp/platform/components/setup_jest.ts
Normal file
4
webapp/platform/components/setup_jest.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {MutableRefObject} from 'react';
|
||||
import {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,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export const useFollowElementDimensions = (elementId: string): DOMRectReadOnly => {
|
||||
const [dimensions, setDimensions] = useState(new DOMRect());
|
||||
|
||||
useEffect(() => {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
if (entries.length > 0) {
|
||||
setDimensions(entries[0].contentRect);
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(element);
|
||||
};
|
||||
}, [elementId]);
|
||||
|
||||
return dimensions;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import throttle from 'lodash/throttle';
|
||||
import {useLayoutEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {useElementAvailable} from './useElementAvailable';
|
||||
|
||||
export type Coords = {
|
||||
x?: string;
|
||||
y?: string;
|
||||
};
|
||||
|
||||
export type Props = Coords & {
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
|
||||
type PunchOutOffset = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const useMeasurePunchouts = (elementIds: string[], additionalDeps: any[], offset?: PunchOutOffset): Props | 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,170 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`LegacyGenericModal/FooterPagination should render default 1`] = `
|
||||
{
|
||||
"asFragment": [Function],
|
||||
"baseElement": <body>
|
||||
<div>
|
||||
<div
|
||||
class="footer-pagination"
|
||||
>
|
||||
<div
|
||||
class="footer-pagination__legend"
|
||||
/>
|
||||
<div
|
||||
class="footer-pagination__button-container"
|
||||
>
|
||||
<button
|
||||
class="footer-pagination__button-container__button disabled"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Previous
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="footer-pagination__button-container__button disabled"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Next
|
||||
</span>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>,
|
||||
"container": <div>
|
||||
<div
|
||||
class="footer-pagination"
|
||||
>
|
||||
<div
|
||||
class="footer-pagination__legend"
|
||||
/>
|
||||
<div
|
||||
class="footer-pagination__button-container"
|
||||
>
|
||||
<button
|
||||
class="footer-pagination__button-container__button disabled"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Previous
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="footer-pagination__button-container__button disabled"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Next
|
||||
</span>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
"debug": [Function],
|
||||
"findAllByAltText": [Function],
|
||||
"findAllByDisplayValue": [Function],
|
||||
"findAllByLabelText": [Function],
|
||||
"findAllByPlaceholderText": [Function],
|
||||
"findAllByRole": [Function],
|
||||
"findAllByTestId": [Function],
|
||||
"findAllByText": [Function],
|
||||
"findAllByTitle": [Function],
|
||||
"findByAltText": [Function],
|
||||
"findByDisplayValue": [Function],
|
||||
"findByLabelText": [Function],
|
||||
"findByPlaceholderText": [Function],
|
||||
"findByRole": [Function],
|
||||
"findByTestId": [Function],
|
||||
"findByText": [Function],
|
||||
"findByTitle": [Function],
|
||||
"getAllByAltText": [Function],
|
||||
"getAllByDisplayValue": [Function],
|
||||
"getAllByLabelText": [Function],
|
||||
"getAllByPlaceholderText": [Function],
|
||||
"getAllByRole": [Function],
|
||||
"getAllByTestId": [Function],
|
||||
"getAllByText": [Function],
|
||||
"getAllByTitle": [Function],
|
||||
"getByAltText": [Function],
|
||||
"getByDisplayValue": [Function],
|
||||
"getByLabelText": [Function],
|
||||
"getByPlaceholderText": [Function],
|
||||
"getByRole": [Function],
|
||||
"getByTestId": [Function],
|
||||
"getByText": [Function],
|
||||
"getByTitle": [Function],
|
||||
"queryAllByAltText": [Function],
|
||||
"queryAllByDisplayValue": [Function],
|
||||
"queryAllByLabelText": [Function],
|
||||
"queryAllByPlaceholderText": [Function],
|
||||
"queryAllByRole": [Function],
|
||||
"queryAllByTestId": [Function],
|
||||
"queryAllByText": [Function],
|
||||
"queryAllByTitle": [Function],
|
||||
"queryByAltText": [Function],
|
||||
"queryByDisplayValue": [Function],
|
||||
"queryByLabelText": [Function],
|
||||
"queryByPlaceholderText": [Function],
|
||||
"queryByRole": [Function],
|
||||
"queryByTestId": [Function],
|
||||
"queryByText": [Function],
|
||||
"queryByTitle": [Function],
|
||||
"rerender": [Function],
|
||||
"unmount": [Function],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,83 @@
|
||||
// These mixins are from `src/sass/utils/_mixins.scss in the web app. They've been copied because rollup-plugin-sass
|
||||
// that we use doesn't properly support @use.
|
||||
|
||||
@mixin button-small {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
@mixin tertiary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: var(--button-bg);
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease-out;
|
||||
|
||||
&:disabled {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.32);
|
||||
}
|
||||
|
||||
&:hover:enabled {
|
||||
background: rgba(var(--button-bg-rgb), 0.12);
|
||||
}
|
||||
|
||||
&:active:enabled {
|
||||
background: rgba(var(--button-bg-rgb), 0.16);
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
font-size: 18px;
|
||||
|
||||
&:first-child::before {
|
||||
margin: 0 7px 0 0;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
margin: 0 0 0 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-pagination {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&__legend {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__button-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__button {
|
||||
@include tertiary-button;
|
||||
@include button-small;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import {FooterPagination} from './footer_pagination';
|
||||
|
||||
import {wrapIntl} from '../testUtils';
|
||||
|
||||
describe('LegacyGenericModal/FooterPagination', () => {
|
||||
const baseProps = {
|
||||
page: 0,
|
||||
total: 0,
|
||||
itemsPerPage: 0,
|
||||
onNextPage: jest.fn(),
|
||||
onPreviousPage: jest.fn(),
|
||||
};
|
||||
|
||||
test('should render default', () => {
|
||||
const wrapper = render(wrapIntl(<FooterPagination {...baseProps}/>));
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should render pagination legend', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
page: 0,
|
||||
total: 17,
|
||||
itemsPerPage: 10,
|
||||
};
|
||||
|
||||
render(wrapIntl(<FooterPagination {...props}/>));
|
||||
|
||||
expect(screen.getByText('Showing 1-10 of 17')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {ChevronLeftIcon, ChevronRightIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import './footer_pagination.scss';
|
||||
|
||||
const BUTTON_ICON_SIZE = 16;
|
||||
|
||||
type Props = {
|
||||
page: number;
|
||||
total: number;
|
||||
itemsPerPage: number;
|
||||
onNextPage: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onPreviousPage: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
};
|
||||
|
||||
export const FooterPagination = ({
|
||||
page,
|
||||
total,
|
||||
itemsPerPage,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const startCount = page * itemsPerPage;
|
||||
const endCount = Math.min(startCount + itemsPerPage, total);
|
||||
const totalPages = Math.trunc((total - 1) / itemsPerPage);
|
||||
|
||||
const prevDisabled = page <= 0;
|
||||
const nextDisabled = page >= totalPages;
|
||||
|
||||
return (
|
||||
<div className='footer-pagination'>
|
||||
<div className='footer-pagination__legend'>
|
||||
{Boolean(total) && (
|
||||
formatMessage(
|
||||
{
|
||||
id: 'footer_pagination.count',
|
||||
defaultMessage: 'Showing {startCount, number}-{endCount, number} of {total, number}',
|
||||
},
|
||||
{
|
||||
startCount: startCount + 1,
|
||||
endCount,
|
||||
total,
|
||||
},
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className='footer-pagination__button-container'>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
'footer-pagination__button-container__button',
|
||||
{disabled: prevDisabled},
|
||||
)}
|
||||
onClick={onPreviousPage}
|
||||
disabled={prevDisabled}
|
||||
>
|
||||
<ChevronLeftIcon size={BUTTON_ICON_SIZE}/>
|
||||
<span>
|
||||
{formatMessage({
|
||||
id: 'footer_pagination.prev',
|
||||
defaultMessage: 'Previous',
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
'footer-pagination__button-container__button',
|
||||
{disabled: nextDisabled},
|
||||
)}
|
||||
onClick={onNextPage}
|
||||
disabled={nextDisabled}
|
||||
>
|
||||
<span>
|
||||
{formatMessage({
|
||||
id: 'footer_pagination.next',
|
||||
defaultMessage: 'Next',
|
||||
})}
|
||||
</span>
|
||||
<ChevronRightIcon size={BUTTON_ICON_SIZE}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`GenericModal should match snapshot for base case 1`] = `
|
||||
{
|
||||
"asFragment": [Function],
|
||||
"baseElement": <body
|
||||
class="modal-open"
|
||||
style="padding-right: 0px;"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="fade modal-backdrop in"
|
||||
/>
|
||||
<div
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-modal="true"
|
||||
class="fade in modal"
|
||||
id="genericModal"
|
||||
role="dialog"
|
||||
style="display: block; padding-right: 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="GenericModal__location--center a11y__modal GenericModal modal-dialog"
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
>
|
||||
<div
|
||||
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
|
||||
>
|
||||
<div
|
||||
class="modal-header"
|
||||
>
|
||||
<div
|
||||
class="GenericModal__header__text_container"
|
||||
/>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="close"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
<span
|
||||
class="sr-only"
|
||||
>
|
||||
Close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="modal-body"
|
||||
>
|
||||
<div
|
||||
class="GenericModal__header"
|
||||
>
|
||||
<h1
|
||||
class="modal-title"
|
||||
id="genericModalLabel"
|
||||
>
|
||||
Modal Header Text
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="GenericModal__body padding"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>,
|
||||
"container": <div
|
||||
aria-hidden="true"
|
||||
/>,
|
||||
"debug": [Function],
|
||||
"findAllByAltText": [Function],
|
||||
"findAllByDisplayValue": [Function],
|
||||
"findAllByLabelText": [Function],
|
||||
"findAllByPlaceholderText": [Function],
|
||||
"findAllByRole": [Function],
|
||||
"findAllByTestId": [Function],
|
||||
"findAllByText": [Function],
|
||||
"findAllByTitle": [Function],
|
||||
"findByAltText": [Function],
|
||||
"findByDisplayValue": [Function],
|
||||
"findByLabelText": [Function],
|
||||
"findByPlaceholderText": [Function],
|
||||
"findByRole": [Function],
|
||||
"findByTestId": [Function],
|
||||
"findByText": [Function],
|
||||
"findByTitle": [Function],
|
||||
"getAllByAltText": [Function],
|
||||
"getAllByDisplayValue": [Function],
|
||||
"getAllByLabelText": [Function],
|
||||
"getAllByPlaceholderText": [Function],
|
||||
"getAllByRole": [Function],
|
||||
"getAllByTestId": [Function],
|
||||
"getAllByText": [Function],
|
||||
"getAllByTitle": [Function],
|
||||
"getByAltText": [Function],
|
||||
"getByDisplayValue": [Function],
|
||||
"getByLabelText": [Function],
|
||||
"getByPlaceholderText": [Function],
|
||||
"getByRole": [Function],
|
||||
"getByTestId": [Function],
|
||||
"getByText": [Function],
|
||||
"getByTitle": [Function],
|
||||
"queryAllByAltText": [Function],
|
||||
"queryAllByDisplayValue": [Function],
|
||||
"queryAllByLabelText": [Function],
|
||||
"queryAllByPlaceholderText": [Function],
|
||||
"queryAllByRole": [Function],
|
||||
"queryAllByTestId": [Function],
|
||||
"queryAllByText": [Function],
|
||||
"queryAllByTitle": [Function],
|
||||
"queryByAltText": [Function],
|
||||
"queryByDisplayValue": [Function],
|
||||
"queryByLabelText": [Function],
|
||||
"queryByPlaceholderText": [Function],
|
||||
"queryByRole": [Function],
|
||||
"queryByTestId": [Function],
|
||||
"queryByText": [Function],
|
||||
"queryByTitle": [Function],
|
||||
"rerender": [Function],
|
||||
"unmount": [Function],
|
||||
}
|
||||
`;
|
||||
178
webapp/platform/components/src/generic_modal/generic_modal.scss
Normal file
178
webapp/platform/components/src/generic_modal/generic_modal.scss
Normal file
@@ -0,0 +1,178 @@
|
||||
.console__body .modal .GenericModal,
|
||||
.app__body .modal .GenericModal {
|
||||
&__location--top {
|
||||
margin-top: 5vh;
|
||||
}
|
||||
|
||||
&__location--center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-top: 0;
|
||||
padding-bottom: 24px;
|
||||
|
||||
.modal-content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__location--bottom {
|
||||
margin-top: calc(50vh + 240px);
|
||||
}
|
||||
|
||||
&.modal-overflow {
|
||||
.modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.no-header__img {
|
||||
margin-top: -40px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
.GenericModal__header__text_container {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
|
||||
.modal-subheading-container {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
div#genericModalSubheading {
|
||||
font-size: 12px;
|
||||
margin-block: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
|
||||
&.divider {
|
||||
border-top: var(--border-light);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
border: var(--border-default);
|
||||
border-radius: 4px;
|
||||
|
||||
&.form-control--lg {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
||||
& + .input-clear {
|
||||
top: 17px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: 0 0 0 1px var(--button-bg);
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
border-color: var(--error-text);
|
||||
}
|
||||
}
|
||||
|
||||
.MaxLengthInput {
|
||||
&.form-control.has-error {
|
||||
padding-right: 66px;
|
||||
}
|
||||
|
||||
&__validation {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 56px;
|
||||
color: var(--error-text);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-clear {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: 16px;
|
||||
opacity: 0.48;
|
||||
}
|
||||
}
|
||||
|
||||
&.GenericModal__compassDesign {
|
||||
.modal-content {
|
||||
max-height: 90vh;
|
||||
|
||||
.modal-body {
|
||||
.GenericModal__body {
|
||||
padding: 0;
|
||||
|
||||
&.padding {
|
||||
padding: 8px 32px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.genericModalError {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
margin: 0 32px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16);
|
||||
margin-bottom: 24px;
|
||||
background: rgba(var(--dnd-indicator-rgb), 0.08);
|
||||
border-radius: var(--radius-s);
|
||||
|
||||
span {
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 8px;
|
||||
color: var(--error-text);
|
||||
font-size: 24px;
|
||||
line-height: 24px;
|
||||
|
||||
&::before {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
margin: 0 !important;
|
||||
|
||||
.modal-header {
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__wrapper-enter-key-press-catcher {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act, render, screen, waitFor} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import {GenericModal} from './generic_modal';
|
||||
|
||||
import {wrapIntl} from '../testUtils';
|
||||
|
||||
describe('GenericModal', () => {
|
||||
const baseProps = {
|
||||
onExited: jest.fn(),
|
||||
modalHeaderText: 'Modal Header Text',
|
||||
children: <></>,
|
||||
};
|
||||
|
||||
test('should match snapshot for base case', () => {
|
||||
const wrapper = render(
|
||||
wrapIntl(<GenericModal {...baseProps}/>),
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should have confirm and cancels buttons when handlers are passed for both buttons', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
handleConfirm: jest.fn(),
|
||||
handleCancel: jest.fn(),
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
expect(screen.getByText('Confirm')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onExited when modal exits', async () => {
|
||||
const onExitedMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onExited: onExitedMock,
|
||||
};
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// Find and click the close button to trigger modal exit
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
|
||||
// Wait for onExited to be called
|
||||
await waitFor(() => {
|
||||
expect(onExitedMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not throw if onExited is undefined', async () => {
|
||||
// Create props without onExited
|
||||
const {onExited, ...propsWithoutOnExited} = baseProps; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const props = {
|
||||
...propsWithoutOnExited,
|
||||
onHide: jest.fn(), // Ensure onHide is provided since it's mandatory
|
||||
};
|
||||
|
||||
// This should not throw
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// Find and click the close button to trigger modal exit
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
|
||||
// This should not throw
|
||||
expect(() => {
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('calls onEntered when modal enters', async () => {
|
||||
const onEnteredMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onEntered: onEnteredMock,
|
||||
show: false, // Start with modal hidden
|
||||
};
|
||||
|
||||
const {rerender} = render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// Show the modal
|
||||
rerender(
|
||||
wrapIntl(<GenericModal {...props} show={true}/>),
|
||||
);
|
||||
|
||||
// Wait for onEntered to be called
|
||||
await waitFor(() => {
|
||||
expect(onEnteredMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not throw if onEntered is undefined', async () => {
|
||||
// Create props without onEntered
|
||||
const props = {
|
||||
...baseProps,
|
||||
show: false, // Start with modal hidden
|
||||
};
|
||||
|
||||
const {rerender} = render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// This should not throw
|
||||
expect(() => {
|
||||
rerender(
|
||||
wrapIntl(<GenericModal {...props} show={true}/>),
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('calls onHide when modal is closed', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
};
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// Find and click the close button to trigger modal exit
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
|
||||
// Wait for onHide to be called
|
||||
await waitFor(() => {
|
||||
expect(onHideMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('preventClose functionality', () => {
|
||||
test('preventClose=true prevents modal from closing when close button is clicked', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const onExitedMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
onExited: onExitedMock,
|
||||
preventClose: true,
|
||||
show: true,
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(modal).toHaveClass('in'); // Modal is visible
|
||||
|
||||
// Find and click the close button
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
closeButton.click();
|
||||
|
||||
// onHide should still be called (for error handling, etc.)
|
||||
await waitFor(() => {
|
||||
expect(onHideMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// But modal should still be visible (preventClose prevents internal state change)
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(modal).toHaveClass('in'); // Still visible
|
||||
|
||||
// onExited should NOT be called since modal didn't actually close
|
||||
expect(onExitedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('preventClose=true prevents modal from closing when cancel button is clicked', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const handleCancelMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
handleCancel: handleCancelMock,
|
||||
preventClose: true,
|
||||
show: true,
|
||||
autoCloseOnCancelButton: true, // Default behavior
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toHaveClass('in');
|
||||
|
||||
// Find and click the cancel button
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
cancelButton.click();
|
||||
|
||||
// Both callbacks should be called
|
||||
await waitFor(() => {
|
||||
expect(handleCancelMock).toHaveBeenCalled();
|
||||
expect(onHideMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// But modal should still be visible due to preventClose
|
||||
expect(modal).toHaveClass('in');
|
||||
});
|
||||
|
||||
test('preventClose=true prevents modal from closing when confirm button is clicked', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const handleConfirmMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
handleConfirm: handleConfirmMock,
|
||||
preventClose: true,
|
||||
show: true,
|
||||
autoCloseOnConfirmButton: true, // Default behavior
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toHaveClass('in');
|
||||
|
||||
// Find and click the confirm button
|
||||
const confirmButton = screen.getByText('Confirm');
|
||||
confirmButton.click();
|
||||
|
||||
// Both callbacks should be called
|
||||
await waitFor(() => {
|
||||
expect(handleConfirmMock).toHaveBeenCalled();
|
||||
expect(onHideMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// But modal should still be visible due to preventClose
|
||||
expect(modal).toHaveClass('in');
|
||||
});
|
||||
|
||||
test('preventClose=false allows normal modal closing via close button', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const onExitedMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
onExited: onExitedMock,
|
||||
preventClose: false,
|
||||
show: true,
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toHaveClass('in');
|
||||
|
||||
// Find and click the close button
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
|
||||
// onHide should be called
|
||||
await waitFor(() => {
|
||||
expect(onHideMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should close (lose 'in' class)
|
||||
await waitFor(() => {
|
||||
expect(modal).not.toHaveClass('in');
|
||||
});
|
||||
});
|
||||
|
||||
test('preventClose=false allows normal modal closing via cancel button', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const handleCancelMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
handleCancel: handleCancelMock,
|
||||
preventClose: false,
|
||||
show: true,
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toHaveClass('in');
|
||||
|
||||
// Find and click the cancel button
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
act(() => {
|
||||
cancelButton.click();
|
||||
});
|
||||
|
||||
// Both callbacks should be called
|
||||
await waitFor(() => {
|
||||
expect(handleCancelMock).toHaveBeenCalled();
|
||||
expect(onHideMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should close normally
|
||||
await waitFor(() => {
|
||||
expect(modal).not.toHaveClass('in');
|
||||
});
|
||||
});
|
||||
|
||||
test('preventClose state can be toggled dynamically', async () => {
|
||||
const onHideMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
onHide: onHideMock,
|
||||
preventClose: true,
|
||||
show: true,
|
||||
};
|
||||
|
||||
const {rerender} = render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole('dialog');
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
|
||||
// First click - should be prevented
|
||||
closeButton.click();
|
||||
await waitFor(() => {
|
||||
expect(onHideMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(modal).toHaveClass('in'); // Still visible
|
||||
|
||||
// Change preventClose to false
|
||||
rerender(
|
||||
wrapIntl(<GenericModal {...props} preventClose={false}/>),
|
||||
);
|
||||
|
||||
// Second click - should close modal
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onHideMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Modal should close now
|
||||
await waitFor(() => {
|
||||
expect(modal).not.toHaveClass('in');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
385
webapp/platform/components/src/generic_modal/generic_modal.tsx
Normal file
385
webapp/platform/components/src/generic_modal/generic_modal.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useState, useEffect, useCallback, useRef} from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {Button, type ButtonVariant} from '@mattermost/shared/components/button';
|
||||
|
||||
import {useFocusTrap} from '../hooks/useFocusTrap';
|
||||
import {useStackedModal} from '../hooks/useStackedModal';
|
||||
import './generic_modal.scss';
|
||||
|
||||
export type ModalLocation = 'top' | 'center' | 'bottom';
|
||||
|
||||
export type Props = {
|
||||
className?: string;
|
||||
onExited?: () => void;
|
||||
onEntered?: () => void;
|
||||
onHide?: () => void;
|
||||
preventClose?: boolean;
|
||||
modalHeaderText?: React.ReactNode;
|
||||
modalHeaderTextId?: string;
|
||||
modalSubheaderText?: React.ReactNode;
|
||||
show?: boolean;
|
||||
handleCancel?: () => void;
|
||||
handleConfirm?: () => void;
|
||||
handleEnterKeyPress?: () => void;
|
||||
handleKeydown?: (event?: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
confirmButtonText?: React.ReactNode;
|
||||
confirmButtonVariant?: ButtonVariant;
|
||||
cancelButtonText?: React.ReactNode;
|
||||
isConfirmDisabled?: boolean;
|
||||
id?: string;
|
||||
autoCloseOnCancelButton?: boolean;
|
||||
autoCloseOnConfirmButton?: boolean;
|
||||
enforceFocus?: boolean;
|
||||
container?: React.ReactNode | readonly React.ReactNode[];
|
||||
ariaLabel?: string;
|
||||
ariaLabelledby?: string;
|
||||
errorText?: string | React.ReactNode;
|
||||
compassDesign?: boolean;
|
||||
backdrop?: boolean | 'static';
|
||||
backdropClassName?: string;
|
||||
tabIndex?: number;
|
||||
children: React.ReactNode;
|
||||
autoFocusConfirmButton?: boolean;
|
||||
keyboardEscape?: boolean;
|
||||
headerInput?: React.ReactNode;
|
||||
bodyPadding?: boolean;
|
||||
bodyDivider?: boolean;
|
||||
bodyOverflowVisible?: boolean;
|
||||
footerContent?: React.ReactNode;
|
||||
footerDivider?: boolean;
|
||||
appendedContent?: React.ReactNode;
|
||||
headerButton?: React.ReactNode;
|
||||
showCloseButton?: boolean;
|
||||
showHeader?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this modal is stacked on top of another modal.
|
||||
* When true, the modal will not render its own backdrop and will
|
||||
* adjust the z-index of the parent modal's backdrop.
|
||||
*/
|
||||
isStacked?: boolean;
|
||||
|
||||
/*
|
||||
* Controls the vertical location of the modal.
|
||||
* 'top' => margin-top: 5vh
|
||||
* 'center' => margin-top: calc(50vh - 240px)
|
||||
* 'bottom' => margin-top: calc(50vh + 240px) (example calculation)
|
||||
*/
|
||||
modalLocation?: ModalLocation;
|
||||
|
||||
/**
|
||||
* Optionally set a test ID for the container, so that the modal can be easily referenced
|
||||
* in tests (Cypress, Playwright, etc.)
|
||||
*/
|
||||
dataTestId?: string;
|
||||
|
||||
/**
|
||||
* Whether to delay activating the focus trap.
|
||||
*
|
||||
* This is useful for modals with dynamic content that might not be fully
|
||||
* rendered when the modal is opened. The delay allows the DOM to settle
|
||||
* before the focus trap identifies focusable elements. ie. MultiSelect
|
||||
*
|
||||
* When true, applies a 500ms delay.
|
||||
*/
|
||||
delayFocusTrap?: boolean;
|
||||
};
|
||||
|
||||
export const GenericModal: React.FC<Props> = ({
|
||||
show = true,
|
||||
id = 'genericModal',
|
||||
autoCloseOnCancelButton = true,
|
||||
autoCloseOnConfirmButton = true,
|
||||
enforceFocus = true,
|
||||
keyboardEscape = true,
|
||||
bodyPadding = true,
|
||||
showCloseButton = true,
|
||||
showHeader = true,
|
||||
modalLocation = 'center',
|
||||
className,
|
||||
onExited,
|
||||
onEntered,
|
||||
onHide,
|
||||
preventClose = false,
|
||||
modalHeaderText,
|
||||
modalHeaderTextId,
|
||||
modalSubheaderText,
|
||||
handleCancel,
|
||||
handleConfirm,
|
||||
handleEnterKeyPress,
|
||||
handleKeydown,
|
||||
confirmButtonText,
|
||||
confirmButtonVariant,
|
||||
cancelButtonText,
|
||||
isConfirmDisabled,
|
||||
container,
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
errorText,
|
||||
compassDesign,
|
||||
backdrop,
|
||||
backdropClassName,
|
||||
children,
|
||||
autoFocusConfirmButton,
|
||||
headerInput,
|
||||
bodyDivider,
|
||||
bodyOverflowVisible,
|
||||
footerContent,
|
||||
footerDivider,
|
||||
appendedContent,
|
||||
headerButton,
|
||||
dataTestId,
|
||||
delayFocusTrap,
|
||||
isStacked = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
// Create a ref for the modal container
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [showState, setShowState] = useState(show);
|
||||
|
||||
const onHideCallback = useCallback(() => {
|
||||
if (!preventClose) {
|
||||
setShowState(false);
|
||||
}
|
||||
onHide?.();
|
||||
}, [onHide, preventClose]);
|
||||
|
||||
// Use focus trap to keep focus within the modal when it's open
|
||||
useFocusTrap(showState, containerRef, {
|
||||
delayMs: delayFocusTrap ? 500 : undefined,
|
||||
});
|
||||
|
||||
// Use stacked modal hook to manage backdrop and z-index. Pass the
|
||||
// portal container (when it resolves to a DOM element) so backdrop
|
||||
// discovery stays scoped to this modal's own stack rather than every
|
||||
// backdrop in the document.
|
||||
const stackedModalContainer = container instanceof HTMLElement ? container : undefined;
|
||||
const {
|
||||
shouldRenderBackdrop,
|
||||
modalStyle,
|
||||
backdropStyle,
|
||||
} = useStackedModal(Boolean(isStacked), showState, stackedModalContainer);
|
||||
|
||||
useEffect(() => {
|
||||
setShowState(show);
|
||||
}, [show]);
|
||||
|
||||
const handleCancelCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
if (autoCloseOnCancelButton) {
|
||||
onHideCallback();
|
||||
}
|
||||
handleCancel?.();
|
||||
}, [autoCloseOnCancelButton, onHideCallback, handleCancel]);
|
||||
|
||||
const handleConfirmCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
if (autoCloseOnConfirmButton) {
|
||||
onHideCallback();
|
||||
}
|
||||
handleConfirm?.();
|
||||
}, [autoCloseOnConfirmButton, onHideCallback, handleConfirm]);
|
||||
|
||||
const onEnterKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (handleConfirm && autoCloseOnConfirmButton) {
|
||||
onHideCallback();
|
||||
}
|
||||
handleEnterKeyPress?.();
|
||||
}
|
||||
handleKeydown?.(event);
|
||||
}, [handleConfirm, autoCloseOnConfirmButton, onHideCallback, handleEnterKeyPress, handleKeydown]);
|
||||
|
||||
// Build confirm button if provided.
|
||||
let confirmButtonElement;
|
||||
if (handleConfirm) {
|
||||
const buttonTypeClass = confirmButtonVariant === 'destructive' ? 'delete' : 'confirm';
|
||||
let confirmButtonTextContent: React.ReactNode = (
|
||||
<FormattedMessage
|
||||
id='generic_modal.confirm'
|
||||
defaultMessage='Confirm'
|
||||
/>
|
||||
);
|
||||
if (confirmButtonText) {
|
||||
confirmButtonTextContent = confirmButtonText;
|
||||
}
|
||||
confirmButtonElement = (
|
||||
<Button
|
||||
autoFocus={autoFocusConfirmButton}
|
||||
type='submit'
|
||||
emphasis='primary'
|
||||
variant={confirmButtonVariant}
|
||||
className={classNames('GenericModal__button', buttonTypeClass, {
|
||||
disabled: isConfirmDisabled,
|
||||
})}
|
||||
onClick={handleConfirmCallback}
|
||||
disabled={isConfirmDisabled}
|
||||
>
|
||||
{confirmButtonTextContent}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Build cancel button if provided.
|
||||
let cancelButtonElement;
|
||||
if (handleCancel) {
|
||||
let cancelButtonTextContent: React.ReactNode = (
|
||||
<FormattedMessage
|
||||
id='generic_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
);
|
||||
if (cancelButtonText) {
|
||||
cancelButtonTextContent = cancelButtonText;
|
||||
}
|
||||
cancelButtonElement = (
|
||||
<Button
|
||||
type='button'
|
||||
emphasis='tertiary'
|
||||
onClick={handleCancelCallback}
|
||||
>
|
||||
{cancelButtonTextContent}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Build header text if provided.
|
||||
const headerText = modalHeaderText && (
|
||||
<div className='GenericModal__header'>
|
||||
<h1 id={modalHeaderTextId || 'genericModalLabel'} className='modal-title'>
|
||||
{modalHeaderText}
|
||||
</h1>
|
||||
{headerButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Map modalLocation to a CSS class.
|
||||
const locationClassMapping: Record<ModalLocation, string> = {
|
||||
top: 'GenericModal__location--top',
|
||||
center: 'GenericModal__location--center',
|
||||
bottom: 'GenericModal__location--bottom',
|
||||
};
|
||||
const modalLocationClass = locationClassMapping[modalLocation];
|
||||
|
||||
// Accessibility labeling strategy:
|
||||
// 1. We always set aria-labelledby to ensure the modal has a proper label
|
||||
// - First try to use the provided ariaLabeledBy prop
|
||||
// - Fall back to 'genericModalLabel' which references the modal title
|
||||
// 2. We also support aria-label as a secondary option
|
||||
// - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist
|
||||
// - This provides a fallback for accessibility in case the referenced element is missing
|
||||
// Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence
|
||||
const ariaLabelledbyValue = ariaLabelledby || 'genericModalLabel';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id={id}
|
||||
role='none'
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledbyValue}
|
||||
aria-modal='true'
|
||||
dialogClassName={classNames(
|
||||
modalLocationClass,
|
||||
'a11y__modal GenericModal',
|
||||
{
|
||||
GenericModal__compassDesign: compassDesign,
|
||||
'modal--overflow': bodyOverflowVisible,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
show={showState}
|
||||
restoreFocus={true}
|
||||
enforceFocus={enforceFocus}
|
||||
onHide={onHideCallback}
|
||||
onExited={onExited}
|
||||
backdrop={shouldRenderBackdrop ? backdrop : false}
|
||||
backdropStyle={backdropStyle}
|
||||
backdropClassName={backdropClassName}
|
||||
container={container}
|
||||
keyboard={keyboardEscape}
|
||||
onEntered={onEntered}
|
||||
data-testid={dataTestId}
|
||||
style={modalStyle}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onKeyDown={onEnterKeyDown}
|
||||
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
|
||||
>
|
||||
{showHeader && (
|
||||
<Modal.Header closeButton={false}>
|
||||
<div className='GenericModal__header__text_container'>
|
||||
{compassDesign && (
|
||||
<>
|
||||
{headerText}
|
||||
{headerInput}
|
||||
</>
|
||||
)}
|
||||
{modalSubheaderText && (
|
||||
<div className='modal-subheading-container'>
|
||||
<div id='genericModalSubheading' className='modal-subheading'>
|
||||
{modalSubheaderText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type='button'
|
||||
className='close'
|
||||
onClick={onHideCallback}
|
||||
aria-label={intl.formatMessage({id: 'generic_modal.close', defaultMessage: 'Close'})}
|
||||
>
|
||||
<span aria-hidden='true'>{'×'}</span>
|
||||
<span className='sr-only'>
|
||||
<FormattedMessage id='generic_modal.close' defaultMessage='Close' />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</Modal.Header>
|
||||
)}
|
||||
<Modal.Body className={classNames({divider: bodyDivider, 'overflow-visible': bodyOverflowVisible})}>
|
||||
{compassDesign ? (
|
||||
errorText && (
|
||||
<div className='genericModalError'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
<span>{errorText}</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
headerText
|
||||
)}
|
||||
<div className={classNames('GenericModal__body', {padding: bodyPadding})}>
|
||||
{children}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
{(cancelButtonElement || confirmButtonElement || footerContent) && (
|
||||
<Modal.Footer className={classNames({divider: footerDivider})}>
|
||||
{(cancelButtonElement || confirmButtonElement) ? (
|
||||
<>
|
||||
{cancelButtonElement}
|
||||
{confirmButtonElement}
|
||||
</>
|
||||
) : (
|
||||
footerContent
|
||||
)}
|
||||
</Modal.Footer>
|
||||
)}
|
||||
{Boolean(appendedContent) && appendedContent}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericModal;
|
||||
308
webapp/platform/components/src/hooks/useFocusTrap.test.tsx
Normal file
308
webapp/platform/components/src/hooks/useFocusTrap.test.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import React, {useRef} from 'react';
|
||||
|
||||
import {useFocusTrap} from './useFocusTrap';
|
||||
|
||||
// Test component that uses the hook
|
||||
function FocusTrapTestComponent({
|
||||
isActive = true,
|
||||
initialFocus = false,
|
||||
restoreFocus = false,
|
||||
delayMs = 0,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
initialFocus?: boolean;
|
||||
restoreFocus?: boolean;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useFocusTrap(isActive, containerRef, {
|
||||
initialFocus,
|
||||
restoreFocus,
|
||||
delayMs,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-testid='container'>
|
||||
<button data-testid='button1'>Button 1</button>
|
||||
<button data-testid='button2'>Button 2</button>
|
||||
<button data-testid='button3'>Button 3</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Test component with nested focus traps
|
||||
function NestedFocusTrapsComponent() {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useFocusTrap(true, outerRef);
|
||||
useFocusTrap(true, innerRef);
|
||||
|
||||
return (
|
||||
<div ref={outerRef} data-testid='outer-container'>
|
||||
<button data-testid='outer-button1'>Outer Button 1</button>
|
||||
<div ref={innerRef} data-testid='inner-container'>
|
||||
<button data-testid='inner-button1'>Inner Button 1</button>
|
||||
<button data-testid='inner-button2'>Inner Button 2</button>
|
||||
</div>
|
||||
<button data-testid='outer-button2'>Outer Button 2</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mimics the mobile menu modal, where an ancestor sets `pointer-events: none`
|
||||
// and a descendant restores `pointer-events: auto` (see menu.scss `.menuModal`).
|
||||
function PointerEventsFocusTrapComponent() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useFocusTrap(true, containerRef);
|
||||
|
||||
return (
|
||||
<div style={{pointerEvents: 'none'}} data-testid='pe-modal'>
|
||||
<div ref={containerRef} style={{pointerEvents: 'auto'}} data-testid='pe-container'>
|
||||
<button data-testid='pe-button1'>Button 1</button>
|
||||
<button data-testid='pe-button2'>Button 2</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFocusTrap', () => {
|
||||
beforeEach(() => {
|
||||
// Create a div to hold our rendered components
|
||||
const container = document.createElement('div');
|
||||
container.id = 'root';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create an element outside the focus trap for testing restoreFocus
|
||||
const outsideButton = document.createElement('button');
|
||||
outsideButton.setAttribute('data-testid', 'outside-button');
|
||||
outsideButton.textContent = 'Outside Button';
|
||||
document.body.appendChild(outsideButton);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
document.body.innerHTML = '';
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// Helper function to simulate Tab key press
|
||||
const simulateTabKey = (shiftKey = false) => {
|
||||
const tabEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(tabEvent);
|
||||
};
|
||||
|
||||
// Helper function to simulate tab navigation with focus trap
|
||||
const simulateTabWithFocusTrap = (container: HTMLElement, shiftKey = false) => {
|
||||
const focusableElements = Array.from(
|
||||
container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
|
||||
) as HTMLElement[];
|
||||
|
||||
if (focusableElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const currentElement = document.activeElement as HTMLElement;
|
||||
|
||||
// Find the current element index
|
||||
const currentIndex = focusableElements.indexOf(currentElement);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// If not found, focus the first element
|
||||
firstElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
// Backward navigation
|
||||
if (currentElement === firstElement) {
|
||||
lastElement.focus();
|
||||
} else {
|
||||
const prevIndex = ((currentIndex - 1) + focusableElements.length) % focusableElements.length;
|
||||
focusableElements[prevIndex].focus();
|
||||
}
|
||||
} else if (currentElement === lastElement) {
|
||||
// Forward navigation - if at last element, go to first
|
||||
firstElement.focus();
|
||||
} else {
|
||||
// Forward navigation - go to next element
|
||||
const nextIndex = (currentIndex + 1) % focusableElements.length;
|
||||
focusableElements[nextIndex].focus();
|
||||
}
|
||||
};
|
||||
|
||||
test('should trap focus within the container', async () => {
|
||||
const {container} = render(<FocusTrapTestComponent />);
|
||||
|
||||
// Focus the first button
|
||||
const button1 = screen.getByTestId('button1');
|
||||
button1.focus();
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Tab to the next button
|
||||
simulateTabWithFocusTrap(container);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button2'));
|
||||
|
||||
// Tab to the last button
|
||||
simulateTabWithFocusTrap(container);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button3'));
|
||||
|
||||
// Tab again should cycle back to the first button
|
||||
simulateTabWithFocusTrap(container);
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Shift+Tab should go to the last button
|
||||
simulateTabWithFocusTrap(container, true);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button3'));
|
||||
});
|
||||
|
||||
test('should set initial focus when initialFocus is true', () => {
|
||||
render(<FocusTrapTestComponent initialFocus={true} />);
|
||||
|
||||
// The first focusable element should be focused automatically
|
||||
// We need to wait for the focus to be set
|
||||
setTimeout(() => {
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button1'));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
test('should restore focus when restoreFocus is true', () => {
|
||||
// Focus the outside button first
|
||||
const outsideButton = screen.getByTestId('outside-button');
|
||||
outsideButton.focus();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
|
||||
// Render the component with restoreFocus=true
|
||||
const {unmount} = render(<FocusTrapTestComponent restoreFocus={true} />);
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Focus should be restored to the outside button
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
});
|
||||
|
||||
test('should handle delay option', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const {container} = render(<FocusTrapTestComponent delayMs={500} />);
|
||||
|
||||
// Focus the first button
|
||||
const button1 = screen.getByTestId('button1');
|
||||
button1.focus();
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Tab to the next button - should not be trapped yet due to delay
|
||||
// We'll use simulateTabKey here to simulate what happens without the trap
|
||||
simulateTabKey();
|
||||
|
||||
// Advance timers
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Now focus the first button again and try tabbing
|
||||
button1.focus();
|
||||
simulateTabWithFocusTrap(container);
|
||||
|
||||
// Now the focus trap should be active
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button2'));
|
||||
});
|
||||
|
||||
test('should not activate when isActive is false', () => {
|
||||
render(<FocusTrapTestComponent isActive={false} />);
|
||||
|
||||
// Focus the first button
|
||||
const button1 = screen.getByTestId('button1');
|
||||
button1.focus();
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Tab to the next button - should not be trapped
|
||||
// We'll use simulateTabKey here to simulate what happens without the trap
|
||||
simulateTabKey();
|
||||
|
||||
// Focus should not be trapped within the container
|
||||
expect(document.activeElement).not.toBe(screen.getByTestId('button2'));
|
||||
});
|
||||
|
||||
test('should handle nested focus traps', () => {
|
||||
render(<NestedFocusTrapsComponent />);
|
||||
|
||||
// Focus the first inner button
|
||||
const innerButton1 = screen.getByTestId('inner-button1');
|
||||
innerButton1.focus();
|
||||
expect(document.activeElement).toBe(innerButton1);
|
||||
|
||||
// Find the inner container
|
||||
const innerContainer = screen.getByTestId('inner-container');
|
||||
|
||||
// Tab to the next button in the inner trap
|
||||
simulateTabWithFocusTrap(innerContainer);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('inner-button2'));
|
||||
|
||||
// Tab again should cycle back to the first inner button
|
||||
simulateTabWithFocusTrap(innerContainer);
|
||||
expect(document.activeElement).toBe(innerButton1);
|
||||
|
||||
// The outer trap should not interfere with the inner trap
|
||||
});
|
||||
|
||||
test('should handle empty containers gracefully', () => {
|
||||
// Create a component with no focusable elements
|
||||
function EmptyComponent() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(true, containerRef);
|
||||
return <div ref={containerRef} data-testid='empty-container'></div>;
|
||||
}
|
||||
|
||||
render(<EmptyComponent />);
|
||||
|
||||
// No errors should be thrown
|
||||
const container = screen.getByTestId('empty-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression test for MM-69557: the mobile menu fix sets `pointer-events: none`
|
||||
// on an ancestor (`.menuModal`) and restores it on a descendant. The trap must
|
||||
// keep treating the still-focusable descendants as focusable rather than
|
||||
// filtering them out because an ancestor has `pointer-events: none`.
|
||||
test('should trap focus for elements under an ancestor with pointer-events: none', () => {
|
||||
// jsdom reports zero-size rects by default, which would mark every element
|
||||
// hidden; give the buttons a non-zero size so visibility hinges on styles.
|
||||
const rectSpy = jest.
|
||||
spyOn(HTMLElement.prototype, 'getBoundingClientRect').
|
||||
mockReturnValue({width: 100, height: 20, x: 0, y: 0, top: 0, left: 0, right: 100, bottom: 20, toJSON: () => ({})} as DOMRect);
|
||||
|
||||
// Restore the global spy in finally so a failed render or assertion
|
||||
// cannot leak the mock into subsequent tests.
|
||||
try {
|
||||
render(<PointerEventsFocusTrapComponent />);
|
||||
|
||||
const button1 = screen.getByTestId('pe-button1');
|
||||
const button2 = screen.getByTestId('pe-button2');
|
||||
|
||||
// Focus the last button, then Tab. The trap must cycle back to the first
|
||||
// button, which only happens if both buttons were detected as focusable.
|
||||
button2.focus();
|
||||
expect(document.activeElement).toBe(button2);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab', code: 'Tab', bubbles: true, cancelable: true}));
|
||||
|
||||
expect(document.activeElement).toBe(button1);
|
||||
} finally {
|
||||
rectSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
214
webapp/platform/components/src/hooks/useFocusTrap.ts
Normal file
214
webapp/platform/components/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
|
||||
// A global stack to hold active focus trap containers.
|
||||
// This ensures that only the topmost trap processes Tab events.
|
||||
const activeFocusTraps: HTMLElement[] = [];
|
||||
|
||||
type FocusTrapOptions = {
|
||||
initialFocus?: boolean;
|
||||
restoreFocus?: boolean;
|
||||
delayMs?: number; // Delay in milliseconds before activating the focus trap
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that traps focus within a container element.
|
||||
* When multiple focus traps are active, only the topmost one will process Tab key events.
|
||||
* @param isActive Whether the focus trap is active
|
||||
* @param containerRef A ref to the container element
|
||||
* @param options FocusTrapOptions Options for the focus trap
|
||||
* @returns void
|
||||
*/
|
||||
export function useFocusTrap(
|
||||
isActive: boolean,
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
options: FocusTrapOptions = {initialFocus: false, restoreFocus: false},
|
||||
): void {
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Add a ref to store the cached focusable elements
|
||||
const focusableElementsRef = useRef<HTMLElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!isActive || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the previously focused element for restoration if needed
|
||||
if (options.restoreFocus) {
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
}
|
||||
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let trapActive = false;
|
||||
|
||||
// Function to cache focusable elements and activate the trap
|
||||
const activateFocusTrap = () => {
|
||||
// Cache the focusable elements
|
||||
focusableElementsRef.current = getFocusableElements(container);
|
||||
|
||||
// Register this focus trap (push it onto the global stack)
|
||||
activeFocusTraps.push(container);
|
||||
trapActive = true;
|
||||
|
||||
if (focusableElementsRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial focus if needed
|
||||
if (options.initialFocus && focusableElementsRef.current.length > 0) {
|
||||
focusableElementsRef.current[0].focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to refresh the cached elements if needed
|
||||
const refreshFocusableElements = () => {
|
||||
focusableElementsRef.current = getFocusableElements(container);
|
||||
};
|
||||
|
||||
// Delay the activation if delayMs is specified
|
||||
if (options.delayMs && options.delayMs > 0) {
|
||||
timeoutId = setTimeout(activateFocusTrap, options.delayMs);
|
||||
} else {
|
||||
// Activate immediately if no delay
|
||||
activateFocusTrap();
|
||||
}
|
||||
|
||||
// Handle tab key navigation - only trap Tab key, let other keys propagate
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle Tab key for focus trapping
|
||||
if (e.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if this container is the top-most active focus trap
|
||||
// AND if the focus trap has been activated (after delay)
|
||||
if (!trapActive || activeFocusTraps[activeFocusTraps.length - 1] !== container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the cached focusable elements
|
||||
const elements = focusableElementsRef.current;
|
||||
if (elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = elements[0];
|
||||
const lastElement = elements[elements.length - 1];
|
||||
|
||||
// If shift+tab on first element, move to last element
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) { // If tab on last element, move to first element
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Set up a MutationObserver to detect DOM changes that might affect focusable elements
|
||||
const observer = new MutationObserver(() => {
|
||||
// Only refresh if the trap is active
|
||||
if (trapActive) {
|
||||
refreshFocusableElements();
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the container for changes that might affect focusability
|
||||
observer.observe(container, {
|
||||
childList: true, // Watch for changes to child elements
|
||||
subtree: true, // Watch the entire subtree
|
||||
attributes: true, // Watch for attribute changes
|
||||
attributeFilter: ['tabindex', 'disabled'], // Only care about attributes that affect focusability
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Cleanup function
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
// Clear the timeout if component unmounts during delay
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Stop the observer
|
||||
observer.disconnect();
|
||||
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Only remove from stack if it was actually added
|
||||
if (trapActive) {
|
||||
const index = activeFocusTraps.indexOf(container);
|
||||
if (index > -1) {
|
||||
activeFocusTraps.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore focus when trap is deactivated
|
||||
if (options.restoreFocus && previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isActive, containerRef, options.initialFocus, options.restoreFocus, options.delayMs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get all focusable elements within a container
|
||||
* @param container The container element
|
||||
* @returns An array of focusable elements
|
||||
*/
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
const elements = Array.from(container.querySelectorAll(selector)) as HTMLElement[];
|
||||
|
||||
// Filter out hidden elements
|
||||
return elements.filter((element) => isElementVisible(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an element is visible in the DOM
|
||||
* @param element The element to check
|
||||
* @returns true if the element is visible, false otherwise
|
||||
*/
|
||||
function isElementVisible(element: HTMLElement): boolean {
|
||||
// Check if the element has zero dimensions
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check computed styles for this element and its ancestors
|
||||
let currentElement: HTMLElement | null = element;
|
||||
while (currentElement) {
|
||||
const style = window.getComputedStyle(currentElement);
|
||||
|
||||
// Check common ways elements can be hidden. Note: pointer-events is
|
||||
// intentionally not treated as a visibility signal here. It only affects
|
||||
// mouse/touch hit-testing, not keyboard focusability, and an ancestor's
|
||||
// `pointer-events: none` can be re-enabled on descendants, so walking the
|
||||
// ancestor chain for it wrongly excludes still-focusable elements.
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
currentElement.hasAttribute('hidden')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
453
webapp/platform/components/src/hooks/useStackedModal.test.tsx
Normal file
453
webapp/platform/components/src/hooks/useStackedModal.test.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act, render, screen} from '@testing-library/react';
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {useStackedModal} from './useStackedModal';
|
||||
|
||||
import {GenericModal} from '../generic_modal/generic_modal';
|
||||
import {wrapIntl} from '../testUtils';
|
||||
|
||||
// Z-index constants from the hook implementation
|
||||
const BASE_MODAL_Z_INDEX = 1050;
|
||||
const Z_INDEX_INCREMENT = 10;
|
||||
|
||||
// The mock below installs two `.modal-backdrop` elements, so a stacked
|
||||
// modal rendered under it sits at stacking depth 2.
|
||||
const MOCK_STACK_DEPTH = 2;
|
||||
|
||||
// Mock component that directly uses the useStackedModal hook
|
||||
const TestComponent = ({
|
||||
isStacked = false,
|
||||
isOpen = true,
|
||||
container,
|
||||
}: {
|
||||
isStacked?: boolean;
|
||||
isOpen?: boolean;
|
||||
container?: HTMLElement | null;
|
||||
}) => {
|
||||
const {shouldRenderBackdrop, modalStyle, backdropStyle} = useStackedModal(isStacked, isOpen, container);
|
||||
|
||||
return (
|
||||
<div data-testid='test-component'>
|
||||
<div data-testid='should-render-backdrop'>{shouldRenderBackdrop.toString()}</div>
|
||||
<div data-testid='modal-z-index'>{modalStyle.zIndex || 'none'}</div>
|
||||
<div data-testid='backdrop-z-index'>{backdropStyle?.zIndex ?? 'none'}</div>
|
||||
<div>Modal Content</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useStackedModal', () => {
|
||||
// Mock document.querySelectorAll for backdrop tests
|
||||
let originalQuerySelectorAll: typeof document.querySelectorAll;
|
||||
let mockBackdrop1: HTMLElement;
|
||||
let mockBackdrop2: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original implementation
|
||||
originalQuerySelectorAll = document.querySelectorAll;
|
||||
|
||||
// Create mock backdrop elements
|
||||
mockBackdrop1 = document.createElement('div');
|
||||
mockBackdrop1.className = 'modal-backdrop';
|
||||
mockBackdrop1.style.zIndex = '1040'; // Bootstrap default
|
||||
mockBackdrop1.style.opacity = '0.5'; // Bootstrap default
|
||||
|
||||
mockBackdrop2 = document.createElement('div');
|
||||
mockBackdrop2.className = 'modal-backdrop';
|
||||
mockBackdrop2.style.zIndex = '1045'; // Higher z-index for the second backdrop
|
||||
mockBackdrop2.style.opacity = '0.5'; // Bootstrap default
|
||||
|
||||
document.querySelectorAll = jest.fn().mockImplementation((selector: string) => {
|
||||
if (selector === '.modal-backdrop') {
|
||||
return [mockBackdrop1, mockBackdrop2]; // Return multiple backdrops to simulate stacked modals
|
||||
}
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original implementation
|
||||
document.querySelectorAll = originalQuerySelectorAll;
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
test('does not affect regular modals', () => {
|
||||
const props = {
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
modalHeaderText: 'Regular Modal',
|
||||
children: <div>Regular Modal Content</div>,
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// The modal should be in the document
|
||||
expect(screen.getByText('Regular Modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regular Modal Content')).toBeInTheDocument();
|
||||
|
||||
// Regular modals should have a backdrop
|
||||
// We can't directly test the backdrop since it's controlled by react-bootstrap
|
||||
// But we can verify the modal displayed correctly and has the expected aria attributes
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
||||
});
|
||||
|
||||
test('stacked modals have shouldRenderBackdrop=true but pass backdrop=false to Modal', () => {
|
||||
const props = {
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
modalHeaderText: 'Stacked Modal',
|
||||
isStacked: true,
|
||||
children: <div>Stacked Modal Content</div>,
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...props}/>),
|
||||
);
|
||||
|
||||
// The modal should be in the document
|
||||
expect(screen.getByText('Stacked Modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stacked Modal Content')).toBeInTheDocument();
|
||||
|
||||
// We can't directly test the backdrop since it's controlled by react-bootstrap
|
||||
// But we can verify the modal displayed correctly and has the expected aria attributes
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
||||
});
|
||||
|
||||
test('stacked modals do not render their own backdrop', () => {
|
||||
// This test verifies that stacked modals don't render their own backdrop through GenericModal
|
||||
const stackedProps = {
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
modalHeaderText: 'Stacked Modal',
|
||||
id: 'stackedModal',
|
||||
isStacked: true,
|
||||
children: <div>Stacked Modal Content</div>,
|
||||
};
|
||||
|
||||
render(
|
||||
wrapIntl(<GenericModal {...stackedProps}/>),
|
||||
);
|
||||
|
||||
// The modal should be in the document
|
||||
expect(screen.getByText('Stacked Modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stacked Modal Content')).toBeInTheDocument();
|
||||
|
||||
// The modal should have aria-modal="true"
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direct Hook Tests - Basic Functionality', () => {
|
||||
test('regular modals should render their own backdrop', () => {
|
||||
render(<TestComponent isStacked={false}/>);
|
||||
|
||||
expect(screen.getByTestId('should-render-backdrop')).toHaveTextContent('true');
|
||||
expect(screen.getByTestId('modal-z-index')).toHaveTextContent('none');
|
||||
expect(screen.getByTestId('backdrop-z-index')).toHaveTextContent('none');
|
||||
});
|
||||
|
||||
test('stacked modals should have increased z-index scaled by stacking depth', () => {
|
||||
render(<TestComponent isStacked={true}/>);
|
||||
|
||||
// z-index scales with the number of modals beneath this one
|
||||
// so each level sits above the previous stacked modal.
|
||||
const expectedZIndex = BASE_MODAL_Z_INDEX + (MOCK_STACK_DEPTH * Z_INDEX_INCREMENT);
|
||||
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(expectedZIndex.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direct Hook Tests - Backdrop Manipulation', () => {
|
||||
test('stacked modals should modify parent backdrop opacity', () => {
|
||||
render(<TestComponent isStacked={true}/>);
|
||||
|
||||
// The hook should have modified the most recent backdrop (mockBackdrop2)
|
||||
expect(mockBackdrop2.style.opacity).toBe('0');
|
||||
});
|
||||
|
||||
test('stacked modals should set transition property on parent backdrop', () => {
|
||||
render(<TestComponent isStacked={true}/>);
|
||||
|
||||
// Verify the transition property is set correctly
|
||||
expect(mockBackdrop2.style.transition).toBe('opacity 150ms ease-in-out');
|
||||
});
|
||||
|
||||
test('stacked modals place their backdrop just below their own modal', () => {
|
||||
render(<TestComponent isStacked={true}/>);
|
||||
|
||||
// The backdrop must sit one below the stacked modal: high
|
||||
// enough to dim the modal directly beneath it, low enough to
|
||||
// stay behind this modal's own content.
|
||||
const stackedModalZIndex = BASE_MODAL_Z_INDEX + (MOCK_STACK_DEPTH * Z_INDEX_INCREMENT);
|
||||
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(stackedModalZIndex.toString());
|
||||
expect(screen.getByTestId('backdrop-z-index')).toHaveTextContent((stackedModalZIndex - 1).toString());
|
||||
});
|
||||
|
||||
test('cleanup should restore original backdrop properties', () => {
|
||||
const {unmount} = render(<TestComponent isStacked={true}/>);
|
||||
|
||||
// The hook should have modified the parent backdrop
|
||||
expect(mockBackdrop2.style.opacity).toBe('0');
|
||||
|
||||
// Unmount to trigger cleanup
|
||||
unmount();
|
||||
|
||||
// Original opacity should be restored
|
||||
expect(mockBackdrop2.style.opacity).toBe('0.5');
|
||||
|
||||
// The standard transition is re-enabled after the restore so any
|
||||
// LATER opacity change (e.g. the parent modal's own close) still
|
||||
// animates. It is not used for the restore itself — that snaps
|
||||
// instantly; see the "snaps the parent backdrop opacity" test.
|
||||
expect(mockBackdrop2.style.transition).toBe('opacity 150ms ease-in-out');
|
||||
});
|
||||
|
||||
test('cleanup snaps the parent backdrop opacity back without animating (no close-time flash)', () => {
|
||||
// Regression guard for the stacked-modal close flash: when a
|
||||
// stacked modal closes, its own backdrop is removed instantly.
|
||||
// If the parent backdrop opacity is animated back up from 0 over
|
||||
// 150ms, there is a window with no opaque overlay and the whole
|
||||
// screen flashes bright. The restore must therefore be a snap
|
||||
// (transition disabled) so the dimming stays continuous.
|
||||
//
|
||||
// Use a non-default original opacity so the assertions also prove
|
||||
// the restore reuses the STORED value rather than a hardcoded one.
|
||||
const style = mockBackdrop2.style;
|
||||
style.opacity = '0.7';
|
||||
|
||||
// Record every style write in order, tagging each opacity write
|
||||
// with the transition value in effect at that instant. A correct
|
||||
// fix restores the original opacity while transitions are
|
||||
// disabled; the buggy version restored it while a 150ms opacity
|
||||
// transition was live.
|
||||
let opacityVal = style.opacity;
|
||||
let transitionVal = style.transition;
|
||||
const writes: Array<{prop: 'opacity' | 'transition'; value: string; transitionInEffect?: string}> = [];
|
||||
|
||||
Object.defineProperty(style, 'opacity', {
|
||||
configurable: true,
|
||||
get: () => opacityVal,
|
||||
set: (v: string) => {
|
||||
writes.push({prop: 'opacity', value: v, transitionInEffect: transitionVal});
|
||||
opacityVal = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(style, 'transition', {
|
||||
configurable: true,
|
||||
get: () => transitionVal,
|
||||
set: (v: string) => {
|
||||
writes.push({prop: 'transition', value: v});
|
||||
transitionVal = v;
|
||||
},
|
||||
});
|
||||
|
||||
const {unmount} = render(<TestComponent isStacked={true}/>);
|
||||
unmount();
|
||||
|
||||
// The final restore writes the stored original opacity back (0.7),
|
||||
// proving the hook reuses the captured value, not a default.
|
||||
const restoreWrite = [...writes].reverse().find((w) => w.prop === 'opacity' && w.value === '0.7');
|
||||
expect(restoreWrite).toBeDefined();
|
||||
|
||||
// Critically, that restore happens with transitions disabled so it
|
||||
// snaps instead of fading in (the fade-in is what caused the flash).
|
||||
expect(restoreWrite?.transitionInEffect).toBe('none');
|
||||
|
||||
// Lock in the cleanup ordering: transitions are disabled, THEN the
|
||||
// opacity is snapped back, THEN the standard transition is
|
||||
// re-enabled for future changes. (The forced reflow between the
|
||||
// snap and the re-enable is a no-op in jsdom, so this ordering
|
||||
// assertion is the closest available proxy for "no fade-in".)
|
||||
const cleanupWrites = writes.slice(writes.findIndex((w) => w.prop === 'transition' && w.value === 'none'));
|
||||
expect(cleanupWrites.map((w) => `${w.prop}:${w.value}`)).toEqual([
|
||||
'transition:none',
|
||||
'opacity:0.7',
|
||||
'transition:opacity 150ms ease-in-out',
|
||||
]);
|
||||
|
||||
expect(style.opacity).toBe('0.7');
|
||||
expect(style.transition).toBe('opacity 150ms ease-in-out');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Rendered against the real DOM (no querySelectorAll mock) so the hook
|
||||
// sees the actual backdrop elements each stacked modal creates. This
|
||||
// covers stacks deeper than one level, which is where a fixed z-index
|
||||
// offset silently broke: the deepest modal's backdrop landed below the
|
||||
// modal beneath it and left that modal undimmed (regression: Channel
|
||||
// Settings → Simulate access → Decision details flashed the middle
|
||||
// modal to full brightness while the details modal was open).
|
||||
describe('useStackedModal - multi-level stacking (integration)', () => {
|
||||
const flush = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
function StackHarness() {
|
||||
const [showMiddle, setShowMiddle] = useState(false);
|
||||
const [showDeepest, setShowDeepest] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setShowMiddle(true)}>{'open-middle'}</button>
|
||||
<button onClick={() => setShowDeepest(true)}>{'open-deepest'}</button>
|
||||
<GenericModal
|
||||
show={true}
|
||||
onHide={jest.fn()}
|
||||
modalHeaderText='Base'
|
||||
id='baseModal'
|
||||
>
|
||||
<div>base body</div>
|
||||
</GenericModal>
|
||||
{showMiddle ? (
|
||||
<GenericModal
|
||||
show={true}
|
||||
onHide={jest.fn()}
|
||||
modalHeaderText='Middle'
|
||||
id='middleModal'
|
||||
isStacked={true}
|
||||
>
|
||||
<div>middle body</div>
|
||||
</GenericModal>
|
||||
) : null}
|
||||
{showDeepest ? (
|
||||
<GenericModal
|
||||
show={true}
|
||||
onHide={jest.fn()}
|
||||
modalHeaderText='Deepest'
|
||||
id='deepestModal'
|
||||
isStacked={true}
|
||||
>
|
||||
<div>deepest body</div>
|
||||
</GenericModal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const zIndexOf = (id: string): number => {
|
||||
const el = document.getElementById(id) as HTMLElement | null;
|
||||
return el ? parseInt(el.style.zIndex || '1050', 10) : NaN;
|
||||
};
|
||||
|
||||
const topBackdrop = (): HTMLElement => {
|
||||
const backdrops = document.querySelectorAll<HTMLElement>('.modal-backdrop');
|
||||
return backdrops[backdrops.length - 1];
|
||||
};
|
||||
|
||||
test('a third-level stacked modal dims the modal directly beneath it', async () => {
|
||||
render(wrapIntl(<StackHarness/>));
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
screen.getByText('open-middle').click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Second level: the middle modal sits above the base modal and
|
||||
// its backdrop dims the base modal.
|
||||
expect(zIndexOf('middleModal')).toBeGreaterThan(zIndexOf('baseModal'));
|
||||
|
||||
act(() => {
|
||||
screen.getByText('open-deepest').click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const deepestModalZ = zIndexOf('deepestModal');
|
||||
const middleModalZ = zIndexOf('middleModal');
|
||||
const deepestBackdrop = topBackdrop();
|
||||
const deepestBackdropZ = parseInt(deepestBackdrop.style.zIndex || '0', 10);
|
||||
|
||||
// The deepest modal stacks above the middle modal...
|
||||
expect(deepestModalZ).toBeGreaterThan(middleModalZ);
|
||||
|
||||
// ...and its backdrop is sandwiched between them, so the middle
|
||||
// modal is dimmed instead of showing through at full brightness.
|
||||
expect(deepestBackdropZ).toBeGreaterThan(middleModalZ);
|
||||
expect(deepestBackdropZ).toBeLessThan(deepestModalZ);
|
||||
|
||||
// The dimming layer is actually visible (not the transparent
|
||||
// backdrop the close-flash fix parks the covered modal at).
|
||||
expect(deepestBackdrop.style.opacity).not.toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
// A document-wide `.modal-backdrop` query can't tell two independent
|
||||
// modal stacks apart: a second stack's backdrops would inflate this
|
||||
// stack's computed depth (pushing its z-index too high) and its most
|
||||
// recent backdrop could be grabbed as this stack's parent and wrongly
|
||||
// dimmed. Passing each modal's own portal container scopes discovery to
|
||||
// that stack so the two never interfere. (CodeRabbit review: scope
|
||||
// backdrop discovery to the current stack/container.)
|
||||
describe('useStackedModal - independent simultaneous stacks (scoped discovery)', () => {
|
||||
const BACKDROP_Z_INDEX = 1040;
|
||||
|
||||
const addBackdrops = (container: HTMLElement, count: number): HTMLElement[] => {
|
||||
const created: HTMLElement[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.style.zIndex = String(BACKDROP_Z_INDEX);
|
||||
backdrop.style.opacity = '0.5';
|
||||
container.appendChild(backdrop);
|
||||
created.push(backdrop);
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
let containerA: HTMLElement;
|
||||
let containerB: HTMLElement;
|
||||
let backdropsA: HTMLElement[];
|
||||
let backdropsB: HTMLElement[];
|
||||
|
||||
beforeEach(() => {
|
||||
containerA = document.createElement('div');
|
||||
containerB = document.createElement('div');
|
||||
document.body.appendChild(containerA);
|
||||
document.body.appendChild(containerB);
|
||||
|
||||
// Stack A has one modal beneath it (depth 1); stack B has two
|
||||
// (depth 2). If discovery were document-wide, BOTH stacked modals
|
||||
// would see all three backdrops and wrongly compute depth 3.
|
||||
backdropsA = addBackdrops(containerA, 1);
|
||||
backdropsB = addBackdrops(containerB, 2);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
containerA.remove();
|
||||
containerB.remove();
|
||||
});
|
||||
|
||||
test('each stack derives its depth from only its own container backdrops', () => {
|
||||
const {rerender} = render(<TestComponent isStacked={true} container={containerA}/>);
|
||||
|
||||
// Depth 1 from container A's single backdrop → not depth 3.
|
||||
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(
|
||||
(BASE_MODAL_Z_INDEX + Z_INDEX_INCREMENT).toString(),
|
||||
);
|
||||
|
||||
rerender(<TestComponent isStacked={true} container={containerB}/>);
|
||||
|
||||
// Depth 2 from container B's two backdrops → still isolated from A.
|
||||
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(
|
||||
(BASE_MODAL_Z_INDEX + (2 * Z_INDEX_INCREMENT)).toString(),
|
||||
);
|
||||
});
|
||||
|
||||
test('a stacked modal only dims the parent backdrop in its own container', () => {
|
||||
render(<TestComponent isStacked={true} container={containerA}/>);
|
||||
|
||||
// Stack A's most recent backdrop is dimmed...
|
||||
expect(backdropsA[backdropsA.length - 1].style.opacity).toBe('0');
|
||||
|
||||
// ...while the unrelated stack B's backdrops are left untouched.
|
||||
for (const backdrop of backdropsB) {
|
||||
expect(backdrop.style.opacity).toBe('0.5');
|
||||
}
|
||||
});
|
||||
});
|
||||
206
webapp/platform/components/src/hooks/useStackedModal.ts
Normal file
206
webapp/platform/components/src/hooks/useStackedModal.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const BASE_MODAL_Z_INDEX = 1050; // Bootstrap default modal z-index
|
||||
const BASE_BACKDROP_Z_INDEX = 1040; // Bootstrap default backdrop z-index
|
||||
const Z_INDEX_INCREMENT = 10; // Increment for each stacked modal level
|
||||
|
||||
// No options needed since delayMs is not used by any consumers
|
||||
|
||||
type StackedModalResult = {
|
||||
|
||||
/**
|
||||
* Whether the modal should render its own backdrop
|
||||
*/
|
||||
shouldRenderBackdrop: boolean;
|
||||
|
||||
/**
|
||||
* Style object for the modal element
|
||||
*/
|
||||
modalStyle: React.CSSProperties;
|
||||
|
||||
/**
|
||||
* Style object for the modal's backdrop element. Positions a
|
||||
* stacked modal's backdrop just above the modal directly beneath
|
||||
* it so that modal is dimmed while this one is open.
|
||||
*/
|
||||
backdropStyle?: React.CSSProperties;
|
||||
|
||||
/**
|
||||
* Reference to the parent modal element (if this is a stacked modal)
|
||||
*/
|
||||
parentModalRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that manages stacked modals, controlling backdrop visibility and z-index values.
|
||||
*
|
||||
* @param isStacked Whether this modal is stacked on top of another modal
|
||||
* @param isOpen Whether the modal is currently open
|
||||
* @param container The DOM element the modal is portaled into. Backdrop
|
||||
* discovery is scoped to this element so an independent modal stack in a
|
||||
* different container can't inflate this modal's stacking depth or have
|
||||
* its backdrop mistaken for this stack's parent. Defaults to the whole
|
||||
* document, which is the single shared stack for the common case where
|
||||
* modals render into `document.body`.
|
||||
* @returns An object with properties to control modal and backdrop rendering
|
||||
*/
|
||||
export function useStackedModal(
|
||||
isStacked: boolean,
|
||||
isOpen: boolean,
|
||||
container?: HTMLElement | null,
|
||||
): StackedModalResult {
|
||||
// State to track whether this modal should render its own backdrop
|
||||
const [shouldRenderBackdrop, setShouldRenderBackdrop] = useState(!isStacked);
|
||||
|
||||
// State to track z-index values
|
||||
const [zIndexes, setZIndexes] = useState({
|
||||
modal: BASE_MODAL_Z_INDEX,
|
||||
backdrop: BASE_BACKDROP_Z_INDEX,
|
||||
});
|
||||
|
||||
// Ref to store the parent modal element
|
||||
const parentModalRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Ref to store the original z-index of the parent modal's backdrop
|
||||
const originalBackdropZIndexRef = useRef<string | null>(null);
|
||||
|
||||
// Ref to store the parent modal's backdrop element
|
||||
const backdropRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Ref to store the original opacity of the parent modal's backdrop
|
||||
const originalBackdropOpacityRef = useRef<string | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// If this is not a stacked modal, do nothing
|
||||
if (!isStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If modal is closed, reset state and do cleanup
|
||||
if (!isOpen) {
|
||||
setShouldRenderBackdrop(false);
|
||||
setZIndexes({
|
||||
modal: BASE_MODAL_Z_INDEX,
|
||||
backdrop: BASE_BACKDROP_Z_INDEX,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No timeout needed since we're not using delay
|
||||
|
||||
// Function to adjust the backdrop for stacked modals
|
||||
const adjustBackdrop = () => {
|
||||
// For stacked modals, we want to render our own backdrop
|
||||
setShouldRenderBackdrop(true);
|
||||
|
||||
// The stacking depth is the number of backdrops already in
|
||||
// the DOM from the modals beneath this one. Each level must
|
||||
// sit a full increment above the previous stacked modal:
|
||||
// using a fixed offset broke stacks 3+ deep (e.g. Channel
|
||||
// Settings → Simulate access → Decision details), where the
|
||||
// deepest modal shared the middle modal's z-index and its
|
||||
// backdrop landed *below* that modal, leaving it undimmed.
|
||||
//
|
||||
// Scope the lookup to this modal's own container so a second,
|
||||
// independent stack rendered elsewhere in the document can't
|
||||
// be counted as part of this stack (which would over-count the
|
||||
// depth) or have its backdrop picked as this stack's parent.
|
||||
const root: ParentNode | null = container ?? (typeof document === 'undefined' ? null : document);
|
||||
const backdrops = root === null ?
|
||||
[] :
|
||||
Array.from(root.querySelectorAll<HTMLElement>('.modal-backdrop'));
|
||||
const depth = Math.max(backdrops.length, 1);
|
||||
|
||||
// The backdrop sits one below the modal so it dims the modal
|
||||
// directly beneath this one but stays behind this modal.
|
||||
const stackedModalZIndex = BASE_MODAL_Z_INDEX + (depth * Z_INDEX_INCREMENT);
|
||||
setZIndexes({
|
||||
modal: stackedModalZIndex,
|
||||
backdrop: stackedModalZIndex - 1,
|
||||
});
|
||||
|
||||
// Adjust the parent backdrop so we don't stack two dim
|
||||
// layers over the same content.
|
||||
if (backdrops.length > 0) {
|
||||
// Get the most recent backdrop (the one with the highest z-index)
|
||||
// This should be the backdrop of the parent modal
|
||||
const parentBackdrop = backdrops[backdrops.length - 1];
|
||||
backdropRef.current = parentBackdrop;
|
||||
originalBackdropZIndexRef.current = parentBackdrop.style.zIndex || String(BASE_BACKDROP_Z_INDEX);
|
||||
originalBackdropOpacityRef.current = parentBackdrop.style.opacity || '0.5'; // Default Bootstrap backdrop opacity
|
||||
|
||||
// Add a transition for smooth opacity change
|
||||
parentBackdrop.style.transition = 'opacity 150ms ease-in-out';
|
||||
parentBackdrop.style.opacity = '0';
|
||||
}
|
||||
};
|
||||
|
||||
// Adjust the backdrop immediately (no delay option)
|
||||
adjustBackdrop();
|
||||
|
||||
// Cleanup function
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
// Restore original backdrop properties
|
||||
if (backdropRef.current) {
|
||||
if (originalBackdropZIndexRef.current) {
|
||||
// Restore original z-index if it was stored
|
||||
backdropRef.current.style.zIndex = originalBackdropZIndexRef.current;
|
||||
}
|
||||
|
||||
if (originalBackdropOpacityRef.current) {
|
||||
const backdrop = backdropRef.current;
|
||||
|
||||
// Snap the parent backdrop back to its original opacity
|
||||
// WITHOUT a fade-in. The stacked modal's own backdrop is
|
||||
// removed instantly when it closes, so animating the
|
||||
// parent backdrop up from 0 over 150ms leaves a window
|
||||
// with no opaque overlay — the whole screen flashes
|
||||
// bright for that fraction of a second. Restoring the
|
||||
// opacity synchronously keeps the visible dimming
|
||||
// continuous as the stacked backdrop disappears.
|
||||
backdrop.style.transition = 'none';
|
||||
backdrop.style.opacity = originalBackdropOpacityRef.current;
|
||||
|
||||
// Reading a layout property forces the snap above to
|
||||
// commit before transitions are re-enabled; otherwise the
|
||||
// browser batches both writes and animates the opacity
|
||||
// change, bringing the flash back. offsetHeight is always
|
||||
// non-negative, so this also consumes the forced read.
|
||||
if (backdrop.offsetHeight >= 0) {
|
||||
backdrop.style.transition = 'opacity 150ms ease-in-out';
|
||||
}
|
||||
}
|
||||
|
||||
// Clear refs
|
||||
backdropRef.current = null;
|
||||
originalBackdropZIndexRef.current = null;
|
||||
originalBackdropOpacityRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, isStacked, container]);
|
||||
|
||||
const modalStyle = useMemo(() => {
|
||||
return isStacked ? {
|
||||
zIndex: zIndexes.modal,
|
||||
} : {};
|
||||
}, [isStacked, zIndexes.modal]);
|
||||
|
||||
const backdropStyle = useMemo(() => {
|
||||
return isStacked ? {
|
||||
zIndex: zIndexes.backdrop,
|
||||
} : undefined;
|
||||
}, [isStacked, zIndexes.backdrop]);
|
||||
|
||||
return {
|
||||
shouldRenderBackdrop,
|
||||
modalStyle,
|
||||
backdropStyle,
|
||||
parentModalRef,
|
||||
};
|
||||
}
|
||||
|
||||
export default useStackedModal;
|
||||
21
webapp/platform/components/src/index.tsx
Normal file
21
webapp/platform/components/src/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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 PunchOutCoordsHeightAndWidth} from './common/hooks/useMeasurePunchouts';
|
||||
|
||||
// components
|
||||
export {GenericModal} from './generic_modal/generic_modal';
|
||||
export {FooterPagination} from './footer_pagination/footer_pagination';
|
||||
export {CircleSkeletonLoader, RectangleSkeletonLoader} from './skeleton_loader';
|
||||
export {TourTip} from './tour_tip/tour_tip';
|
||||
export {TourTipBackdrop} from './tour_tip/tour_tip_backdrop';
|
||||
export {PulsatingDot} from './pulsating_dot';
|
||||
|
||||
// hooks
|
||||
export {useElementAvailable} from './common/hooks/useElementAvailable';
|
||||
export {useFocusTrap} from './hooks/useFocusTrap';
|
||||
export {useFollowElementDimensions} from './common/hooks/useFollowElementDimensions';
|
||||
export {useMeasurePunchouts} from './common/hooks/useMeasurePunchouts';
|
||||
43
webapp/platform/components/src/pulsating_dot/index.tsx
Normal file
43
webapp/platform/components/src/pulsating_dot/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Coords} from '../common/hooks/useMeasurePunchouts';
|
||||
|
||||
import './pulsating_dot.scss';
|
||||
|
||||
type Props = {
|
||||
targetRef?: React.RefObject<HTMLImageElement | null>;
|
||||
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,71 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse1 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 0;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse2 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
81
webapp/platform/components/src/skeleton_loader/index.tsx
Normal file
81
webapp/platform/components/src/skeleton_loader/index.tsx
Normal file
@@ -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;
|
||||
}
|
||||
14
webapp/platform/components/src/testUtils.tsx
Normal file
14
webapp/platform/components/src/testUtils.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import React from 'react';
|
||||
import {IntlProvider, createIntl} from 'react-intl';
|
||||
|
||||
export const defaultIntl = createIntl({
|
||||
locale: 'en',
|
||||
defaultLocale: 'en',
|
||||
messages: {},
|
||||
});
|
||||
|
||||
export const wrapIntl = (children?: ReactNode) => <IntlProvider {...defaultIntl}>{children}</IntlProvider>;
|
||||
5
webapp/platform/components/src/tour_tip/index.ts
Normal file
5
webapp/platform/components/src/tour_tip/index.ts
Normal file
@@ -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';
|
||||
389
webapp/platform/components/src/tour_tip/tour_tip.scss
Normal file
389
webapp/platform/components/src/tour_tip/tour_tip.scss
Normal file
@@ -0,0 +1,389 @@
|
||||
$z-index-tour-tips-backdrop: 1250;
|
||||
|
||||
.tour-tip {
|
||||
display: flex;
|
||||
|
||||
&__box {
|
||||
&.tippy-box {
|
||||
padding: 18px 24px 24px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
background: var(--button-bg);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--button-color);
|
||||
filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.12));
|
||||
|
||||
a {
|
||||
color: rgba(var(--button-color-rgb), 0.8);
|
||||
|
||||
&:hover {
|
||||
color: var(--button-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
color: var(--center-channel-bg);
|
||||
}
|
||||
|
||||
.tippy-arrow::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
background: var(--button-bg);
|
||||
color: var(--button-color);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
button[class^='StyledChip-'],
|
||||
button[class*='StyledChip-'] {
|
||||
color: var(--button-color);
|
||||
background-color: rgba(var(--button-color-rgb), 0.08);
|
||||
box-shadow: none;
|
||||
margin: 0 8px 8px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--button-color-rgb), 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
&.btn-primary {
|
||||
background: var(--button-color);
|
||||
color: var(--button-bg) !important;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
rgba(var(--button-bg-rgb), 0.08) 0%,
|
||||
rgba(var(--button-bg-rgb), 0.08) 100%),
|
||||
var(--button-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
rgba(var(--button-bg-rgb), 0.16) 0%,
|
||||
rgba(var(--button-bg-rgb), 0.16) 100%),
|
||||
var(--button-color);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-tertiary {
|
||||
background: rgba(var(--button-color-rgb), 0.08);
|
||||
color: var(--button-color);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-color-rgb), 0.12);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(var(--button-color-rgb), 0.16);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-icon {
|
||||
margin-right: -8px;
|
||||
margin-left: 1.2rem;
|
||||
color: rgba(var(--button-color-rgb), 0.64);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-color-rgb), 0.08);
|
||||
color: rgba(var(--button-color-rgb), 0.8);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--button-color-rgb), 0.16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
.Tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
border-radius: var(--radius-s);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 136px;
|
||||
border-radius: var(--radius-s);
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-ctr {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__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: var(--radius-full);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot-ring-active {
|
||||
background: rgba(var(--button-color-rgb), 0.16);
|
||||
}
|
||||
|
||||
&__dot {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgba(var(--button-color-rgb), 0.32);
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--button-color-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: $z-index-tour-tips-backdrop;
|
||||
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;
|
||||
}
|
||||
253
webapp/platform/components/src/tour_tip/tour_tip.tsx
Normal file
253
webapp/platform/components/src/tour_tip/tour_tip.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Tippy from '@tippyjs/react';
|
||||
import classNames from 'classnames';
|
||||
import React, {useRef, type JSX} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import type {Placement} from 'tippy.js';
|
||||
|
||||
import {Button} from '@mattermost/shared/components/button';
|
||||
|
||||
import {TourTipBackdrop} from './tour_tip_backdrop';
|
||||
|
||||
import type {Props as PunchOutCoordsHeightAndWidth} from '../common/hooks/useMeasurePunchouts';
|
||||
import {PulsatingDot} from '../pulsating_dot';
|
||||
|
||||
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 './tour_tip.scss';
|
||||
|
||||
export type TourTipEventSource = 'next' | 'prev' | 'dismiss' | 'jump' | 'skipped' | 'open' | 'punchOut';
|
||||
|
||||
// If this needs to alter, change in _variables $z-index-tour-tips-popover as well
|
||||
const DEFAULT_Z_INDEX_TOUR_TIPS_POPOVER = 1300;
|
||||
|
||||
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 = DEFAULT_Z_INDEX_TOUR_TIPS_POPOVER,
|
||||
hideBackdrop = false,
|
||||
tippyBlueStyle = false,
|
||||
}: Props) => {
|
||||
const FIRST_STEP_INDEX = 0;
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const onJump = (event: React.MouseEvent, jumpToStep: number) => {
|
||||
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='btn btn-sm btn-icon'
|
||||
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'
|
||||
emphasis='tertiary'
|
||||
size='sm'
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
{prevBtn}
|
||||
</Button>
|
||||
)}
|
||||
{nextBtn && (
|
||||
<Button
|
||||
id='tipNextButton'
|
||||
emphasis='primary'
|
||||
size='sm'
|
||||
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 as React.RefObject<Element>}
|
||||
interactive={true}
|
||||
appendTo={rootPortal!}
|
||||
offset={offset}
|
||||
className={classNames(
|
||||
'tour-tip__box',
|
||||
className,
|
||||
{'tippy-blue-style': tippyBlueStyle},
|
||||
)}
|
||||
placement={placement}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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 type {Props as 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>
|
||||
);
|
||||
};
|
||||
22
webapp/platform/components/tsconfig.json
Normal file
22
webapp/platform/components/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"types": ["node", "@testing-library/jest-dom"],
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user