@linagora/twake-mui 10.3.0 → 10.4.1

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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## @linagora/twake-mui [10.4.1](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@10.4.0...@linagora/twake-mui@10.4.1) (2026-10-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **twake-mui:** Follow the color scheme in overrides and SearchBar ([#142](https://github.com/linagora/twake-ui/issues/142)) ([1c2efac](https://github.com/linagora/twake-ui/commit/1c2efacba0a3e140384775e9a4f8e76416409b7a))
7
+
8
+ # @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)
9
+
10
+
11
+ ### Features
12
+
13
+ * **twake-mui:** Add the TwakeSpace overlay for embedded apps ([97a8b50](https://github.com/linagora/twake-ui/commit/97a8b506c0c485fffaa71805e4c32c77bd6ff30d))
14
+
1
15
  # @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
16
 
3
17
 
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
@@ -1,6 +1,6 @@
1
1
  import { Icon } from '@linagora/twake-icons';
2
2
  import { Copy } from '@linagora/twake-icons';
3
- import { Box, IconButton, Typography, useTheme, alpha } from '@mui/material';
3
+ import { Box, IconButton, Typography, useTheme } from '@mui/material';
4
4
  import React, { useState } from 'react';
5
5
  import { useI18n } from 'twake-i18n';
6
6
  import { Avatar } from '../Avatar';
@@ -28,8 +28,8 @@ export const AttendeeInfo = ({ name, email, onEmailCopy }) => {
28
28
  name && name !== email ? (React.createElement(Typography, { variant: "body1", sx: { fontWeight: 500, wordBreak: 'break-word' } }, name)) : null,
29
29
  React.createElement(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 } },
30
30
  React.createElement(Typography, { variant: "body2", color: "text.secondary", sx: { wordBreak: 'break-word' } }, email),
31
- React.createElement(IconButton, { size: "small", onClick: () => void handleCopy(), sx: { p: 0.5, color: alpha(theme.palette.grey[900], 0.9) }, title: showCopied
31
+ React.createElement(IconButton, { size: "small", onClick: () => void handleCopy(), sx: { p: 0.5, color: 'text.primary' }, title: showCopied
32
32
  ? t('ContactPopover.copiedTooltip')
33
33
  : t('ContactPopover.copyTooltip') },
34
- React.createElement(Icon, { icon: Copy, size: 16, color: theme.palette.text.icon }))))));
34
+ React.createElement(Icon, { icon: Copy, size: 16, color: theme.vars.palette.text.icon }))))));
35
35
  };
@@ -4,6 +4,6 @@ import { IconButton, useTheme } from '@mui/material';
4
4
  import React from 'react';
5
5
  export const ContactPopoverCalendarAction = ({ url, disabled = false }) => {
6
6
  const theme = useTheme();
7
- return (React.createElement(IconButton, { href: url, target: "_blank", rel: "noopener noreferrer", size: "small", disabled: disabled, sx: { border: `1px solid ${theme.palette.divider}` } },
8
- React.createElement(Icon, { icon: CalendarToday, size: 20, color: theme.palette.text.icon })));
7
+ return (React.createElement(IconButton, { href: url, target: "_blank", rel: "noopener noreferrer", size: "small", disabled: disabled, sx: { border: `1px solid ${theme.vars.palette.divider}` } },
8
+ React.createElement(Icon, { icon: CalendarToday, size: 20, color: theme.vars.palette.text.icon })));
9
9
  };
@@ -4,6 +4,6 @@ import { IconButton, useTheme } from '@mui/material';
4
4
  import React from 'react';
5
5
  export const ContactPopoverChatAction = ({ url, disabled = false }) => {
6
6
  const theme = useTheme();
7
- return (React.createElement(IconButton, { href: url, target: "_blank", rel: "noopener noreferrer", size: "small", disabled: disabled, sx: { border: `1px solid ${theme.palette.divider}` } },
8
- React.createElement(Icon, { icon: Discuss, size: 20, color: theme.palette.text.icon })));
7
+ return (React.createElement(IconButton, { href: url, target: "_blank", rel: "noopener noreferrer", size: "small", disabled: disabled, sx: { border: `1px solid ${theme.vars.palette.divider}` } },
8
+ React.createElement(Icon, { icon: Discuss, size: 20, color: theme.vars.palette.text.icon })));
9
9
  };
@@ -6,7 +6,7 @@ export const ContactPopoverEmailAction = ({ url, disabled = false }) => {
6
6
  const { t } = useI18n();
7
7
  return (React.createElement(Button, { href: url, target: "_blank", rel: "noopener noreferrer", variant: "outlined", size: "small", disabled: disabled, sx: {
8
8
  justifyContent: 'center',
9
- color: theme.palette.text.primary,
10
- borderColor: theme.palette.divider
9
+ color: theme.vars.palette.text.primary,
10
+ borderColor: theme.vars.palette.divider
11
11
  } }, t('ContactPopover.emailButton')));
12
12
  };
@@ -4,6 +4,6 @@ import { IconButton, useTheme } from '@mui/material';
4
4
  import React from 'react';
5
5
  export const ContactPopoverVideoAction = ({ url, disabled = false }) => {
6
6
  const theme = useTheme();
7
- return (React.createElement(IconButton, { href: url, target: "_blank", rel: "noopener noreferrer", size: "small", disabled: disabled, sx: { border: `1px solid ${theme.palette.divider}` } },
8
- React.createElement(Icon, { icon: Camera, size: 20, color: theme.palette.text.icon })));
7
+ return (React.createElement(IconButton, { href: url, target: "_blank", rel: "noopener noreferrer", size: "small", disabled: disabled, sx: { border: `1px solid ${theme.vars.palette.divider}` } },
8
+ React.createElement(Icon, { icon: Camera, size: 20, color: theme.vars.palette.text.icon })));
9
9
  };
@@ -1,5 +1,5 @@
1
1
  import { styled } from '@mui/material';
2
2
  import { Highlight } from './Highlight';
3
3
  export const DisableHighlight = styled(Highlight)(({ theme }) => ({
4
- backgroundColor: theme.palette.action.disabled
4
+ backgroundColor: theme.vars.palette.action.disabled
5
5
  }));
@@ -2,5 +2,5 @@ import { styled } from '@mui/material';
2
2
  import { Highlight } from './Highlight';
3
3
  export const FocusHighlight = styled(Highlight)(({ theme }) => ({
4
4
  pointerEvents: 'none',
5
- backgroundColor: theme.palette.action.hover
5
+ backgroundColor: theme.vars.palette.action.hover
6
6
  }));
@@ -1,6 +1,6 @@
1
1
  import { styled } from '@mui/material';
2
2
  export const SearchBarIconWrapper = styled('div')(({ theme }) => ({
3
- color: theme.palette.text.secondary,
3
+ color: theme.vars.palette.text.secondary,
4
4
  padding: '0 1rem',
5
5
  display: 'flex'
6
6
  }));
@@ -15,10 +15,10 @@ export const SearchBarRoot = styled(Paper, {
15
15
  borderStyle: 'solid',
16
16
  borderWidth: 1,
17
17
  paddingRight: '0.5rem',
18
- borderColor: isFocused ? theme.palette.primary.main : 'transparent',
18
+ borderColor: isFocused ? theme.vars.palette.primary.main : 'transparent',
19
19
  backgroundColor: isFocused
20
- ? theme.palette.background.paper
21
- : theme.palette.background.default,
20
+ ? theme.vars.palette.background.paper
21
+ : theme.vars.palette.background.default,
22
22
  transition: theme.transitions.create(['border-color', 'background-color']),
23
23
  '&:hover': {
24
24
  '&:not(.Mui-disabled):not(.SearchBar-focused)': {
@@ -29,7 +29,7 @@ export const SearchBarRoot = styled(Paper, {
29
29
  },
30
30
  '&.Mui-disabled': {
31
31
  '& .SearchBar-icon': {
32
- color: theme.palette.text.disabled
32
+ color: theme.vars.palette.text.disabled
33
33
  },
34
34
  '& .SearchBar-disableHighlight': {
35
35
  opacity: 1
@@ -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,20 @@
1
+ import { Box, Portal } from '@mui/material';
2
+ import React from 'react';
3
+ export const OverlayFrame = ({ name, src, title, sandbox, allow, clipPath }) => {
4
+ const empty = clipPath.startsWith('inset(');
5
+ return (React.createElement(Portal, null,
6
+ React.createElement(Box, { component: "iframe", name: name, src: src, title: title, sandbox: sandbox, allow: allow, "aria-hidden": empty || undefined, tabIndex: empty ? -1 : undefined,
7
+ // Inline: it changes with every move of a menu, a class per value
8
+ // would pile up
9
+ style: { clipPath }, sx: {
10
+ position: 'fixed',
11
+ inset: 0,
12
+ width: '100%',
13
+ height: '100%',
14
+ border: 0,
15
+ background: 'transparent',
16
+ // A frame whose colour scheme differs from the page is painted opaque
17
+ colorScheme: 'normal',
18
+ zIndex: theme => theme.zIndex.modal - 1
19
+ } })));
20
+ };
@@ -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 { Portal } from '@mui/material';
2
+ import React, { createContext, useContext, useSyncExternalStore } from 'react';
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(Portal, { container: body, disablePortal: body === null }, children));
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,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,10 @@ 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 { connectSpaceOverlay, computeOverlayRegion } from './components/SpaceOverlay/spaceOverlay';
45
+ export { OverlayPortal, SpaceOverlayProvider, overlayThemeOptions, useOverlayWindow } from './components/SpaceOverlay/SpaceOverlay';
46
+ export { overlayClipPath, overlayRegionMessage, parseOverlayRegion, parseOverlayRegionMessage } from './components/SpaceOverlay/region';
47
+ export { OverlayFrame } from './components/SpaceOverlay/OverlayFrame';
44
48
  export type { TwakeTheme } from './lib/theme';
45
49
  export type { PaletteJson } from './lib/types';
46
50
  export type { AvatarProps } from './components/Avatar';
@@ -53,6 +57,10 @@ export type { ContactPopoverVideoActionProps } from './components/ContactPopover
53
57
  export type { ContactPopoverCalendarActionProps } from './components/ContactPopover/ContactPopoverCalendarAction';
54
58
  export type { ContactPopoverEmailActionProps } from './components/ContactPopover/ContactPopoverEmailAction';
55
59
  export type { ChipProps } from './components/Chip';
60
+ export type { SpaceOverlay, SpaceOverlayStatus } from './components/SpaceOverlay/spaceOverlay';
61
+ export type { SpaceOverlayProviderProps } from './components/SpaceOverlay/SpaceOverlay';
62
+ export type { OverlayBox, OverlayRegion } from './components/SpaceOverlay/region';
63
+ export type { OverlayFrameProps } from './components/SpaceOverlay/OverlayFrame';
56
64
  export type { DialogProps, DialogSize } from './components/Dialog';
57
65
  export type { PointerAlertProps, PointerAlertDirection } from './components/PointerAlert';
58
66
  export type { EmptyProps, EmptyIconSize, EmptyComponentsProps } from './components/Empty';
package/dist/index.js CHANGED
@@ -44,4 +44,8 @@ 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 { connectSpaceOverlay, computeOverlayRegion } from './components/SpaceOverlay/spaceOverlay';
48
+ export { OverlayPortal, SpaceOverlayProvider, overlayThemeOptions, useOverlayWindow } from './components/SpaceOverlay/SpaceOverlay';
49
+ export { overlayClipPath, overlayRegionMessage, parseOverlayRegion, parseOverlayRegionMessage } from './components/SpaceOverlay/region';
50
+ export { OverlayFrame } from './components/SpaceOverlay/OverlayFrame';
47
51
  export { ContactPopoverActions, ContactPopoverChatAction, ContactPopoverVideoAction, ContactPopoverCalendarAction, ContactPopoverEmailAction } from './components/ContactPopover';
@@ -16,7 +16,7 @@ import { selectClasses } from '@mui/material/Select';
16
16
  import { switchClasses } from '@mui/material/Switch';
17
17
  import { tabClasses } from '@mui/material/Tab';
18
18
  import { tabsClasses } from '@mui/material/Tabs';
19
- import { alpha, darken } from '@mui/material/styles';
19
+ import { alpha } from '@mui/material/styles';
20
20
  import React from 'react';
21
21
  import { radius } from './radius';
22
22
  import AccordionExpandIcon from '../components/AccordionExpandIcon';
@@ -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
- // Alert renders its close button, titled with the translated
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
  }
@@ -523,7 +520,7 @@ export const overrides = {
523
520
  height: 14,
524
521
  minWidth: 14,
525
522
  padding: 0,
526
- border: `2px solid ${theme.palette.background.paper}`,
523
+ border: `2px solid ${theme.vars.palette.background.paper}`,
527
524
  borderRadius: '100%',
528
525
  fontSize: theme.typography.pxToRem(10)
529
526
  }),
@@ -590,12 +587,12 @@ export const overrides = {
590
587
  MuiToggleButtonGroup: {
591
588
  styleOverrides: {
592
589
  root: ({ theme }) => ({
593
- borderColor: theme.palette.secondary.dark,
590
+ borderColor: theme.vars.palette.secondary.dark,
594
591
  '& .MuiToggleButton-root:not(.Mui-selected)': {
595
- color: theme.palette.text.secondary,
596
- backgroundColor: alpha(theme.palette.text.secondary, 0.08),
592
+ color: theme.vars.palette.text.secondary,
593
+ backgroundColor: theme.alpha(theme.vars.palette.text.secondary, 0.08),
597
594
  '& svg, & .MuiSvgIcon-root': {
598
- color: theme.palette.text.secondary
595
+ color: theme.vars.palette.text.secondary
599
596
  }
600
597
  }
601
598
  })
@@ -604,15 +601,15 @@ export const overrides = {
604
601
  MuiToggleButton: {
605
602
  styleOverrides: {
606
603
  root: ({ theme }) => ({
607
- color: theme.palette.text.primary,
604
+ color: theme.vars.palette.text.primary,
608
605
  '&.Mui-selected': {
609
- color: theme.palette.text.primary,
606
+ color: theme.vars.palette.text.primary,
610
607
  backgroundColor: 'transparent',
611
608
  '&:hover': {
612
609
  backgroundColor: 'transparent'
613
610
  },
614
611
  '& svg, & .MuiSvgIcon-root': {
615
- color: theme.palette.primary.main
612
+ color: theme.vars.palette.primary.main
616
613
  }
617
614
  },
618
615
  variants: [
@@ -634,7 +631,7 @@ export const overrides = {
634
631
  {
635
632
  props: { color: 'default' },
636
633
  style: {
637
- color: theme.palette.text.secondary
634
+ color: theme.vars.palette.text.secondary
638
635
  }
639
636
  },
640
637
  {
@@ -958,7 +955,9 @@ export const overrides = {
958
955
  marginLeft: 5,
959
956
  marginRight: -12,
960
957
  transform: 'rotate(-90deg)',
961
- [`&.${accordionSummaryClasses.expanded}`]: { transform: 'rotate(0deg)' }
958
+ [`&.${accordionSummaryClasses.expanded}`]: {
959
+ transform: 'rotate(0deg)'
960
+ }
962
961
  }),
963
962
  content: {
964
963
  paddingLeft: 8,
@@ -984,23 +983,23 @@ export const overrides = {
984
983
  borderRadius: 28,
985
984
  width: 96,
986
985
  height: 96,
987
- color: theme.palette.text.primary,
988
- backgroundColor: theme.palette.background.paper,
986
+ color: theme.vars.palette.text.primary,
987
+ backgroundColor: theme.vars.palette.background.paper,
989
988
  '&:hover': {
990
- backgroundColor: darken(theme.palette.background.paper, 0.05)
989
+ backgroundColor: `color-mix(in srgb, ${theme.vars.palette.background.paper}, #000 5%)`
991
990
  },
992
991
  '@media (hover: none)': {
993
- backgroundColor: theme.palette.background.paper
992
+ backgroundColor: theme.vars.palette.background.paper
994
993
  }
995
994
  }),
996
995
  primary: ({ theme }) => ({
997
- color: theme.palette.primary.dark,
998
- backgroundColor: theme.palette.primary.light,
996
+ color: theme.vars.palette.primary.dark,
997
+ backgroundColor: theme.vars.palette.primary.light,
999
998
  '&:hover': {
1000
- backgroundColor: darken(theme.palette.primary.light, 0.05)
999
+ backgroundColor: `color-mix(in srgb, ${theme.vars.palette.primary.light}, #000 5%)`
1001
1000
  },
1002
1001
  '@media (hover: none)': {
1003
- backgroundColor: theme.palette.primary.light
1002
+ backgroundColor: theme.vars.palette.primary.light
1004
1003
  }
1005
1004
  }),
1006
1005
  extended: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linagora/twake-mui",
3
3
  "type": "module",
4
- "version": "10.3.0",
4
+ "version": "10.4.1",
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",