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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View 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`.

View 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
```

View 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;

View 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,
];

View 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;

View 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"
}
}

View 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,
},
},
];

View 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';

View File

@@ -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]);
}

View File

@@ -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]);
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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],
}
`;

View File

@@ -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;
}
}
}
}

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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],
}
`;

View 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%;
}
}

View File

@@ -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');
});
});
});
});

View 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;

View 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();
}
});
});

View 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;
}

View 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');
}
});
});

View 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;

View 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';

View 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'}
/>
);
}
}

View File

@@ -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);
}
}

View 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;
}

View 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>;

View 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';

View 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;
}

View 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}
/>
)}
</>
);
};

View File

@@ -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>
);
};

View 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/**/*"
]
}