Files
twenty/packages/twenty-ui/src/surfaces/Modal/Modal.tsx
T
Raphaël Bosi 0f451897cf Make twenty-ui theming a consumer-facing API (#22007)
**What**
- Add `useTheme()` and `useThemeColorScheme()` as the public theme
accessors, and migrate the 88 internal `useContext(ThemeContext)` call
sites to them. `ThemeContext` stays exported.
- Make `ThemeProvider` overridable and scopeable: new `applyToRoot`
(default `true`), `overrides` (a `--t-*` map), and `className` props.
When scoping is requested it renders a `display: contents` wrapper that
also serves as the themed portal container, exposed via
`ThemeScopeContext` / `useThemeContainer()`. `AppTooltip` and `Modal`
portal into that container, falling back to `document.body`.
- Document the `--t-*` override contract in the README; barrels
regenerated.

**Why**
Consumers had no stable theme accessor (they reached into the raw
context) and no supported way to re-theme. This adds both without
behavior change.

**Reviewer notes**
- The default path is unchanged: `applyToRoot` defaults to `true`, so
the colorScheme class still lands on `<html>` and portaled overlays
(tooltips, dropdowns, modals) stay themed. The global class is
load-bearing for body portals; scoping is opt-in.
- `twenty-front` is untouched (migrating its consumers is a separate
follow-up).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22007?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-23 14:16:09 +02:00

131 lines
4.0 KiB
TypeScript

import { Dialog } from '@base-ui/react/dialog';
import { clsx } from 'clsx';
import { useRef } from 'react';
import { useThemeContainer } from '@ui/theme-constants';
import { isDefined } from '@ui/utilities/utils/isDefined';
import { type ModalOverlay } from '@ui/surfaces/Modal/types/ModalOverlay';
import { type ModalPadding } from '@ui/surfaces/Modal/types/ModalPadding';
import { type ModalProps } from '@ui/surfaces/Modal/types/ModalProps';
import styles from './Modal.module.scss';
import { ModalBackdrop } from '@ui/surfaces/ModalBackdrop/ModalBackdrop';
const DEFAULT_MODAL_Z_INDEX = 40;
const DEFAULT_BACKDROP_Z_INDEX = 39;
// Base UI's Dialog.Popup stops the propagation of these keydown events, but
// the deprecated Modal let them bubble up to global hotkey listeners.
const KEYDOWN_EVENTS_TO_PROPAGATE = new Set([
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
]);
const PADDING_CLASS_NAMES: Record<ModalPadding, string> = {
none: styles.paddingNone,
small: styles.paddingSmall,
medium: styles.paddingMedium,
large: styles.paddingLarge,
};
// The 'light' overlay is the base style of the modal, so it needs no class
const OVERLAY_CLASS_NAMES: Record<ModalOverlay, string | undefined> = {
dark: styles.overlayDark,
light: undefined,
transparent: styles.overlayTransparent,
};
export const Modal = ({
isOpen,
ariaLabel,
children,
size = 'medium',
padding = 'medium',
overlay = 'dark',
isMobile = false,
isInContainer = false,
container,
gap,
smallBorderRadius,
narrowWidth,
autoHeight,
modalZIndex = DEFAULT_MODAL_Z_INDEX,
backdropZIndex = DEFAULT_BACKDROP_Z_INDEX,
backdropTestId = 'modal-backdrop',
backdropClickOutsideId,
preventClickOutside,
onBackdropMouseDown,
modalRef: externalRef,
}: ModalProps) => {
const internalRef = useRef<HTMLDivElement>(null);
const resolvedRef = externalRef ?? internalRef;
const themeContainer = useThemeContainer();
const resolvedContainer = isDefined(container)
? container
: (themeContainer ?? undefined);
const handleBackdropMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onBackdropMouseDown?.(e);
};
// modal={false} and disablePointerDismissal keep Base UI from trapping
// focus, locking scroll or closing on outside press: like the deprecated
// Modal, open/close is fully controlled by the isOpen prop
return (
<Dialog.Root open={isOpen} modal={false} disablePointerDismissal>
<Dialog.Portal container={resolvedContainer}>
<ModalBackdrop
data-testid={backdropTestId}
data-click-outside-id={backdropClickOutsideId}
onMouseDown={handleBackdropMouseDown}
overlay={overlay}
backdropZIndex={backdropZIndex}
isInContainer={isInContainer}
>
<Dialog.Popup
initialFocus={false}
finalFocus={false}
onKeyDown={(event) => {
if (KEYDOWN_EVENTS_TO_PROPAGATE.has(event.key)) {
event.preventBaseUIHandler();
}
}}
render={
<div
ref={resolvedRef}
aria-label={ariaLabel}
className={clsx(
styles.modal,
styles[size],
PADDING_CLASS_NAMES[padding],
OVERLAY_CLASS_NAMES[overlay],
isMobile && styles.mobile,
smallBorderRadius && styles.smallBorderRadius,
narrowWidth && styles.narrowWidth,
autoHeight && styles.autoHeight,
)}
style={
{
'--modal-z-index': modalZIndex,
gap:
gap !== undefined ? `var(--t-spacing-${gap})` : undefined,
} as React.CSSProperties
}
data-globally-prevent-click-outside={preventClickOutside}
/>
}
>
{children}
</Dialog.Popup>
</ModalBackdrop>
</Dialog.Portal>
</Dialog.Root>
);
};