**Related issue:** Resolves #49276 **New features** - Added new "Secondary" (bordered, off-white fill) and "Subdued" (borderless, low-emphasis) button variants to match the Figma spec, alongside the existing Primary style. - Allowed rows to be selected in Controls > OS updates. **Cleanup** - Once nothing referenced the old styles anymore, fully removed the old `text-icon`, `brand-inverse-icon`, `inverse-alert`, `inverse`, and `icon` button variants (type, styles, and Storybook entries) from the shared `Button` component. - Removed the `iconStroke` prop, which had become a no-op once the old variants it supported were gone. - Renamed `ActionsDropdown`'s variants (`button`/`brand-button`/`small-button`) to `subdued`/`primary`/`secondary` to match the same naming used everywhere else. - Replaced a one-off dropdown implementation on the Software title page with the shared `ActionsDropdown` component, instead of maintaining duplicate styling logic. - Changed the button name on Host details > Reports > Report details from "View data for all hosts" to "View report for all hosts" (to match the previous page's Actions drop-down options). # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <img width="1475" height="241" alt="Screenshot 2026-07-21 at 06 35 49" src="https://github.com/user-attachments/assets/7cfbd444-7837-40e8-854e-bc5989d57d85" /> <img width="661" height="306" alt="Screenshot 2026-07-21 at 06 37 18" src="https://github.com/user-attachments/assets/5d0c4873-8179-4089-b115-7e8cd3a53b4d" /> <img width="1427" height="423" alt="Screenshot 2026-07-21 at 06 37 30" src="https://github.com/user-attachments/assets/a4850a60-f44a-4902-b45e-0094f23a52f8" /> <img width="1427" height="640" alt="Screenshot 2026-07-21 at 06 37 46" src="https://github.com/user-attachments/assets/738a4a7f-cd7d-4162-b659-6f649c32204d" /> <img width="1445" height="479" alt="Screenshot 2026-07-22 at 07 03 22" src="https://github.com/user-attachments/assets/4f672dc0-5c6d-4eb8-8465-ed5233fcd1b2" /> <img width="811" height="871" alt="Screenshot 2026-07-21 at 06 41 20" src="https://github.com/user-attachments/assets/5421c96e-2dab-492a-af26-be0e5a7791ca" />
230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import classnames from "classnames";
|
|
import Button from "components/buttons/Button/Button";
|
|
import Icon from "components/Icon/Icon";
|
|
|
|
const baseClass = "modal";
|
|
const CLOSE_ANIMATION_MS = 100;
|
|
|
|
type ModalWidth = "medium" | "large" | "xlarge" | "auto";
|
|
// 650px 800px 850px auto
|
|
|
|
export interface IModalProps {
|
|
title: string | JSX.Element;
|
|
children: React.ReactNode;
|
|
onExit: () => void;
|
|
/** Called when the user presses Enter. Avoid using this on modals that
|
|
* contain forms, reveal/copy controls, or other elements where Enter has
|
|
* its own meaning — it will conflict with keyboard navigation. */
|
|
onEnter?: () => void;
|
|
/** medium 650px, large 800px, xlarge 850px, auto auto-width
|
|
* @default "medium"
|
|
*/
|
|
width?: ModalWidth;
|
|
/** isHidden can be set true to hide the modal when opening another modal
|
|
* @default false
|
|
*/
|
|
isHidden?: boolean;
|
|
/** isLoading can be set true to enable targeting elements by loading state
|
|
* @default false
|
|
*/
|
|
isLoading?: boolean;
|
|
/** `isContentDisabled` can be set to true to display the modal content as disabled.
|
|
* At the moment this will place an overlay over the modal content and make it
|
|
* unclickable. The top right will not be disabled and will still be clickable.
|
|
*
|
|
* @default false
|
|
*/
|
|
isContentDisabled?: boolean;
|
|
/** `disableClosingModal` can be set to disable the users ability to manually
|
|
* close the modal.
|
|
* @default false
|
|
* */
|
|
disableClosingModal?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const Modal = ({
|
|
title,
|
|
children,
|
|
onExit,
|
|
onEnter,
|
|
width = "medium",
|
|
isHidden = false,
|
|
isLoading = false,
|
|
isContentDisabled = false,
|
|
disableClosingModal = false,
|
|
className,
|
|
}: IModalProps): JSX.Element => {
|
|
const isDownOnBackgroundRef = useRef(false);
|
|
const isFormDirtyRef = useRef(false);
|
|
const [isClosing, setIsClosing] = useState(false);
|
|
const isClosingRef = useRef(false);
|
|
|
|
const handleClose = useCallback(() => {
|
|
if (isClosingRef.current) return;
|
|
isClosingRef.current = true;
|
|
setIsClosing(true);
|
|
setTimeout(() => {
|
|
onExit();
|
|
}, CLOSE_ANIMATION_MS);
|
|
}, [onExit]);
|
|
|
|
useEffect(() => {
|
|
const closeWithEscapeKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
handleClose();
|
|
}
|
|
};
|
|
|
|
if (!disableClosingModal) {
|
|
document.addEventListener("keydown", closeWithEscapeKey);
|
|
}
|
|
|
|
return () => {
|
|
if (!disableClosingModal) {
|
|
document.removeEventListener("keydown", closeWithEscapeKey);
|
|
}
|
|
};
|
|
}, [disableClosingModal, handleClose]);
|
|
|
|
useEffect(() => {
|
|
if (onEnter) {
|
|
const closeOrSaveWithEnterKey = (event: KeyboardEvent) => {
|
|
if (event.code === "Enter" || event.code === "NumpadEnter") {
|
|
event.preventDefault();
|
|
onEnter();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", closeOrSaveWithEnterKey);
|
|
return () => {
|
|
document.removeEventListener("keydown", closeOrSaveWithEnterKey);
|
|
};
|
|
}
|
|
return undefined;
|
|
}, [onEnter]);
|
|
|
|
useEffect(() => {
|
|
const onWindowBlur = () => {
|
|
isDownOnBackgroundRef.current = false;
|
|
};
|
|
window.addEventListener("blur", onWindowBlur);
|
|
return () => {
|
|
window.removeEventListener("blur", onWindowBlur);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
document.body.classList.add("modal-open");
|
|
return () => {
|
|
// By cleanup time this modal's own background node is already
|
|
// detached, so only unlock scroll once none remain.
|
|
if (document.querySelectorAll(`.${baseClass}__background`).length === 0) {
|
|
document.body.classList.remove("modal-open");
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const backgroundClasses = classnames(`${baseClass}__background`, {
|
|
[`${baseClass}__hidden`]: isHidden,
|
|
[`${baseClass}__closing`]: isClosing,
|
|
});
|
|
|
|
const modalContainerClasses = classnames(
|
|
className,
|
|
`${baseClass}__modal_container`,
|
|
`${baseClass}__modal_container__${width}`,
|
|
{
|
|
[`${className}__loading`]: isLoading,
|
|
[`${baseClass}__closing`]: isClosing,
|
|
}
|
|
);
|
|
|
|
const contentWrapperClasses = classnames(`${baseClass}__content-wrapper`, {
|
|
[`${baseClass}__content-wrapper-disabled`]: isContentDisabled,
|
|
});
|
|
|
|
const contentClasses = classnames(`${baseClass}__content`, {
|
|
[`${baseClass}__content-disabled`]: isContentDisabled,
|
|
});
|
|
|
|
const handleBackgroundMouseDown = () => {
|
|
isDownOnBackgroundRef.current = true;
|
|
};
|
|
|
|
const handleBackgroundMouseUp = () => {
|
|
if (
|
|
!disableClosingModal &&
|
|
isDownOnBackgroundRef.current &&
|
|
!isFormDirtyRef.current
|
|
) {
|
|
handleClose();
|
|
}
|
|
isDownOnBackgroundRef.current = false;
|
|
};
|
|
|
|
const handleContainerMouseDown = (e: React.MouseEvent) => e.stopPropagation();
|
|
|
|
const handleContainerMouseUp = (e: React.MouseEvent) => e.stopPropagation();
|
|
|
|
const handleContainerInput = () => {
|
|
isFormDirtyRef.current = true;
|
|
};
|
|
|
|
const handleContainerClick = (e: React.MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
const isCheckbox =
|
|
target instanceof HTMLInputElement && target.type === "checkbox";
|
|
const isToggle = !!target.closest('button[role="switch"]');
|
|
if (isCheckbox || isToggle) {
|
|
isFormDirtyRef.current = true;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={backgroundClasses}
|
|
style={
|
|
{
|
|
"--modal-close-duration": `${CLOSE_ANIMATION_MS}ms`,
|
|
} as React.CSSProperties
|
|
}
|
|
onMouseDown={handleBackgroundMouseDown}
|
|
onMouseUp={handleBackgroundMouseUp}
|
|
>
|
|
<div
|
|
className={modalContainerClasses}
|
|
tabIndex={-1} // Make focusable
|
|
onMouseDown={handleContainerMouseDown}
|
|
onMouseUp={handleContainerMouseUp}
|
|
onInput={handleContainerInput}
|
|
onClick={handleContainerClick}
|
|
>
|
|
<div className={`${baseClass}__header`}>
|
|
<span>{title}</span>
|
|
{!disableClosingModal && (
|
|
<div className={`${baseClass}__ex`}>
|
|
<Button
|
|
variant="subdued"
|
|
onClick={handleClose}
|
|
autofocus={isContentDisabled}
|
|
>
|
|
<Icon name="close" color="core-fleet-black" size="medium" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={contentWrapperClasses}>
|
|
{isContentDisabled && (
|
|
<div className={`${baseClass}__disabled-overlay`} />
|
|
)}
|
|
<div className={contentClasses}>{children}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Modal;
|