@linagora/twake-mui 10.3.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +55 -0
- package/dist/components/SpaceOverlay/OverlayFrame.d.ts +14 -0
- package/dist/components/SpaceOverlay/OverlayFrame.js +24 -0
- package/dist/components/SpaceOverlay/SpaceOverlay.d.ts +28 -0
- package/dist/components/SpaceOverlay/SpaceOverlay.js +57 -0
- package/dist/components/SpaceOverlay/index.d.ts +8 -0
- package/dist/components/SpaceOverlay/index.js +4 -0
- package/dist/components/SpaceOverlay/region.d.ts +25 -0
- package/dist/components/SpaceOverlay/region.js +63 -0
- package/dist/components/SpaceOverlay/spaceOverlay.d.ts +42 -0
- package/dist/components/SpaceOverlay/spaceOverlay.js +312 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/overrides.js +1 -4
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# @linagora/twake-mui [10.4.0](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@10.3.0...@linagora/twake-mui@10.4.0) (2026-10-07)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **twake-mui:** Add the TwakeSpace overlay for embedded apps ([97a8b50](https://github.com/linagora/twake-ui/commit/97a8b506c0c485fffaa71805e4c32c77bd6ff30d))
|
|
7
|
+
|
|
1
8
|
# @linagora/twake-mui [10.3.0](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@10.2.0...@linagora/twake-mui@10.3.0) (2026-10-06)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -54,6 +54,61 @@ function MyComponent() {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### Apps embedded in TwakeSpace
|
|
58
|
+
|
|
59
|
+
TwakeSpace shows an app of a space's tab in a frame named `twake-embed-<app>`.
|
|
60
|
+
Next to it, it puts an overlay over its whole page: a second frame, named
|
|
61
|
+
`twake-embed-<app>:overlay`, on `<app origin>/embed/overlay.html`. The app
|
|
62
|
+
renders its dialogs, drawers and docked windows into the overlay, so they sit
|
|
63
|
+
on TwakeSpace's page instead of inside the app's frame, with no change to its
|
|
64
|
+
`Dialog` or `Drawer` calls.
|
|
65
|
+
|
|
66
|
+
The contract:
|
|
67
|
+
|
|
68
|
+
- The app serves `/embed/overlay.html`: an empty page, no script, with
|
|
69
|
+
`html, body { background: transparent; color-scheme: normal }`.
|
|
70
|
+
- The app reports the region it draws in with `overlayRegionMessage(region)`,
|
|
71
|
+
posted to TwakeSpace: `'full'` while something blocks the page (a dialog, a
|
|
72
|
+
menu), else the boxes it draws. TwakeSpace shows that region only, and the
|
|
73
|
+
rest of its page keeps its clicks.
|
|
74
|
+
|
|
75
|
+
In the app:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import {
|
|
79
|
+
connectSpaceOverlay,
|
|
80
|
+
overlayRegionMessage,
|
|
81
|
+
overlayThemeOptions,
|
|
82
|
+
OverlayPortal,
|
|
83
|
+
SpaceOverlayProvider,
|
|
84
|
+
TwakeMuiThemeProvider
|
|
85
|
+
} from '@linagora/twake-mui'
|
|
86
|
+
|
|
87
|
+
// Null outside TwakeSpace: everything renders in place
|
|
88
|
+
const overlay = connectSpaceOverlay(region => {
|
|
89
|
+
window.parent.postMessage(overlayRegionMessage(region), SPACE_ORIGIN)
|
|
90
|
+
})
|
|
91
|
+
const themeOptions = overlay ? overlayThemeOptions(overlay) : {}
|
|
92
|
+
|
|
93
|
+
function App() {
|
|
94
|
+
return (
|
|
95
|
+
<TwakeMuiThemeProvider themeOptions={themeOptions}>
|
|
96
|
+
<SpaceOverlayProvider overlay={overlay}>
|
|
97
|
+
{/* Dialogs and drawers go to the overlay through the theme */}
|
|
98
|
+
<OverlayPortal>{/* a docked window, such as a composer */}</OverlayPortal>
|
|
99
|
+
</SpaceOverlayProvider>
|
|
100
|
+
</TwakeMuiThemeProvider>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
What renders on the overlay lives in another document: use `ownerDocument`
|
|
106
|
+
rather than `document`, and `nodeType` rather than `instanceof`.
|
|
107
|
+
|
|
108
|
+
In TwakeSpace, `OverlayFrame` is the overlay: it takes the region from
|
|
109
|
+
`parseOverlayRegionMessage`, checked to come from the app's frame, through
|
|
110
|
+
`overlayClipPath`.
|
|
111
|
+
|
|
57
112
|
## Features
|
|
58
113
|
|
|
59
114
|
- **Palette System**: Techno-independent color definitions
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type FC } from 'react';
|
|
2
|
+
export interface OverlayFrameProps {
|
|
3
|
+
/** `<name of the app's frame>:overlay`, the name the app looks for */
|
|
4
|
+
name: string;
|
|
5
|
+
/** `/embed/overlay.html` on the app's origin */
|
|
6
|
+
src: string;
|
|
7
|
+
title: string;
|
|
8
|
+
/** The same as the app's frame */
|
|
9
|
+
sandbox: string;
|
|
10
|
+
allow: string;
|
|
11
|
+
/** From `overlayClipPath` */
|
|
12
|
+
clipPath: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const OverlayFrame: FC<OverlayFrameProps>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Box } from '@mui/material';
|
|
2
|
+
import React, { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
export const OverlayFrame = ({ name, src, title, sandbox, allow, clipPath }) => {
|
|
5
|
+
const ref = useRef(null);
|
|
6
|
+
const empty = clipPath.startsWith('inset(');
|
|
7
|
+
// Set by hand: it changes with every move of a menu, a class per value
|
|
8
|
+
// would pile up.
|
|
9
|
+
useLayoutEffect(() => {
|
|
10
|
+
if (ref.current)
|
|
11
|
+
ref.current.style.clipPath = clipPath;
|
|
12
|
+
}, [clipPath, ref]);
|
|
13
|
+
return createPortal(React.createElement(Box, { component: "iframe", ref: ref, name: name, src: src, title: title, sandbox: sandbox, allow: allow, "aria-hidden": empty || undefined, tabIndex: empty ? -1 : undefined, sx: {
|
|
14
|
+
position: 'fixed',
|
|
15
|
+
inset: 0,
|
|
16
|
+
width: '100%',
|
|
17
|
+
height: '100%',
|
|
18
|
+
border: 0,
|
|
19
|
+
background: 'transparent',
|
|
20
|
+
// A frame whose colour scheme differs from the page is painted opaque
|
|
21
|
+
colorScheme: 'normal',
|
|
22
|
+
zIndex: theme => theme.zIndex.modal - 1
|
|
23
|
+
} }), document.body);
|
|
24
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ThemeOptions } from '@mui/material/styles';
|
|
2
|
+
import { type FC, type ReactNode } from 'react';
|
|
3
|
+
import type { SpaceOverlay } from './spaceOverlay';
|
|
4
|
+
export interface SpaceOverlayProviderProps {
|
|
5
|
+
/** From `connectSpaceOverlay`, null outside TwakeSpace */
|
|
6
|
+
overlay: SpaceOverlay | null;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
export declare const SpaceOverlayProvider: FC<SpaceOverlayProviderProps>;
|
|
10
|
+
/**
|
|
11
|
+
* The window of the overlay of TwakeSpace once connected, else null: what
|
|
12
|
+
* renders there is laid out on its size, not on this frame's
|
|
13
|
+
*/
|
|
14
|
+
export declare function useOverlayWindow(): Window | null;
|
|
15
|
+
/**
|
|
16
|
+
* Renders its children on the overlay of TwakeSpace when the app is framed
|
|
17
|
+
* there, in place otherwise. Menus and tooltips opened inside follow: MUI
|
|
18
|
+
* portals them into the document of their anchor.
|
|
19
|
+
*/
|
|
20
|
+
export declare const OverlayPortal: FC<{
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Theme options sending every dialog and temporary drawer to the overlay, so
|
|
25
|
+
* they are centred on the window of TwakeSpace and dim all of it. Built once:
|
|
26
|
+
* the theme provider rebuilds the theme when they change.
|
|
27
|
+
*/
|
|
28
|
+
export declare function overlayThemeOptions(overlay: SpaceOverlay): ThemeOptions;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { createContext, useContext, useSyncExternalStore } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
const SpaceOverlayContext = createContext(null);
|
|
4
|
+
export const SpaceOverlayProvider = ({ overlay, children }) => (React.createElement(SpaceOverlayContext.Provider, { value: overlay }, children));
|
|
5
|
+
const NO_OVERLAY = () => () => undefined;
|
|
6
|
+
function useOverlayStatus() {
|
|
7
|
+
const overlay = useContext(SpaceOverlayContext);
|
|
8
|
+
return useSyncExternalStore(overlay?.subscribe ?? NO_OVERLAY, () => overlay?.getStatus() ?? null);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The window of the overlay of TwakeSpace once connected, else null: what
|
|
12
|
+
* renders there is laid out on its size, not on this frame's
|
|
13
|
+
*/
|
|
14
|
+
export function useOverlayWindow() {
|
|
15
|
+
const overlay = useContext(SpaceOverlayContext);
|
|
16
|
+
const status = useOverlayStatus();
|
|
17
|
+
return status === 'connected'
|
|
18
|
+
? (overlay?.getBody()?.ownerDocument.defaultView ?? null)
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Renders its children on the overlay of TwakeSpace when the app is framed
|
|
23
|
+
* there, in place otherwise. Menus and tooltips opened inside follow: MUI
|
|
24
|
+
* portals them into the document of their anchor.
|
|
25
|
+
*/
|
|
26
|
+
export const OverlayPortal = ({ children }) => {
|
|
27
|
+
const overlay = useContext(SpaceOverlayContext);
|
|
28
|
+
const status = useOverlayStatus();
|
|
29
|
+
// Shown in place first, then moved, it would mount twice: a window being
|
|
30
|
+
// written in would lose what it holds
|
|
31
|
+
if (status === 'connecting')
|
|
32
|
+
return null;
|
|
33
|
+
const body = status === 'connected' ? (overlay?.getBody() ?? null) : null;
|
|
34
|
+
return React.createElement(React.Fragment, null, body === null ? children : createPortal(children, body));
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Theme options sending every dialog and temporary drawer to the overlay, so
|
|
38
|
+
* they are centred on the window of TwakeSpace and dim all of it. Built once:
|
|
39
|
+
* the theme provider rebuilds the theme when they change.
|
|
40
|
+
*/
|
|
41
|
+
export function overlayThemeOptions(overlay) {
|
|
42
|
+
// Null before TwakeSpace answers: MUI then portals into the app's own body
|
|
43
|
+
const container = () => overlay.getBody();
|
|
44
|
+
return {
|
|
45
|
+
components: {
|
|
46
|
+
MuiDialog: { defaultProps: { container } },
|
|
47
|
+
// Its backdrop dims all of TwakeSpace: shown at once with its panel,
|
|
48
|
+
// not faded in over the whole page
|
|
49
|
+
MuiDrawer: {
|
|
50
|
+
defaultProps: {
|
|
51
|
+
container,
|
|
52
|
+
slotProps: { backdrop: { transitionDuration: 0 } }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { connectSpaceOverlay, computeOverlayRegion } from './spaceOverlay';
|
|
2
|
+
export type { SpaceOverlay, SpaceOverlayStatus } from './spaceOverlay';
|
|
3
|
+
export { OverlayPortal, SpaceOverlayProvider, overlayThemeOptions, useOverlayWindow } from './SpaceOverlay';
|
|
4
|
+
export type { SpaceOverlayProviderProps } from './SpaceOverlay';
|
|
5
|
+
export { overlayClipPath, overlayRegionMessage, parseOverlayRegion, parseOverlayRegionMessage } from './region';
|
|
6
|
+
export type { OverlayBox, OverlayRegion } from './region';
|
|
7
|
+
export { OverlayFrame } from './OverlayFrame';
|
|
8
|
+
export type { OverlayFrameProps } from './OverlayFrame';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { connectSpaceOverlay, computeOverlayRegion } from './spaceOverlay';
|
|
2
|
+
export { OverlayPortal, SpaceOverlayProvider, overlayThemeOptions, useOverlayWindow } from './SpaceOverlay';
|
|
3
|
+
export { overlayClipPath, overlayRegionMessage, parseOverlayRegion, parseOverlayRegionMessage } from './region';
|
|
4
|
+
export { OverlayFrame } from './OverlayFrame';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A box the app draws on the overlay, in CSS px of the window */
|
|
2
|
+
export interface OverlayBox {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The part of the overlay TwakeSpace shows: `'full'` while something there
|
|
10
|
+
* blocks the page (a dialog, a menu, a fullscreen window), otherwise the
|
|
11
|
+
* boxes the app draws
|
|
12
|
+
*/
|
|
13
|
+
export type OverlayRegion = 'full' | readonly OverlayBox[];
|
|
14
|
+
declare const OVERLAY_REGION = "twake-embed:overlay-region";
|
|
15
|
+
export declare function parseOverlayRegion(value: unknown): OverlayRegion | null;
|
|
16
|
+
/** The message an app posts to TwakeSpace with the region of its overlay */
|
|
17
|
+
export declare function overlayRegionMessage(region: OverlayRegion): {
|
|
18
|
+
type: typeof OVERLAY_REGION;
|
|
19
|
+
region: OverlayRegion;
|
|
20
|
+
};
|
|
21
|
+
/** The region an app reports, from `overlayRegionMessage` */
|
|
22
|
+
export declare function parseOverlayRegionMessage(data: unknown): OverlayRegion | null;
|
|
23
|
+
/** The clip path of the overlay: nothing until the app says what it draws */
|
|
24
|
+
export declare function overlayClipPath(region: OverlayRegion | null): string;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The region of the overlay an embedded app draws on, over the whole page of
|
|
2
|
+
// TwakeSpace. It is a frame on the app's origin: the app renders its docked
|
|
3
|
+
// windows and dialogs into it, and TwakeSpace only shows the region the app
|
|
4
|
+
// says it draws in (`twake-embed:overlay-region`), so the rest of the page
|
|
5
|
+
// keeps its clicks.
|
|
6
|
+
const OVERLAY_REGION = 'twake-embed:overlay-region';
|
|
7
|
+
const MAX_BOXES = 32;
|
|
8
|
+
const MAX_SIZE = 100000;
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null;
|
|
11
|
+
}
|
|
12
|
+
function isCoordinate(value) {
|
|
13
|
+
return (typeof value === 'number' &&
|
|
14
|
+
Number.isFinite(value) &&
|
|
15
|
+
Math.abs(value) <= MAX_SIZE);
|
|
16
|
+
}
|
|
17
|
+
function toBox(value) {
|
|
18
|
+
if (!isRecord(value))
|
|
19
|
+
return null;
|
|
20
|
+
const { x, y, width, height } = value;
|
|
21
|
+
if (!isCoordinate(x) ||
|
|
22
|
+
!isCoordinate(y) ||
|
|
23
|
+
!isCoordinate(width) ||
|
|
24
|
+
!isCoordinate(height)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (width < 0 || height < 0)
|
|
28
|
+
return null;
|
|
29
|
+
return { x, y, width, height };
|
|
30
|
+
}
|
|
31
|
+
export function parseOverlayRegion(value) {
|
|
32
|
+
if (value === 'full')
|
|
33
|
+
return 'full';
|
|
34
|
+
if (!Array.isArray(value) || value.length > MAX_BOXES)
|
|
35
|
+
return null;
|
|
36
|
+
const boxes = [];
|
|
37
|
+
for (const item of value) {
|
|
38
|
+
const box = toBox(item);
|
|
39
|
+
if (box === null)
|
|
40
|
+
return null;
|
|
41
|
+
boxes.push(box);
|
|
42
|
+
}
|
|
43
|
+
return boxes;
|
|
44
|
+
}
|
|
45
|
+
/** The message an app posts to TwakeSpace with the region of its overlay */
|
|
46
|
+
export function overlayRegionMessage(region) {
|
|
47
|
+
return { type: OVERLAY_REGION, region };
|
|
48
|
+
}
|
|
49
|
+
/** The region an app reports, from `overlayRegionMessage` */
|
|
50
|
+
export function parseOverlayRegionMessage(data) {
|
|
51
|
+
if (!isRecord(data) || data.type !== OVERLAY_REGION)
|
|
52
|
+
return null;
|
|
53
|
+
return parseOverlayRegion(data.region);
|
|
54
|
+
}
|
|
55
|
+
/** The clip path of the overlay: nothing until the app says what it draws */
|
|
56
|
+
export function overlayClipPath(region) {
|
|
57
|
+
if (region === 'full')
|
|
58
|
+
return 'none';
|
|
59
|
+
if (region === null || region.length === 0)
|
|
60
|
+
return 'inset(0 0 100% 0)';
|
|
61
|
+
const boxes = region.map(({ x, y, width, height }) => ['M', x, ' ', y, 'h', width, 'v', height, 'h', -width, 'Z'].join(''));
|
|
62
|
+
return `path('${boxes.join(' ')}')`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OverlayRegion } from './region';
|
|
2
|
+
/**
|
|
3
|
+
* Where the overlay stands: looked for (render nothing yet, so that what
|
|
4
|
+
* would render in place does not mount twice), there, or not coming (render
|
|
5
|
+
* in place)
|
|
6
|
+
*/
|
|
7
|
+
export type SpaceOverlayStatus = 'connecting' | 'connected' | 'unavailable';
|
|
8
|
+
/**
|
|
9
|
+
* The overlay TwakeSpace frames over its whole window, on this app's origin:
|
|
10
|
+
* docked windows and dialogs render into its body with portals, so they sit
|
|
11
|
+
* on the page of TwakeSpace instead of inside the app's frame
|
|
12
|
+
*/
|
|
13
|
+
export interface SpaceOverlay {
|
|
14
|
+
getStatus: () => SpaceOverlayStatus;
|
|
15
|
+
/** The body to render into, null unless connected */
|
|
16
|
+
getBody: () => HTMLElement | null;
|
|
17
|
+
/** Called when the status changes */
|
|
18
|
+
subscribe: (listener: () => void) => () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The overlay of this frame, found by name: TwakeSpace names it after the
|
|
22
|
+
* frame (`<frame name>:overlay`). Null outside a frame or in a frame without
|
|
23
|
+
* a name, where windows and dialogs stay in the app. `reportRegion` tells
|
|
24
|
+
* TwakeSpace the region of the overlay to show.
|
|
25
|
+
*
|
|
26
|
+
* Connected once per window: the app's styles are copied by wrapping the
|
|
27
|
+
* stylesheet methods of its window, so a later call returns the first
|
|
28
|
+
* connection, and its `reportRegion` is ignored.
|
|
29
|
+
*/
|
|
30
|
+
export declare function connectSpaceOverlay(reportRegion: (region: OverlayRegion) => void): SpaceOverlay | null;
|
|
31
|
+
/**
|
|
32
|
+
* Copies every CSS rule of the app into the overlay, and again whenever the
|
|
33
|
+
* app adds some: Emotion and its global styles insert rules one by one
|
|
34
|
+
* (`insertRule`), other styles arrive as elements of the head
|
|
35
|
+
*/
|
|
36
|
+
export declare function mirrorStyles(source: Document, target: Document): void;
|
|
37
|
+
/**
|
|
38
|
+
* What the overlay shows: everything while a modal (dialog, menu) or a
|
|
39
|
+
* backdrop is open, otherwise the boxes that paint something. A box that
|
|
40
|
+
* neither takes clicks nor paints (the dock line) is looked through.
|
|
41
|
+
*/
|
|
42
|
+
export declare function computeOverlayRegion(doc: Document): OverlayRegion;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The overlay TwakeSpace puts over its page for an embedded app (ADR 010 of
|
|
2
|
+
// twake-space-architecture), from the side of the app.
|
|
3
|
+
/** Room left around each box for its shadow, in px */
|
|
4
|
+
const SHADOW_MARGIN = 16;
|
|
5
|
+
/**
|
|
6
|
+
* How long a frame named by TwakeSpace waits for its overlay before showing
|
|
7
|
+
* its windows in place: the overlay loads in well under a second, and a
|
|
8
|
+
* TwakeSpace without overlay should not hold a composer back longer
|
|
9
|
+
*/
|
|
10
|
+
const CONNECT_TIMEOUT_MS = 5000;
|
|
11
|
+
/** How often the overlay is looked for while TwakeSpace loads it */
|
|
12
|
+
const LOOKUP_INTERVAL_MS = 100;
|
|
13
|
+
/** Changes of the overlay sent together, about one frame */
|
|
14
|
+
const REGION_DELAY_MS = 16;
|
|
15
|
+
const FOCUS_RETRY_MS = 50;
|
|
16
|
+
/** The overlay of this window, once looked for */
|
|
17
|
+
let connection = null;
|
|
18
|
+
/**
|
|
19
|
+
* The overlay of this frame, found by name: TwakeSpace names it after the
|
|
20
|
+
* frame (`<frame name>:overlay`). Null outside a frame or in a frame without
|
|
21
|
+
* a name, where windows and dialogs stay in the app. `reportRegion` tells
|
|
22
|
+
* TwakeSpace the region of the overlay to show.
|
|
23
|
+
*
|
|
24
|
+
* Connected once per window: the app's styles are copied by wrapping the
|
|
25
|
+
* stylesheet methods of its window, so a later call returns the first
|
|
26
|
+
* connection, and its `reportRegion` is ignored.
|
|
27
|
+
*/
|
|
28
|
+
export function connectSpaceOverlay(reportRegion) {
|
|
29
|
+
connection ?? (connection = { overlay: connect(reportRegion) });
|
|
30
|
+
return connection.overlay;
|
|
31
|
+
}
|
|
32
|
+
function connect(reportRegion) {
|
|
33
|
+
if (window.parent === window || window.name === '')
|
|
34
|
+
return null;
|
|
35
|
+
const name = `${window.name}:overlay`;
|
|
36
|
+
const listeners = new Set();
|
|
37
|
+
let status = 'connecting';
|
|
38
|
+
let body = null;
|
|
39
|
+
const settle = (next) => {
|
|
40
|
+
if (status !== 'connecting')
|
|
41
|
+
return;
|
|
42
|
+
status = next;
|
|
43
|
+
listeners.forEach(listener => {
|
|
44
|
+
listener();
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const attach = (doc) => {
|
|
48
|
+
mirrorStyles(document, doc);
|
|
49
|
+
followRegion(doc, reportRegion, followFocus(document));
|
|
50
|
+
body = doc.body;
|
|
51
|
+
settle('connected');
|
|
52
|
+
};
|
|
53
|
+
const giveUp = setTimeout(() => {
|
|
54
|
+
settle('unavailable');
|
|
55
|
+
}, CONNECT_TIMEOUT_MS);
|
|
56
|
+
const find = () => {
|
|
57
|
+
if (status !== 'connecting')
|
|
58
|
+
return;
|
|
59
|
+
const overlay = findOverlay(name);
|
|
60
|
+
if (overlay !== null) {
|
|
61
|
+
clearTimeout(giveUp);
|
|
62
|
+
attach(overlay);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setTimeout(find, LOOKUP_INTERVAL_MS);
|
|
66
|
+
};
|
|
67
|
+
find();
|
|
68
|
+
return {
|
|
69
|
+
getStatus: () => status,
|
|
70
|
+
getBody: () => body,
|
|
71
|
+
subscribe: listener => {
|
|
72
|
+
listeners.add(listener);
|
|
73
|
+
return () => listeners.delete(listener);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The element of the app that last had the focus: a dialog opened from it
|
|
79
|
+
* shows on the overlay, and closing it cannot give the focus back across
|
|
80
|
+
* frames (the focus event carries no `relatedTarget` there)
|
|
81
|
+
*/
|
|
82
|
+
function followFocus(app) {
|
|
83
|
+
let last = null;
|
|
84
|
+
app.addEventListener('focusin', event => {
|
|
85
|
+
if (event.target !== app.body)
|
|
86
|
+
last = event.target;
|
|
87
|
+
});
|
|
88
|
+
return () => (last?.isConnected ? last : null);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The document of the overlay once loaded: until then the frame holds the
|
|
92
|
+
* blank page of TwakeSpace, of another origin
|
|
93
|
+
*/
|
|
94
|
+
function findOverlay(name) {
|
|
95
|
+
const frames = Array.from({ length: window.parent.length }, (_, index) => window.parent[index]);
|
|
96
|
+
for (const frame of frames) {
|
|
97
|
+
try {
|
|
98
|
+
if (frame?.name === name &&
|
|
99
|
+
frame.location.href !== 'about:blank' &&
|
|
100
|
+
frame.document.readyState === 'complete') {
|
|
101
|
+
return frame.document;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Another origin
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Copies every CSS rule of the app into the overlay, and again whenever the
|
|
112
|
+
* app adds some: Emotion and its global styles insert rules one by one
|
|
113
|
+
* (`insertRule`), other styles arrive as elements of the head
|
|
114
|
+
*/
|
|
115
|
+
export function mirrorStyles(source, target) {
|
|
116
|
+
const sourceWindow = source.defaultView;
|
|
117
|
+
if (sourceWindow === null)
|
|
118
|
+
return;
|
|
119
|
+
const mirror = target.createElement('style');
|
|
120
|
+
mirror.dataset.mirror = '';
|
|
121
|
+
target.head.appendChild(mirror);
|
|
122
|
+
const linked = new Set();
|
|
123
|
+
const copy = () => {
|
|
124
|
+
const css = [];
|
|
125
|
+
for (const sheet of Array.from(source.styleSheets)) {
|
|
126
|
+
// A stylesheet file is loaded again: its relative URLs (fonts) resolve
|
|
127
|
+
// against it, and the rules of another origin cannot be read
|
|
128
|
+
if (sheet.href !== null) {
|
|
129
|
+
if (!linked.has(sheet.href)) {
|
|
130
|
+
linked.add(sheet.href);
|
|
131
|
+
const link = target.createElement('link');
|
|
132
|
+
link.rel = 'stylesheet';
|
|
133
|
+
link.href = sheet.href;
|
|
134
|
+
target.head.insertBefore(link, mirror);
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
for (const rule of Array.from(sheet.cssRules))
|
|
140
|
+
css.push(rule.cssText);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Unreadable: nothing to copy
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
mirror.textContent = css.join('\n');
|
|
147
|
+
copyRootAttributes(source.documentElement, target.documentElement);
|
|
148
|
+
};
|
|
149
|
+
let scheduled = false;
|
|
150
|
+
const schedule = () => {
|
|
151
|
+
if (scheduled)
|
|
152
|
+
return;
|
|
153
|
+
scheduled = true;
|
|
154
|
+
sourceWindow.requestAnimationFrame(() => {
|
|
155
|
+
scheduled = false;
|
|
156
|
+
copy();
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
// A rule inserted in the app is in the overlay at once, before what it
|
|
160
|
+
// styles is laid out: a popover measures its paper as it mounts, and a
|
|
161
|
+
// paper without its rules spans the whole window. The copy that follows
|
|
162
|
+
// puts the rule back in its place.
|
|
163
|
+
const copyRule = (sheet, rule) => {
|
|
164
|
+
const copied = mirror.sheet;
|
|
165
|
+
// A stylesheet file is loaded again instead
|
|
166
|
+
if (Boolean(sheet.href) || copied === null || sheet === copied)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
copied.insertRule(rule, copied.cssRules.length);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Not allowed at the end of a sheet (`@import`): left to the copy
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
// Emotion inserts rules without touching the DOM: watch the CSSOM itself
|
|
176
|
+
const prototype = sourceWindow.CSSStyleSheet.prototype;
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- wrapped, called with its sheet
|
|
178
|
+
const insertRule = prototype.insertRule;
|
|
179
|
+
prototype.insertRule = function (...args) {
|
|
180
|
+
const index = insertRule.apply(this, args);
|
|
181
|
+
copyRule(this, args[0]);
|
|
182
|
+
schedule();
|
|
183
|
+
return index;
|
|
184
|
+
};
|
|
185
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- wrapped, called with its sheet
|
|
186
|
+
const deleteRule = prototype.deleteRule;
|
|
187
|
+
prototype.deleteRule = function (...args) {
|
|
188
|
+
schedule();
|
|
189
|
+
deleteRule.apply(this, args);
|
|
190
|
+
};
|
|
191
|
+
new MutationObserver(schedule).observe(source.head, {
|
|
192
|
+
childList: true,
|
|
193
|
+
subtree: true,
|
|
194
|
+
characterData: true
|
|
195
|
+
});
|
|
196
|
+
// The colour scheme and the language live on <html>
|
|
197
|
+
new MutationObserver(schedule).observe(source.documentElement, {
|
|
198
|
+
attributes: true
|
|
199
|
+
});
|
|
200
|
+
copy();
|
|
201
|
+
}
|
|
202
|
+
function copyRootAttributes(source, target) {
|
|
203
|
+
for (const { name, value } of Array.from(source.attributes)) {
|
|
204
|
+
if (name !== 'style' && target.getAttribute(name) !== value) {
|
|
205
|
+
target.setAttribute(name, value);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Gives the focus back to an element of the app, again for a moment: while
|
|
211
|
+
* the dialog was open TwakeSpace made its page inert, the app's frame
|
|
212
|
+
* included, and takes that back only once it reads the new region
|
|
213
|
+
*/
|
|
214
|
+
function restoreFocus(target, attempts = 10) {
|
|
215
|
+
target.focus();
|
|
216
|
+
if (target.ownerDocument.activeElement === target && document.hasFocus()) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (attempts > 1) {
|
|
220
|
+
setTimeout(() => {
|
|
221
|
+
restoreFocus(target, attempts - 1);
|
|
222
|
+
}, FOCUS_RETRY_MS);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Sends TwakeSpace the region of the overlay to show, each time it changes.
|
|
227
|
+
* When a dialog closes and leaves the focus nowhere on the overlay, it goes
|
|
228
|
+
* back to the element of the app that had it.
|
|
229
|
+
*/
|
|
230
|
+
function followRegion(doc, reportRegion, lastAppFocus) {
|
|
231
|
+
const view = doc.defaultView;
|
|
232
|
+
if (view === null)
|
|
233
|
+
return;
|
|
234
|
+
let last = '';
|
|
235
|
+
let scheduled = false;
|
|
236
|
+
const send = () => {
|
|
237
|
+
scheduled = false;
|
|
238
|
+
const region = computeOverlayRegion(doc);
|
|
239
|
+
const key = JSON.stringify(region);
|
|
240
|
+
if (key === last)
|
|
241
|
+
return;
|
|
242
|
+
const wasBlocking = last === '"full"';
|
|
243
|
+
last = key;
|
|
244
|
+
reportRegion(region);
|
|
245
|
+
if (wasBlocking && region !== 'full' && doc.activeElement === doc.body) {
|
|
246
|
+
const target = lastAppFocus();
|
|
247
|
+
if (target !== null)
|
|
248
|
+
restoreFocus(target);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
// Not the overlay's animation frames: the browser does not run them while
|
|
252
|
+
// the overlay shows nothing, which it does until it reports a region
|
|
253
|
+
const schedule = () => {
|
|
254
|
+
if (scheduled)
|
|
255
|
+
return;
|
|
256
|
+
scheduled = true;
|
|
257
|
+
setTimeout(send, REGION_DELAY_MS);
|
|
258
|
+
};
|
|
259
|
+
new MutationObserver(schedule).observe(doc.body, {
|
|
260
|
+
childList: true,
|
|
261
|
+
subtree: true,
|
|
262
|
+
attributes: true
|
|
263
|
+
});
|
|
264
|
+
for (const type of ['transitionend', 'transitioncancel', 'animationend']) {
|
|
265
|
+
doc.addEventListener(type, schedule, true);
|
|
266
|
+
}
|
|
267
|
+
view.addEventListener('resize', schedule);
|
|
268
|
+
schedule();
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* What the overlay shows: everything while a modal (dialog, menu) or a
|
|
272
|
+
* backdrop is open, otherwise the boxes that paint something. A box that
|
|
273
|
+
* neither takes clicks nor paints (the dock line) is looked through.
|
|
274
|
+
*/
|
|
275
|
+
export function computeOverlayRegion(doc) {
|
|
276
|
+
const view = doc.defaultView;
|
|
277
|
+
if (view === null)
|
|
278
|
+
return [];
|
|
279
|
+
const blocking = Array.from(doc.body.querySelectorAll('.MuiModal-root, .MuiBackdrop-root')).some(element => element.closest('.MuiModal-hidden') === null &&
|
|
280
|
+
view.getComputedStyle(element).visibility !== 'hidden');
|
|
281
|
+
if (blocking)
|
|
282
|
+
return 'full';
|
|
283
|
+
const boxes = [];
|
|
284
|
+
const visit = (element) => {
|
|
285
|
+
const style = view.getComputedStyle(element);
|
|
286
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
287
|
+
return;
|
|
288
|
+
if (style.pointerEvents === 'none' && !paints(style)) {
|
|
289
|
+
Array.from(element.children).forEach(visit);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const box = element.getBoundingClientRect();
|
|
293
|
+
if (box.width > 0 && box.height > 0) {
|
|
294
|
+
boxes.push({
|
|
295
|
+
x: Math.floor(box.left - SHADOW_MARGIN),
|
|
296
|
+
y: Math.floor(box.top - SHADOW_MARGIN),
|
|
297
|
+
width: Math.ceil(box.width + 2 * SHADOW_MARGIN),
|
|
298
|
+
height: Math.ceil(box.height + 2 * SHADOW_MARGIN)
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
Array.from(doc.body.children).forEach(visit);
|
|
303
|
+
return boxes;
|
|
304
|
+
}
|
|
305
|
+
function paints(style) {
|
|
306
|
+
const transparent = (color) => color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
|
|
307
|
+
return (!transparent(style.backgroundColor) ||
|
|
308
|
+
(style.boxShadow !== 'none' && style.boxShadow !== '') ||
|
|
309
|
+
(style.borderStyle !== 'none' &&
|
|
310
|
+
style.borderStyle !== '' &&
|
|
311
|
+
parseFloat(style.borderWidth) > 0));
|
|
312
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { default as AccordionExpandIcon } from './components/AccordionExpandIcon
|
|
|
41
41
|
export { default as VirtualizedTable } from './components/VirtualizedTable';
|
|
42
42
|
export { VirtuosoMockContext } from 'react-virtuoso';
|
|
43
43
|
export { useBreakpoints } from './hooks/useBreakpoints';
|
|
44
|
+
export * from './components/SpaceOverlay';
|
|
44
45
|
export type { TwakeTheme } from './lib/theme';
|
|
45
46
|
export type { PaletteJson } from './lib/types';
|
|
46
47
|
export type { AvatarProps } from './components/Avatar';
|
package/dist/index.js
CHANGED
|
@@ -44,4 +44,5 @@ export { default as AccordionExpandIcon } from './components/AccordionExpandIcon
|
|
|
44
44
|
export { default as VirtualizedTable } from './components/VirtualizedTable';
|
|
45
45
|
export { VirtuosoMockContext } from 'react-virtuoso';
|
|
46
46
|
export { useBreakpoints } from './hooks/useBreakpoints';
|
|
47
|
+
export * from './components/SpaceOverlay';
|
|
47
48
|
export { ContactPopoverActions, ContactPopoverChatAction, ContactPopoverVideoAction, ContactPopoverCalendarAction, ContactPopoverEmailAction } from './components/ContactPopover';
|
package/dist/lib/overrides.js
CHANGED
|
@@ -45,10 +45,7 @@ const alertSeverityVariants = alertSeverities.flatMap(severity => [
|
|
|
45
45
|
backgroundColor: theme.alpha(theme.vars.palette[severity].main, 0.24)
|
|
46
46
|
}),
|
|
47
47
|
[`& .${alertClasses.icon}`]: { color: alertIconColor(severity, theme) },
|
|
48
|
-
//
|
|
49
|
-
// `closeText`, as the only child of the action slot and only when no
|
|
50
|
-
// custom `action` is given: matching that structure, not the title,
|
|
51
|
-
// keeps the style in every language.
|
|
48
|
+
// targets close button
|
|
52
49
|
[`& .${alertClasses.action} > .${iconButtonClasses.root}:only-child`]: {
|
|
53
50
|
color: theme.vars.palette.text.secondary
|
|
54
51
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linagora/twake-mui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "10.
|
|
4
|
+
"version": "10.4.0",
|
|
5
5
|
"description": "MUI v9 theme system for Twake applications",
|
|
6
6
|
"author": "Twake",
|
|
7
7
|
"license": "MIT",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"@storybook/builder-vite": "10.3.5",
|
|
79
79
|
"@storybook/react": "10.3.5",
|
|
80
80
|
"@storybook/react-vite": "10.3.5",
|
|
81
|
+
"@testing-library/react": "^16.3.3",
|
|
81
82
|
"@types/classnames": "^2.3.1",
|
|
82
83
|
"@types/lodash": "4.17.24",
|
|
83
84
|
"@types/react": "19.3.0",
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"eslint": "10.2.1",
|
|
88
89
|
"eslint-config-cozy-app": "7.0.0",
|
|
89
90
|
"eslint-plugin-storybook": "10.3.5",
|
|
91
|
+
"jsdom": "^30.1.2",
|
|
90
92
|
"lodash": "4.18.1",
|
|
91
93
|
"playwright": "1.60.0",
|
|
92
94
|
"react": "^19.3.0",
|