From 0bdb697a4042b1c2151047230d9a4902310a99ad Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Thu, 4 Dec 2025 18:25:07 +0100 Subject: [PATCH 1/6] Add FluentOverlay Web Component --- .../Overlay/Examples/OverlayHtmlElement.razor | 19 ++ .../Components/Overlay/FluentOverlay.md | 8 + .../Overlay/FluentOverlay-Styles.ts | 60 ++++++ .../src/Components/Overlay/FluentOverlay.ts | 196 ++++++++++++++++++ src/Core.Scripts/src/Startup.ts | 3 + 5 files changed, 286 insertions(+) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md create mode 100644 src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts create mode 100644 src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts 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..098e34fd6d --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor @@ -0,0 +1,19 @@ + + + +
+

Container

+ + + +
+ +
+ +
+ +@code +{ + bool FullScreen = false; + bool Interactive = false; +} 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..6aba5da14d --- /dev/null +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts @@ -0,0 +1,60 @@ +export const fluentOverlayStyles: string = ` + :host { + --overlayZIndex: 99999; + --overlayBackground: ''; + } + + :host dialog { + border: 1px solid blue; /* TODO: Remove me */ + 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%); + pointer-events: pointer; + } + + :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); + /* pointer-events: none; */ + } + + :host:not([fullscreen]) dialog { + position: fixed; + margin: 0; + transform: translate(-50%, -50%); + } + + :host:not([fullscreen]) dialog::backdrop { + background-color: transparent; + } + + :host[interactive][fullscreen]:has(dialog[open]) { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: calc(var(--overlayZIndex) - 1); + pointer-events: none; + background-color: var(--overlayBackground); + } +`; 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..92aa1191f3 --- /dev/null +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts @@ -0,0 +1,196 @@ +import { StartedMode } from "../../d-ts/StartedMode"; +import { fluentOverlayStyles } from "./FluentOverlay-Styles"; + +export namespace Microsoft.FluentUI.Blazor.Components.Overlay { + + class FluentOverlay extends HTMLElement { + + private container: HTMLElement | null = null; + private dialog: HTMLDialogElement; + private resizeObserver: ResizeObserver | null = null; + + // Add backing field for opened property + 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)` + + // Prevent to use ESC key to close the dialog + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy#browser_compatibility + // this.dialog.setAttribute('closedBy', 'none'); + + // 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 background-color + public get background(): string { + return this.getAttribute('background') ?? 'color-mix(in srgb, var(--colorBackgroundOverlay) 40%, transparent)'; + } + + public set background(value: string) { + if (value) { + this.setAttribute('background', ''); + } else { + this.removeAttribute('background'); + } + } + + /************************ + Public methods + ************************/ + + + // Public method to open the dialog + public show() { + if (this.dialog) { + + if (this.fullscreen === false) { + this.ensureParentPositioning(); + this.createResizeObserver(); + } + + if (this.background) { + this.style.setProperty('--overlayBackground', this.background); + } + + if (this.interactive) { + this.dialog.show(); + } else { + this.dialog.showModal(); + } + } + } + + // Public method to close the dialog + public close() { + if (this.dialog) { + this.dialog.close(); + } + } + + /************************ + Private methods + ************************/ + + // 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`; + } + } + + // 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 { + this.dialog.style.top = ''; + this.dialog.style.left = ''; + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + } + + // Cleanup when element is removed from DOM + disconnectedCallback() { + this.cleanResizeObserver(); + } + } + + /** + * 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 From 057facbc0b77cca2b79f2e12c65bb6fe9939b81f Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Thu, 4 Dec 2025 18:47:15 +0100 Subject: [PATCH 2/6] Fix Container position --- .../src/Components/Overlay/FluentOverlay-Styles.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts index 6aba5da14d..d5f2ddf946 100644 --- a/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts @@ -14,7 +14,7 @@ export const fluentOverlayStyles: string = ` outline: none; } - :host[fullscreen] dialog { + :host([fullscreen]) dialog { position: fixed; top: 50%; left: 50%; @@ -23,11 +23,11 @@ export const fluentOverlayStyles: string = ` pointer-events: pointer; } - :host[fullscreen] dialog::backdrop { + :host([fullscreen]) dialog::backdrop { background-color: var(--overlayBackground); } - :host:not([fullscreen]):has(dialog[open]) { + :host(:not([fullscreen])):has(dialog[open]) { position: absolute; top: 0; left: 0; @@ -37,13 +37,13 @@ export const fluentOverlayStyles: string = ` /* pointer-events: none; */ } - :host:not([fullscreen]) dialog { + :host(:not([fullscreen])) dialog { position: fixed; margin: 0; transform: translate(-50%, -50%); } - :host:not([fullscreen]) dialog::backdrop { + :host(:not([fullscreen])) dialog::backdrop { background-color: transparent; } From dd9cccef73a90af9cfca2548539044e884ad3f8b Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Fri, 5 Dec 2025 12:52:22 +0100 Subject: [PATCH 3/6] Update FluentOverlay --- .../Overlay/Examples/OverlayHtmlElement.razor | 31 ++++++- .../Overlay/FluentOverlay-Styles.ts | 13 +-- .../src/Components/Overlay/FluentOverlay.ts | 87 ++++++++++++++++++- 3 files changed, 121 insertions(+), 10 deletions(-) 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 index 098e34fd6d..87fd96ccd2 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor @@ -1,9 +1,19 @@  +
+ + All + Inside only + Outside only + -
+

Container

- + +
@@ -16,4 +26,21 @@ { bool FullScreen = false; bool Interactive = false; + string CloseMode = "all"; } + + diff --git a/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts index d5f2ddf946..1748bd7e47 100644 --- a/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts @@ -5,7 +5,7 @@ export const fluentOverlayStyles: string = ` } :host dialog { - border: 1px solid blue; /* TODO: Remove me */ + border: none; background: none; z-index: var(--overlayZIndex); } @@ -20,7 +20,6 @@ export const fluentOverlayStyles: string = ` left: 50%; margin: 0; transform: translate(-50%, -50%); - pointer-events: pointer; } :host([fullscreen]) dialog::backdrop { @@ -34,7 +33,6 @@ export const fluentOverlayStyles: string = ` right: 0; bottom: 0; background-color: var(--overlayBackground); - /* pointer-events: none; */ } :host(:not([fullscreen])) dialog { @@ -45,16 +43,21 @@ export const fluentOverlayStyles: string = ` :host(:not([fullscreen])) dialog::backdrop { background-color: transparent; + width: 0; + height: 0; } - :host[interactive][fullscreen]:has(dialog[open]) { + :host([interactive][fullscreen]):has(dialog[open]) { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: calc(var(--overlayZIndex) - 1); - pointer-events: none; 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 index 92aa1191f3..a3e4056cea 100644 --- a/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts +++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts @@ -3,13 +3,15 @@ import { fluentOverlayStyles } from "./FluentOverlay-Styles"; export namespace Microsoft.FluentUI.Blazor.Components.Overlay { + export type CloseMode = 'all' | 'inside' | 'outside' | null; + class FluentOverlay extends HTMLElement { private container: HTMLElement | null = null; private dialog: HTMLDialogElement; private resizeObserver: ResizeObserver | null = null; + private clickHandler: ((ev: MouseEvent) => any) | null = null; - // Add backing field for opened property private _opened: boolean = false; /************************ @@ -37,7 +39,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { // Prevent to use ESC key to close the dialog // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy#browser_compatibility - // this.dialog.setAttribute('closedBy', 'none'); + this.dialog.setAttribute('closedBy', 'none'); // Set initial styles for the dialog const sheet = new CSSStyleSheet(); @@ -86,6 +88,19 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { } } + // 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') ?? 'color-mix(in srgb, var(--colorBackgroundOverlay) 40%, transparent)'; @@ -111,17 +126,30 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { 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) { + 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); + } } } @@ -129,6 +157,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { public close() { if (this.dialog) { this.dialog.close(); + + // Remove the click event listener if it exists + if (this.clickHandler) { + document.removeEventListener('click', this.clickHandler); + this.clickHandler = null; + } } } @@ -136,6 +170,39 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { Private methods ************************/ + // Private method to handle click events + private onClick(event: MouseEvent): void { + if (this.dialog.open && event.target instanceof HTMLElement) { + 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 { + 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; @@ -156,6 +223,14 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { 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 = ''; } } @@ -180,6 +255,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overlay { // 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; + } } } From a6238097b80f5e48495ac38525a7db3ae9e33d96 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Fri, 5 Dec 2025 14:23:06 +0100 Subject: [PATCH 4/6] Add CloseMode --- .../Overlay/Examples/OverlayHtmlElement.razor | 10 +++ .../src/Components/Overlay/FluentOverlay.ts | 84 +++++++++++++++++-- 2 files changed, 85 insertions(+), 9 deletions(-) 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 index 87fd96ccd2..845a9a8e8c 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor @@ -2,6 +2,7 @@
+ Manual All Inside only Outside only @@ -11,6 +12,8 @@

Container

@@ -20,6 +23,7 @@
+
@code @@ -29,6 +33,12 @@ string CloseMode = "all"; } + +