diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor new file mode 100644 index 0000000000..41f3a8560a --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor @@ -0,0 +1,57 @@ + + +
+ + Manual + All + Inside only + Outside only + + +
+

Container

+ + + + +
+ +
+ + +
+ +@code +{ + bool FullScreen = false; + bool Interactive = false; + string CloseMode = "all"; +} + + + + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md new file mode 100644 index 0000000000..07b833be40 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md @@ -0,0 +1,8 @@ +--- +title: Overlay +route: /Overlay +--- + +# Overlay HTMLElement + +{{ OverlayHtmlElement }} diff --git a/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts new file mode 100644 index 0000000000..1748bd7e47 --- /dev/null +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts @@ -0,0 +1,63 @@ +export const fluentOverlayStyles: string = ` + :host { + --overlayZIndex: 99999; + --overlayBackground: ''; + } + + :host dialog { + border: none; + background: none; + z-index: var(--overlayZIndex); + } + + :focus-visible { + outline: none; + } + + :host([fullscreen]) dialog { + position: fixed; + top: 50%; + left: 50%; + margin: 0; + transform: translate(-50%, -50%); + } + + :host([fullscreen]) dialog::backdrop { + background-color: var(--overlayBackground); + } + + :host(:not([fullscreen])):has(dialog[open]) { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--overlayBackground); + } + + :host(:not([fullscreen])) dialog { + position: fixed; + margin: 0; + transform: translate(-50%, -50%); + } + + :host(:not([fullscreen])) dialog::backdrop { + background-color: transparent; + width: 0; + height: 0; + } + + :host([interactive][fullscreen]):has(dialog[open]) { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: calc(var(--overlayZIndex) - 1); + background-color: var(--overlayBackground); + } + + :host([interactive]):has(dialog[open]) { + pointer-events: none; + } +`; diff --git a/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts b/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts new file mode 100644 index 0000000000..8a24c97256 --- /dev/null +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts @@ -0,0 +1,344 @@ +import { StartedMode } from "../../d-ts/StartedMode"; +import { fluentOverlayStyles } from "./FluentOverlay-Styles"; + +export namespace Microsoft.FluentUI.Blazor.Components.Overlay { + + /** + * CloseMode type + * - manual: Do not close automatically + * - all: Close on any click + * - inside: Close on click inside the dialog + * - outside: Close on click outside the dialog + */ + export type CloseMode = 'manual' | 'all' | 'inside' | 'outside' | null; + + /** + * FluentOverlay web component + */ + class FluentOverlay extends HTMLElement { + + private readonly DEFAULT_BACKGROUND: string = 'color-mix(in srgb, var(--colorBackgroundOverlay) 40%, transparent)'; + private container: HTMLElement | null = null; + private dialog: HTMLDialogElement | null = null; + private resizeObserver: ResizeObserver | null = null; + private clickHandler: ((ev: MouseEvent) => any) | null = null; + + private _opened: boolean = false; + + /************************ + Initialization + ************************/ + constructor() { + super(); + } + + // Delay initialization to ensure child content is parsed + connectedCallback() { + setTimeout(() => { + this.initialize(); + }, 0); + } + + private initialize() { + this.container = this.parentElement; + const shadow = this.attachShadow({ mode: 'open' }); + + // Create the dialog element + this.dialog = document.createElement('dialog') as HTMLDialogElement; + this.dialog.setAttribute('fuib', ''); + this.dialog.setAttribute('part', 'dialog'); // To allow styling using `fluent-overlay::part(dialog)` + + this.dialog.addEventListener('toggle', (e) => { + // Dispatch event when closed + this.dispatchOpenedEvent(e.newState === 'open'); + }); + + // Set initial styles for the dialog + const sheet = new CSSStyleSheet(); + sheet.replaceSync(fluentOverlayStyles); + this.shadowRoot!.adoptedStyleSheets = [ + ...(this.shadowRoot!.adoptedStyleSheets || []), + sheet + ]; + + // Slot for user content + const contentSlot = document.createElement('slot'); + while (this.firstChild) { + contentSlot.appendChild(this.firstChild); + } + this.dialog.appendChild(contentSlot); + shadow.appendChild(this.dialog); + } + + /************************ + Public Properties + ************************/ + + // Property getter/setter for fullscreen + public get fullscreen(): boolean { + return this.hasAttribute('fullscreen'); + } + + public set fullscreen(value: boolean) { + if (value) { + this.setAttribute('fullscreen', ''); + } else { + this.removeAttribute('fullscreen'); + } + } + + // Property getter/setter for interactive + public get interactive(): boolean { + return this.hasAttribute('interactive'); + } + + public set interactive(value: boolean) { + if (value) { + this.setAttribute('interactive', ''); + } else { + this.removeAttribute('interactive'); + } + } + + // Property getter/setter for interactive-outside + public get closeMode(): CloseMode { + return this.getAttribute('close-mode') as CloseMode; + } + + public set closeMode(value: CloseMode) { + if (value) { + this.setAttribute('close-mode', value); + } else { + this.removeAttribute('close-mode'); + } + } + + // Property getter/setter for background-color + public get background(): string { + return this.getAttribute('background') ?? this.DEFAULT_BACKGROUND; + } + + public set background(value: string) { + if (value) { + this.setAttribute('background', ''); + } else { + this.removeAttribute('background'); + } + } + + // Property getter/setter for dialog-style + public get dialogStyle(): string { + return this.getAttribute('dialog-style') ?? ''; + } + + public set dialogStyle(value: string) { + if (value) { + this.setAttribute('dialog-style', value); + } else { + this.removeAttribute('dialog-style'); + } + } + + // Property getter/setter for dialog-class + public get dialogClass(): string { + return this.getAttribute('dialog-class') ?? ''; + } + + public set dialogClass(value: string) { + if (value) { + this.setAttribute('dialog-class', value); + } else { + this.removeAttribute('dialog-class'); + } + } + + /************************ + Public methods + ************************/ + + // Public method to open the dialog + public show() { + if (this.dialog && !this.dialog.open) { + + this.dialog.setAttribute('style', this.dialogStyle); + this.dialog.setAttribute('class', this.dialogClass); + + // Prevent to use ESC key to close the dialog + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy#browser_compatibility + if (this.closeMode === 'manual') { + this.dialog.setAttribute('closedBy', 'none'); + } + + if (this.fullscreen === false) { + this.ensureParentPositioning(); + this.createResizeObserver(); + this.positionDialogInContainer(); + } + else { + this.positionDialogClear(); + } + + if (this.background) { + this.style.setProperty('--overlayBackground', this.background); + } + + if (this.interactive || !this.fullscreen) { + this.dialog.show(); + } else { + this.dialog.showModal(); + } + + if (!this.clickHandler) { + this.clickHandler = (e) => this.onClick(e); + + // Use capture phase and delay listener registration to avoid capturing the current click + setTimeout(() => { + document.addEventListener('click', this.clickHandler!); + }, 0); + } + } + } + + // Public method to close the dialog + public close() { + if (this.dialog && this.dialog.open) { + this.dialog.close(); + + // Remove the click event listener if it exists + if (this.clickHandler) { + document.removeEventListener('click', this.clickHandler); + this.clickHandler = null; + } + } + } + + /************************ + Private methods + ************************/ + + // Private method to handle click events + private onClick(event: MouseEvent): void { + if (this.dialog && this.dialog.open) { + const insideDialog = this.isClickInsideDialog(event); + event.stopPropagation(); + + if (this.closeMode === `all` || this.closeMode === null) { + this.close(); + return; + } + if (this.closeMode === `inside` && insideDialog) { + this.close(); + return; + } + if (this.closeMode === `outside` && !insideDialog) { + this.close(); + return; + } + } + } + + // Private method to check if a click event is inside the dialog + private isClickInsideDialog(event: MouseEvent): boolean { + + if (!this.dialog) { + return false; + } + + const dialogRect = this.dialog.getBoundingClientRect(); + const clickX = event.clientX; + const clickY = event.clientY; + + return clickX >= dialogRect.left && + clickX <= dialogRect.right && + clickY >= dialogRect.top && + clickY <= dialogRect.bottom; + } + + // Private method to ensure parent has proper positioning + private ensureParentPositioning(): void { + const parent = this.parentElement; + if (parent) { + const computedStyle = window.getComputedStyle(parent); + const position = computedStyle.position; + + // Check if position is one of the required values + if (position !== 'relative' && position !== 'absolute' && position !== 'fixed' && position !== 'sticky') { + parent.style.position = 'relative'; + } + } + } + + // Private method to position the dialog in the container + private positionDialogInContainer(): void { + if (this.container && this.dialog && this.fullscreen === false) { + const containerRect = this.container.getBoundingClientRect(); + this.dialog.style.top = `${containerRect.top + containerRect.height / 2}px`; + this.dialog.style.left = `${containerRect.left + containerRect.width / 2}px`; + } + } + + // Private method to clear the dialog position + private positionDialogClear(): void { + if (this.dialog) { + this.dialog.style.top = ''; + this.dialog.style.left = ''; + } + } + + // Subscribe to container size changes + private createResizeObserver(): void { + if (!this.resizeObserver && this.container) { + this.resizeObserver = new ResizeObserver(() => { + this.positionDialogInContainer(); + }); + this.resizeObserver.observe(this.container); + } + } + + // Private method to clean up the resize observer + private cleanResizeObserver(): void { + if (this.dialog) { + this.dialog.style.top = ''; + this.dialog.style.left = ''; + } + + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + } + + // Cleanup when element is removed from DOM + disconnectedCallback() { + this.cleanResizeObserver(); + + // Remove the click event listener if it exists + if (this.clickHandler) { + document.removeEventListener('click', this.clickHandler); + this.clickHandler = null; + } + } + + // Dispatch event when opened or closed + private dispatchOpenedEvent(opened: boolean) { + this.dispatchEvent(new CustomEvent('toggle', { + detail: { + oldState: opened ? 'closed' : 'open', + newState: opened ? 'open' : 'closed', + }, + bubbles: true, + composed: true + })); + } + + } + + /** + * Register the FluentOverlay component + * @param blazor + * @param mode + */ + export const registerComponent = (blazor: Blazor, mode: StartedMode): void => { + if (typeof blazor.addEventListener === 'function' && mode === StartedMode.Web) { + customElements.define('fluent-overlay', FluentOverlay); + } + }; +} diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts index 78eb30f65e..f14ea31af8 100644 --- a/src/Core.Scripts/src/Startup.ts +++ b/src/Core.Scripts/src/Startup.ts @@ -3,6 +3,7 @@ import { Microsoft as ThemeFile } from './Utilities/Theme'; import { Microsoft as FluentUIComponentsFile } from './FluentUIWebComponents'; import { Microsoft as FluentPageScriptFile } from './Components/PageScript/FluentPageScript'; import { Microsoft as FluentPopoverFile } from './Components/Popover/FluentPopover'; +import { Microsoft as FluentOverlayFile } from './Components/Overlay/FluentOverlay'; import { Microsoft as FluentUIStylesFile } from './FluentUIStyles'; import { Microsoft as FluentUICustomEventsFile } from './FluentUICustomEvents'; import { StartedMode } from './d-ts/StartedMode'; @@ -14,6 +15,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { import FluentUIComponents = FluentUIComponentsFile.FluentUI.Blazor.FluentUIWebComponents; import FluentPageScript = FluentPageScriptFile.FluentUI.Blazor.Components.PageScript; import FluentPopover = FluentPopoverFile.FluentUI.Blazor.Components.Popover; + import FluentOverlay = FluentOverlayFile.FluentUI.Blazor.Components.Overlay; import FluentUIStyles = FluentUIStylesFile.FluentUI.Blazor.FluentUIStyles; import FluentUICustomEvents = FluentUICustomEventsFile.FluentUI.Blazor.FluentUICustomEvents; @@ -52,6 +54,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { // Initialize all custom components FluentPageScript.registerComponent(blazor, mode); FluentPopover.registerComponent(blazor, mode); + FluentOverlay.registerComponent(blazor, mode); // [^^^ Add your other custom components before this line ^^^] // Register all custom events