diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Menu/MenuEdgeCasesTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Menu/MenuEdgeCasesTest.razor new file mode 100644 index 000000000000..174ec0d05568 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Menu/MenuEdgeCasesTest.razor @@ -0,0 +1,179 @@ + + + Test Application + + + @{ + var priority = "1"; + switch (priority) + { + case "1": + Priority 1 + break; + case "2": + Priority 2 + break; + case "3": + Priority 3 + break; + case "4": + Priority 4 + break; + case "5": + Priority 5 + break; + case null: + None + break; + } + } + + +
+ Priority 1 + Priority 2 + Priority 3 + Priority 4 + Priority 5 + None +
+
+
+ + + @{ + var priority = "1"; + switch (priority) + { + case "1": + Priority 1 + break; + case "2": + Priority 2 + break; + case "3": + Priority 3 + break; + case "4": + Priority 4 + break; + case "5": + Priority 5 + break; + case null: + None + break; + } + } + + +
+ Priority 1 + Priority 2 + Priority 3 + Priority 4 + Priority 5 + None +
+
+
+
+ + @* On initial load these don't respect the mouseover leave event only activating and not deactivating *@ + + @foreach (var menu in _menuList) + { + + + @($"Menu {menu}") + + + + + + + + + + } + + +
+ Click + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + @{ + var priority = "1"; + switch (priority) + { + case "1": + Priority 1 + break; + case "2": + Priority 2 + break; + case "3": + Priority 3 + break; + case "4": + Priority 4 + break; + case "5": + Priority 5 + break; + case null: + None + break; + } + } + + +
+ Priority 1 + Priority 2 + Priority 3 + Priority 4 + Priority 5 + None +
+
+
+
+
+ +@code { + public static string __description__ = "4 menu edge cases for visual testing, initial load, in appbar, nested, and in dialog."; + private readonly string[] _menuList = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]; + private bool _visible; +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverFlipDirectionTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverFlipDirectionTest.razor new file mode 100644 index 000000000000..d4efc91780ba --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverFlipDirectionTest.razor @@ -0,0 +1,115 @@ + + + @(_expanded ? "Collapse" : "Expand") + @(_expanded ? "Collapse" : "Expand") for 5 s + Expand list in 5 s + + +
+ + + + Top Left + Top Center + Top Right + Bottom Left + Bottom Center + Bottom Right + Center Left + Center Center + Center Right + + + Top Left + Top Center + Top Right + Bottom Left + Bottom Center + Bottom Right + Center Left + Center Center + Center Right + +
+
+ + + + + + + + + + @if (_expandList) + { + + + + + + + + + + + + + + + + + + + } + + @if (!_hideBottom) + { + @(_expanded ? "Collapse" : "Expand") + @(_expanded ? "Collapse" : "Expand") for 5 s + + +
+ +
+
+ } +
+@code { + public static string __description__ = "Popover should take the best available space"; + + private bool _hideBottom; + private bool _expandList; + private bool _expanded = true; + private int _height = 600; + private int _heightBottom = 400; + private Origin _anchor = Origin.BottomLeft; + private Origin _transform = Origin.TopLeft; + + private readonly DropdownSettings _dropdownSettings = new() + { + OverflowBehavior = OverflowBehavior.FlipAlways + }; + + private void OnExpandCollapseClick() => _expanded = !_expanded; + + private async Task OnExpandCollapseFor5sClickAsync() + { + var expanded = _expanded; + _expanded = !expanded; + await Task.Delay(5000); + _expanded = expanded; + } + + private async Task OnExpandListIn5sAsync() + { + _expandList = false; + await Task.Delay(5000); + _expandList = true; + } +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Tooltip/TooltipNotRemovedTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tooltip/TooltipNotRemovedTest.razor new file mode 100644 index 000000000000..a675c8aaf52a --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tooltip/TooltipNotRemovedTest.razor @@ -0,0 +1,35 @@ + + @for (var i = 0; i < 200; i++) + { +
+ @{ + var current = i; + } + + BTN @current + +
+ } +
+ +@code +{ + private Guid _key; + + protected override Task OnInitializedAsync() + { + var cts = new CancellationTokenSource(); + RunThingAsync(cts.Token).CatchAndLog(); + return base.OnInitializedAsync(); + } + + private async Task RunThingAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + _key = Guid.NewGuid(); + InvokeAsync(StateHasChanged).CatchAndLog(); + await Task.Delay(100, cancellationToken); + } + } +} diff --git a/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs b/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs index a4f5b55d9c64..6069573b0287 100644 --- a/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs +++ b/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs @@ -104,12 +104,11 @@ public async Task BasicParameters_WithToolTips() actual.Should().BeEquivalentTo(expected); var parent = (IHtmlElement)item.Parent; - parent.Children.Should().HaveCount(1, because: "the button and no empty popover hint since it's not active"); + parent.Children.Should().HaveCount(2, because: "the button and the empty popover hint since it's not active"); await item.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); - var popover = comp.Find("div.mud-popover"); - var popoverId = popover.Id.Substring(15); + var popoverId = parent.Children[1].Id.Substring(8); var toolTip = comp.Find($"#popovercontent-{popoverId}"); @@ -134,12 +133,11 @@ public async Task BasicParameters_WithToolTips() actual.Should().BeEquivalentTo(expected); var parent = (IHtmlElement)item.Parent; - parent.Children.Should().HaveCount(1, because: "the button and no popover hint"); ; + parent.Children.Should().HaveCount(2, because: "the button and the empty popover hint"); ; await item.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); - var popover = comp.Find("div.mud-popover"); - var popoverId = popover.Id.Substring(15); + var popoverId = parent.Children[1].Id.Substring(8); var toolTip = comp.Find($"#popovercontent-{popoverId}"); diff --git a/src/MudBlazor.UnitTests/Components/PopoverTests.cs b/src/MudBlazor.UnitTests/Components/PopoverTests.cs index a7d9e238eab5..07e94aace1e5 100644 --- a/src/MudBlazor.UnitTests/Components/PopoverTests.cs +++ b/src/MudBlazor.UnitTests/Components/PopoverTests.cs @@ -14,7 +14,8 @@ public void PopoverOptions_Defaults() { var options = new PopoverOptions(); - options.ContainerClass.Should().Be("mudblazor-main-content"); + options.OverflowPadding.Should().Be(24); + options.ContainerClass.Should().Be("mud-popover-provider"); options.FlipMargin.Should().Be(0); options.ThrowOnDuplicateProvider.Should().Be(true); } diff --git a/src/MudBlazor.UnitTests/Components/ToolTipTests.cs b/src/MudBlazor.UnitTests/Components/ToolTipTests.cs index fb112497bba3..8a48dacff932 100644 --- a/src/MudBlazor.UnitTests/Components/ToolTipTests.cs +++ b/src/MudBlazor.UnitTests/Components/ToolTipTests.cs @@ -42,23 +42,25 @@ public async Task RenderContent(bool usingFocusout) button.ParentElement.ClassList.Should().Contain("mud-tooltip-root"); - //the button [0] and the popover node doesn't exist yet - button.ParentElement.Children.Should().HaveCount(1); + //the button [0] and [1] the popover npde + button.ParentElement.Children.Should().HaveCount(2); + + var popoverNode = button.ParentElement.Children[1]; + popoverNode.Id.Should().StartWith("popover-"); + + var popoverContentNode = () => comp.Find($"#popovercontent-{popoverNode.Id.Substring(8)}"); + + //no content for the popover node + popoverContentNode().Children.Should().BeEmpty(); //not visible by default tooltipComp.GetState(x => x.Visible).Should().BeFalse(); //trigger pointerover - await button.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); - //content should be visible - var popoverNode = button.ParentElement.Children[1]; - popoverNode.Id.Should().StartWith("popover-"); - - var popoverContentNode = comp.Find($"#popovercontent-{popoverNode.Id.Substring(8)}"); - popoverContentNode.TextContent.Should().Be("my tooltip content text"); - popoverContentNode.ClassList.Should().Contain("d-flex"); + popoverContentNode().TextContent.Should().Be("my tooltip content text"); + popoverContentNode().ClassList.Should().Contain("d-flex"); tooltipComp.GetState(x => x.Visible).Should().BeTrue(); @@ -72,9 +74,7 @@ public async Task RenderContent(bool usingFocusout) button.ParentElement.FocusOut(); } //no content should be visible - comp.Markup.Should().NotContain("my tooltip content text"); - //the button [0] and the popover node doesn't exist again - button.ParentElement.Children.Should().HaveCount(1); + popoverContentNode().Children.Should().BeEmpty(); tooltipComp.GetState(x => x.Visible).Should().BeFalse(); } @@ -110,17 +110,20 @@ public async Task RenderTooltipFragment(bool usingFocusout) button.ParentElement.ClassList.Should().Contain("mud-tooltip-root"); //the button [0] and [1] the popover node - button.ParentElement.Children.Should().HaveCount(1); - - //trigger pointerover - - await button.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); + button.ParentElement.Children.Should().HaveCount(2); var popoverNode = button.ParentElement.Children[1]; popoverNode.Id.Should().StartWith("popover-"); var popoverContentNode = comp.Find($"#popovercontent-{popoverNode.Id.Substring(8)}"); + + //no content for the popover node + popoverContentNode.Children.Should().BeEmpty(); + + //trigger pointerover + await button.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); + //content should be visible popoverContentNode.ClassList.Should().Contain("mud-tooltip"); popoverContentNode.ClassList.Should().Contain("d-flex"); @@ -137,9 +140,7 @@ public async Task RenderTooltipFragment(bool usingFocusout) button.ParentElement.FocusOut(); } //no content should be visible - comp.Markup.Should().NotContain("My content"); - //the button [0] and the popover node doesn't exist again - button.ParentElement.Children.Should().HaveCount(1); + popoverContentNode.Children.Should().BeEmpty(); } [Test] @@ -357,81 +358,39 @@ public async Task Tooltip_Disabled_Button_OnPointerEnter_NoPopover() comp.FindAll("div.mud-popover-open").Count.Should().Be(0); } - [TestCase(0, 0)] - [TestCase(500, 500)] - [Test] - public void Tooltip_Debouncer_Initials(double duration, double delay) - { - var comp = Context.RenderComponent(p => - { - p.Add(x => x.Delay, delay); - p.Add(x => x.Duration, duration); - }); - - var tooltipComp = comp.FindComponent().Instance; - tooltipComp.Delay.Should().Be(delay); - tooltipComp.Duration.Should().Be(duration); - tooltipComp._previousDelay.Should().Be(delay); - tooltipComp._previousDuration.Should().Be(duration); - var button = comp.Find("button"); - button.Should().NotBeNull(); - } - - [TestCase(0, 0)] - [TestCase(500, 500)] [Test] - public async Task Tooltip_Debouncer_Duration_and_Delay(double duration, double delay) + [TestCase(true)] + [TestCase(false)] + public async Task Tooltip_Handle_Pointer_Events(bool showOnHover) { - var comp = Context.RenderComponent(p => - { - p.Add(x => x.Delay, delay); - p.Add(x => x.Duration, duration); - }); - - var tooltipComp = comp.FindComponent().Instance; - - // cannot await or it waits until the debounce happens - var eventTask = tooltipComp.HandlePointerEnterAsync(); - if (delay > 0) - tooltipComp.ShowToolTip().Should().BeFalse(); - - await Task.Delay((int)delay + 50); - tooltipComp.ShowToolTip().Should().BeTrue(); + var comp = Context.RenderComponent(parameters => parameters + .Add(x => x.ShowOnHover, showOnHover) + .Add(x => x.ShowOnClick, true) + .Add(x => x.Text, "tooltip text") + ); - await eventTask; // ensure all completed + var div = comp.Find(".mud-tooltip-root"); + div.Should().NotBeNull(); - // cannot await or it waits until the debounce happens - eventTask = tooltipComp.HandlePointerLeaveAsync(); - if (duration > 0) - tooltipComp.ShowToolTip().Should().BeTrue(); + var tooltip = comp.Instance; + tooltip.Should().NotBeNull(); - await Task.Delay((int)(duration + delay) + 50); - tooltipComp.ShowToolTip().Should().BeFalse(); + await tooltip.HandlePointerEnterAsync(); + tooltip.GetState(x => x.Visible).Should().Be(showOnHover); - await eventTask; - } - - [TestCase(true)] - [TestCase(false)] - [Test] - public async Task Tooltip_ShowOnHover(bool showOnHover) - { - var comp = Context.RenderComponent(p => + if (showOnHover) { - p.Add(x => x.ShowOnHover, showOnHover); - }); - // we don't need to await Task.Delay to account for Delay/Duration since the await Handle takes care of it. - var tooltipComp = comp.FindComponent().Instance; - tooltipComp.ShowOnHover.Should().Be(showOnHover); - if (!showOnHover) - { - await tooltipComp.HandlePointerEnterAsync(); - tooltipComp.ShowToolTip().Should().BeFalse(); + await tooltip.HandlePointerLeaveAsync(); + tooltip.GetState(x => x.Visible).Should().Be(!showOnHover); } - else + + await div.PointerEnterAsync(new PointerEventArgs()); + tooltip.GetState(x => x.Visible).Should().Be(showOnHover); + + if (showOnHover) { - await tooltipComp.HandlePointerEnterAsync(); - tooltipComp.ShowToolTip().Should().BeTrue(); + await div.PointerLeaveAsync(new PointerEventArgs()); + tooltip.GetState(x => x.Visible).Should().Be(!showOnHover); } } } diff --git a/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs b/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs index fe3d6f0ac746..f8ab51148fc1 100644 --- a/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs +++ b/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs @@ -2,11 +2,6 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging.Abstractions; @@ -545,7 +540,7 @@ public async Task CreatePopoverAsync_UpdatePopoverAsync_DestroyPopoverAsync_Shou .Callback(signalEvent.Set); jsRuntimeMock.Setup(x => x.InvokeAsync("mudPopover.initialize", It.IsAny(), - It.Is(y => y.Length == 2))) + It.Is(y => y.Length == 3))) .ReturnsAsync(Mock.Of()) .Verifiable(); diff --git a/src/MudBlazor/Components/Menu/MudMenu.razor b/src/MudBlazor/Components/Menu/MudMenu.razor index 6434b4560ae9..f8d45439581a 100644 --- a/src/MudBlazor/Components/Menu/MudMenu.razor +++ b/src/MudBlazor/Components/Menu/MudMenu.razor @@ -92,8 +92,7 @@ @if (ParentMenu is null) { - new CssBuilder() .AddClass(PopoverClass) + .AddClass("mud-popover-nested", ParentMenu is not null) .AddClass("mud-popover-position-override", PositionAtCursor) .Build(); diff --git a/src/MudBlazor/Components/Popover/MudPopoverProvider.razor b/src/MudBlazor/Components/Popover/MudPopoverProvider.razor index ed3a4d69a787..009ebf662e1e 100644 --- a/src/MudBlazor/Components/Popover/MudPopoverProvider.razor +++ b/src/MudBlazor/Components/Popover/MudPopoverProvider.razor @@ -2,7 +2,7 @@ @if (Enabled) { -
+
@foreach (var handler in PopoverService.ActivePopovers) { diff --git a/src/MudBlazor/Components/Tooltip/MudTooltip.razor b/src/MudBlazor/Components/Tooltip/MudTooltip.razor index 3a9f2a6a2449..b51243294822 100644 --- a/src/MudBlazor/Components/Tooltip/MudTooltip.razor +++ b/src/MudBlazor/Components/Tooltip/MudTooltip.razor @@ -5,7 +5,7 @@ @ChildContent @if (ShowToolTip()) { - + @if (TooltipContent is not null) {
diff --git a/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs b/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs index 56f438cee950..d71defc4e02b 100644 --- a/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs +++ b/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Components; using MudBlazor.State; using MudBlazor.Utilities; -using MudBlazor.Utilities.Debounce; namespace MudBlazor { @@ -11,16 +10,8 @@ public partial class MudTooltip : MudComponentBase private readonly ParameterState _visibleState; private Origin _anchorOrigin; private Origin _transformOrigin; - internal DebounceDispatcher _showDebouncer; - internal DebounceDispatcher _hideDebouncer; - internal double _previousDelay; - internal double _previousDuration; public MudTooltip() { - _previousDelay = Delay; - _showDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Delay)); - _previousDuration = Duration; - _hideDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Duration)); using var registerScope = CreateRegisterScope(); _visibleState = registerScope.RegisterParameter(nameof(Visible)) .WithParameter(() => Visible) @@ -172,49 +163,19 @@ public MudTooltip() /// internal bool ShowToolTip() { - if (_anchorOrigin == Origin.TopLeft || _transformOrigin == Origin.TopLeft) - ConvertPlacement(); - return !Disabled && _visibleState.Value && (TooltipContent is not null || !string.IsNullOrEmpty(Text)); + return !Disabled && (TooltipContent is not null || !string.IsNullOrEmpty(Text)); } protected override void OnParametersSet() { base.OnParametersSet(); - if (Math.Abs(_previousDelay - Delay) > .001) - { - _showDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Delay)); - _previousDelay = Delay; - } - - if (Math.Abs(_previousDuration - Duration) > .001) - { - _hideDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Duration)); - _previousDuration = Duration; - } + ConvertPlacement(); } - internal Task HandlePointerEnterAsync() - { - if (!ShowOnHover) - { - return Task.CompletedTask; - } + internal Task HandlePointerEnterAsync() => ShowOnHover ? _visibleState.SetValueAsync(true) : Task.CompletedTask; - _hideDebouncer.Cancel(); - return _showDebouncer.DebounceAsync(() => _visibleState.SetValueAsync(true)); - } - - internal Task HandlePointerLeaveAsync() - { - if (!ShowOnHover) - { - return Task.CompletedTask; - } - - _showDebouncer.Cancel(); - return _hideDebouncer.DebounceAsync(() => _visibleState.SetValueAsync(false)); - } + internal Task HandlePointerLeaveAsync() => ShowOnHover ? _visibleState.SetValueAsync(false) : Task.CompletedTask; private Task HandleFocusInAsync() { diff --git a/src/MudBlazor/Interop/PopoverJsInterop.cs b/src/MudBlazor/Interop/PopoverJsInterop.cs index ea5b6a6d6efd..cc42803a0f0e 100644 --- a/src/MudBlazor/Interop/PopoverJsInterop.cs +++ b/src/MudBlazor/Interop/PopoverJsInterop.cs @@ -2,9 +2,6 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.JSInterop; namespace MudBlazor.Interop; @@ -19,9 +16,9 @@ public PopoverJsInterop(IJSRuntime jsRuntime) _jsRuntime = jsRuntime; } - public ValueTask Initialize(string containerClass, int flipMargin, CancellationToken cancellationToken = default) + public ValueTask Initialize(string containerClass, int flipMargin, int overflowPadding, CancellationToken cancellationToken = default) { - return _jsRuntime.InvokeVoidAsyncWithErrorHandling("mudPopover.initialize", cancellationToken, containerClass, flipMargin); + return _jsRuntime.InvokeVoidAsyncWithErrorHandling("mudPopover.initialize", cancellationToken, containerClass, flipMargin, overflowPadding); } public ValueTask Connect(Guid id, CancellationToken cancellationToken = default) diff --git a/src/MudBlazor/Services/Popover/PopoverOptions.cs b/src/MudBlazor/Services/Popover/PopoverOptions.cs index aeb767338f7e..52a8fdfb5754 100644 --- a/src/MudBlazor/Services/Popover/PopoverOptions.cs +++ b/src/MudBlazor/Services/Popover/PopoverOptions.cs @@ -20,7 +20,7 @@ public class PopoverOptions /// Gets or sets the CSS class of the popover container. /// The default value is mudblazor-main-content. /// - public string ContainerClass { get; set; } = "mudblazor-main-content"; + public string ContainerClass { get; set; } = "mud-popover-provider"; /// /// Gets or sets the FlipMargin for the popover. @@ -34,6 +34,13 @@ public class PopoverOptions /// public TimeSpan QueueDelay { get; set; } = TimeSpan.FromSeconds(0.5); + /// + /// Gets or sets the overflow padding for the popover. This is used when adjusting popovers that go off screen at the top or left. + /// It is also used to create max-height for popovers containing a list that will go off screen. + /// The default value is 24 rougly equal to the 8dp margin of material design. + /// + public int OverflowPadding { get; set; } = 24; + /// /// Gets or sets a value indicating whether to throw an exception when a duplicate is encountered. /// The default value is true. diff --git a/src/MudBlazor/Services/Popover/PopoverService.cs b/src/MudBlazor/Services/Popover/PopoverService.cs index 9cfa8af1fdbf..22946678f65a 100644 --- a/src/MudBlazor/Services/Popover/PopoverService.cs +++ b/src/MudBlazor/Services/Popover/PopoverService.cs @@ -327,7 +327,7 @@ private async Task InitializeServiceIfNeededAsync() return; } - await _popoverJsInterop.Initialize(PopoverOptions.ContainerClass, PopoverOptions.FlipMargin, _cancellationToken); + await _popoverJsInterop.Initialize(PopoverOptions.ContainerClass, PopoverOptions.FlipMargin, PopoverOptions.OverflowPadding, _cancellationToken); // Starts in background await _batchExecutor.StartAsync(_cancellationToken); IsInitialized = true; diff --git a/src/MudBlazor/Styles/components/_popover.scss b/src/MudBlazor/Styles/components/_popover.scss index 8294cff79cea..0975151cdc1f 100644 --- a/src/MudBlazor/Styles/components/_popover.scss +++ b/src/MudBlazor/Styles/components/_popover.scss @@ -3,6 +3,8 @@ z-index: calc(var(--mud-zindex-popover) + 1); position: absolute; opacity: 0; + top: -9999px; + left: -9999px; &.mud-popover-fixed { position: fixed; diff --git a/src/MudBlazor/TScripts/mudPopover.js b/src/MudBlazor/TScripts/mudPopover.js index 318c46e43e0c..d3d2dc705e6b 100644 --- a/src/MudBlazor/TScripts/mudPopover.js +++ b/src/MudBlazor/TScripts/mudPopover.js @@ -3,7 +3,12 @@ // See the LICENSE file in the project root for more information. window.mudpopoverHelper = { + // set by the class MudPopover in initialize + mainContainerClass: null, + overflowPadding: 24, + flipMargin: 0, + // used for setting a debounce debounce: function (func, wait) { let timeout; return function executedFunction(...args) { @@ -16,19 +21,54 @@ window.mudpopoverHelper = { }; }, - rafThrottle: function (func) { - let ticking = false; - return function (...args) { - if (!ticking) { - window.requestAnimationFrame(() => { - func.apply(this, args); - ticking = false; - }); - ticking = true; - } - }; + basePopoverZIndex: parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--mud-zindex-popover')) || 1200, + + baseTooltipZIndex: parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--mud-zindex-tooltip')) || 1600, + + // static set of replacement values + flipClassReplacements: { + 'top': { + 'mud-popover-top-left': 'mud-popover-bottom-left', + 'mud-popover-top-center': 'mud-popover-bottom-center', + 'mud-popover-anchor-bottom-center': 'mud-popover-anchor-top-center', + 'mud-popover-top-right': 'mud-popover-bottom-right', + }, + 'left': { + 'mud-popover-top-left': 'mud-popover-top-right', + 'mud-popover-center-left': 'mud-popover-center-right', + 'mud-popover-anchor-center-right': 'mud-popover-anchor-center-left', + 'mud-popover-bottom-left': 'mud-popover-bottom-right', + }, + 'right': { + 'mud-popover-top-right': 'mud-popover-top-left', + 'mud-popover-center-right': 'mud-popover-center-left', + 'mud-popover-anchor-center-left': 'mud-popover-anchor-center-right', + 'mud-popover-bottom-right': 'mud-popover-bottom-left', + }, + 'bottom': { + 'mud-popover-bottom-left': 'mud-popover-top-left', + 'mud-popover-bottom-center': 'mud-popover-top-center', + 'mud-popover-anchor-top-center': 'mud-popover-anchor-bottom-center', + 'mud-popover-bottom-right': 'mud-popover-top-right', + }, + 'top-and-left': { + 'mud-popover-top-left': 'mud-popover-bottom-right', + }, + 'top-and-right': { + 'mud-popover-top-right': 'mud-popover-bottom-left', + }, + 'bottom-and-left': { + 'mud-popover-bottom-left': 'mud-popover-top-right', + }, + 'bottom-and-right': { + 'mud-popover-bottom-right': 'mud-popover-top-left', + }, + }, + // used to calculate the position of the popover calculatePopoverPosition: function (list, boundingRect, selfRect) { let top = 0; let left = 0; @@ -100,74 +140,30 @@ window.mudpopoverHelper = { } return { - top: top, left: left, offsetX: offsetX, offsetY: offsetY + top: top, left: left, offsetX: offsetX, offsetY: offsetY, anchorY: top, anchorX: left }; }, - flipClassReplacements: { - 'top': { - 'mud-popover-top-left': 'mud-popover-bottom-left', - 'mud-popover-top-center': 'mud-popover-bottom-center', - 'mud-popover-anchor-bottom-center': 'mud-popover-anchor-top-center', - 'mud-popover-top-right': 'mud-popover-bottom-right', - }, - 'left': { - 'mud-popover-top-left': 'mud-popover-top-right', - 'mud-popover-center-left': 'mud-popover-center-right', - 'mud-popover-anchor-center-right': 'mud-popover-anchor-center-left', - 'mud-popover-bottom-left': 'mud-popover-bottom-right', - }, - 'right': { - 'mud-popover-top-right': 'mud-popover-top-left', - 'mud-popover-center-right': 'mud-popover-center-left', - 'mud-popover-anchor-center-left': 'mud-popover-anchor-center-right', - 'mud-popover-bottom-right': 'mud-popover-bottom-left', - }, - 'bottom': { - 'mud-popover-bottom-left': 'mud-popover-top-left', - 'mud-popover-bottom-center': 'mud-popover-top-center', - 'mud-popover-anchor-top-center': 'mud-popover-anchor-bottom-center', - 'mud-popover-bottom-right': 'mud-popover-top-right', - }, - 'top-and-left': { - 'mud-popover-top-left': 'mud-popover-bottom-right', - }, - 'top-and-right': { - 'mud-popover-top-right': 'mud-popover-bottom-left', - }, - 'bottom-and-left': { - 'mud-popover-bottom-left': 'mud-popover-top-right', - }, - 'bottom-and-right': { - 'mud-popover-bottom-right': 'mud-popover-top-left', - }, - - }, - - flipMargin: 0, - - basePopoverZIndex: parseInt(getComputedStyle(document.documentElement) - .getPropertyValue('--mud-zindex-popover')) || 1200, - - baseTooltipZIndex: parseInt(getComputedStyle(document.documentElement) - .getPropertyValue('--mud-zindex-tooltip')) || 1600, - + // used to flip the popover using the flipClassReplacements, so we pass it the flip direction by selector + // with a list of classes and returns the proper flipped position for calculatePopoverPosition getPositionForFlippedPopver: function (inputArray, selector, boundingRect, selfRect) { const classList = []; + const replacementsList = {}; for (var i = 0; i < inputArray.length; i++) { const item = inputArray[i]; - const replacments = window.mudpopoverHelper.flipClassReplacements[selector][item]; - if (replacments) { - classList.push(replacments); + const replacements = window.mudpopoverHelper.flipClassReplacements[selector][item]; + if (replacements) { + replacementsList[item] = replacements; + classList.push(replacements); } else { classList.push(item); } } - return window.mudpopoverHelper.calculatePopoverPosition(classList, boundingRect, selfRect); }, + // primary positioning method placePopover: function (popoverNode, classSelector) { // parentNode is the calling element, mudmenu/tooltip/etc not the parent popover if it's a child popover // this happens at page load unless it's popover inside a popover, then it happens when you activate the parent @@ -176,41 +172,57 @@ window.mudpopoverHelper = { const id = popoverNode.id.substr(8); const popoverContentNode = document.getElementById('popovercontent-' + id); - if (!popoverContentNode) { - return; - } + // if the popover doesn't exist we stop + if (!popoverContentNode) return; + const classList = popoverContentNode.classList; - if (classList.contains('mud-popover-open') == false) { - return; - } + // if the popover isn't open we stop + if (!classList.contains('mud-popover-open')) return; - if (classSelector) { - if (classList.contains(classSelector) == false) { - return; - } - } + // if a classSelector was supplied and doesn't exist we stop + if (classSelector && !classList.contains(classSelector)) return; + + // Batch DOM reads let boundingRect = popoverNode.parentNode.getBoundingClientRect(); - // allow them to be changed after initial creation + const selfRect = popoverContentNode.getBoundingClientRect(); + const popoverNodeStyle = window.getComputedStyle(popoverNode); + const isPositionFixed = popoverNodeStyle.position === 'fixed'; + const isPositionOverride = classList.contains('mud-popover-position-override'); + const isRelativeWidth = classList.contains('mud-popover-relative-width'); + const isAdaptiveWidth = classList.contains('mud-popover-adaptive-width'); + const isFlipOnOpen = classList.contains('mud-popover-overflow-flip-onopen'); + const isFlipAlways = classList.contains('mud-popover-overflow-flip-always'); + const zIndexAuto = popoverNodeStyle.getPropertyValue('z-index') === 'auto'; + const classListArray = Array.from(classList); + + // calculate position based on opening anchor/transform + const position = window.mudpopoverHelper.calculatePopoverPosition(classListArray, boundingRect, selfRect); + let left = position.left; // X-coordinate of the popover + let top = position.top; // Y-coordinate of the popover + let offsetX = position.offsetX; // Horizontal offset of the popover + let offsetY = position.offsetY; // Vertical offset of the popover + let anchorY = position.anchorY; // Y-coordinate of the opening anchor + let anchorX = position.anchorX; // X-coordinate of the opening anchor + + // reset widths and allow them to be changed after initial creation popoverContentNode.style['max-width'] = 'none'; popoverContentNode.style['min-width'] = 'none'; - if (classList.contains('mud-popover-relative-width')) { + if (isRelativeWidth) { popoverContentNode.style['max-width'] = (boundingRect.width) + 'px'; } - else if (classList.contains('mud-popover-adaptive-width')) { + else if (isAdaptiveWidth) { popoverContentNode.style['min-width'] = (boundingRect.width) + 'px'; } - const selfRect = popoverContentNode.getBoundingClientRect(); - const classListArray = Array.from(classList); + // Reset max-height if it was previously set and anchor is in bounds + if (popoverContentNode.mudHeight && anchorY > 0 && anchorY < window.innerHeight) { + popoverContentNode.style.maxHeight = null; + popoverContentNode.mudHeight = null; + } - const postion = window.mudpopoverHelper.calculatePopoverPosition(classListArray, boundingRect, selfRect); - let left = postion.left; - let top = postion.top; - let offsetX = postion.offsetX; - let offsetY = postion.offsetY; // get the top/left/ from popoverContentNode if the popover has been hardcoded for position - if (classList.contains('mud-popover-position-override')) { + if (isPositionOverride) { left = parseInt(popoverContentNode.style['left']) || left; top = parseInt(popoverContentNode.style['top']) || top; // no offset when hardcoded @@ -227,7 +239,7 @@ window.mudpopoverHelper = { }; } // flipping logic - if (classList.contains('mud-popover-overflow-flip-onopen') || classList.contains('mud-popover-overflow-flip-always')) { + if (isFlipOnOpen || isFlipAlways) { const appBarElements = document.getElementsByClassName("mud-appbar mud-appbar-fixed-top"); let appBarOffset = 0; @@ -235,124 +247,235 @@ window.mudpopoverHelper = { appBarOffset = appBarElements[0].getBoundingClientRect().height; } - const graceMargin = window.mudpopoverHelper.flipMargin; - const deltaToLeft = left + offsetX; - const deltaToRight = window.innerWidth - left - selfRect.width; - const deltaTop = top - selfRect.height - appBarOffset; - const spaceToTop = top - appBarOffset; - const deltaBottom = window.innerHeight - top - selfRect.height; - //console.log('self-width: ' + selfRect.width + ' | self-height: ' + selfRect.height); - //console.log('left: ' + deltaToLeft + ' | rigth:' + deltaToRight + ' | top: ' + deltaTop + ' | bottom: ' + deltaBottom + ' | spaceToTop: ' + spaceToTop); - + // mudPopoverFliped is the flip direction for first flip on flip - onopen popovers let selector = popoverContentNode.mudPopoverFliped; + // flip routine off transform origin, sets selector to an axis to flip on if needed if (!selector) { + const popoverHeight = popoverContentNode.offsetHeight; + const popoverWidth = popoverContentNode.offsetWidth; + // For mud-popover-top-left + if (classList.contains('mud-popover-top-left')) { - if (deltaBottom < graceMargin && deltaToRight < graceMargin && spaceToTop >= selfRect.height && deltaToLeft >= selfRect.width) { + // Space available in current direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; // Space below the anchor + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; // Space to the right of the anchor + + // Space available in opposite direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceBelow && spaceAbove > spaceBelow; + const shouldFlipHorizontal = popoverWidth > spaceRight && spaceLeft > spaceRight; + // Apply flips based on space comparisons + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'top-and-left'; - } else if (deltaBottom < graceMargin && spaceToTop >= selfRect.height) { + } + else if (shouldFlipVertical) { selector = 'top'; - } else if (deltaToRight < graceMargin && deltaToLeft >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'left'; } - } else if (classList.contains('mud-popover-top-center')) { - if (deltaBottom < graceMargin && spaceToTop >= selfRect.height) { + } + + // For mud-popover-top-center + else if (classList.contains('mud-popover-top-center')) { + // Space available in current direction vs opposite direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + + // Only flip if popover exceeds available space AND there's more space in opposite direction + if (popoverHeight > spaceBelow && spaceAbove > spaceBelow) { selector = 'top'; } - } else if (classList.contains('mud-popover-top-right')) { - if (deltaBottom < graceMargin && deltaToLeft < graceMargin && spaceToTop >= selfRect.height && deltaToRight >= selfRect.width) { + } + + // For mud-popover-top-right + else if (classList.contains('mud-popover-top-right')) { + // Space available in current direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Space available in opposite direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceBelow && spaceAbove > spaceBelow; + const shouldFlipHorizontal = popoverWidth > spaceLeft && spaceRight > spaceLeft; + + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'top-and-right'; - } else if (deltaBottom < graceMargin && spaceToTop >= selfRect.height) { + } + else if (shouldFlipVertical) { selector = 'top'; - } else if (deltaToLeft < graceMargin && deltaToRight >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'right'; } } + // For mud-popover-center-left else if (classList.contains('mud-popover-center-left')) { - if (deltaToRight < graceMargin && deltaToLeft >= selfRect.width) { + // Space available in current vs opposite direction + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + if (popoverWidth > spaceRight && spaceLeft > spaceRight) { selector = 'left'; } } + + // For mud-popover-center-right else if (classList.contains('mud-popover-center-right')) { - if (deltaToLeft < graceMargin && deltaToRight >= selfRect.width) { + // Space available in current vs opposite direction + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + if (popoverWidth > spaceLeft && spaceRight > spaceLeft) { selector = 'right'; } } + + // For mud-popover-bottom-left else if (classList.contains('mud-popover-bottom-left')) { - if (deltaTop < graceMargin && deltaToRight < graceMargin && deltaBottom >= 0 && deltaToLeft >= selfRect.width) { + // Space available in current direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + // Space available in opposite direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceAbove && spaceBelow > spaceAbove; + const shouldFlipHorizontal = popoverWidth > spaceRight && spaceLeft > spaceRight; + + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'bottom-and-left'; - } else if (deltaTop < graceMargin && deltaBottom >= 0) { + } + else if (shouldFlipVertical) { selector = 'bottom'; - } else if (deltaToRight < graceMargin && deltaToLeft >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'left'; } - } else if (classList.contains('mud-popover-bottom-center')) { - if (deltaTop < graceMargin && deltaBottom >= 0) { + } + + // For mud-popover-bottom-center + else if (classList.contains('mud-popover-bottom-center')) { + // Space available in current vs opposite direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + + if (popoverHeight > spaceAbove && spaceBelow > spaceAbove) { selector = 'bottom'; } - } else if (classList.contains('mud-popover-bottom-right')) { - if (deltaTop < graceMargin && deltaToLeft < graceMargin && deltaBottom >= 0 && deltaToRight >= selfRect.width) { + } + + // For mud-popover-bottom-right + else if (classList.contains('mud-popover-bottom-right')) { + // Space available in current direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Space available in opposite direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceAbove && spaceBelow > spaceAbove; + const shouldFlipHorizontal = popoverWidth > spaceLeft && spaceRight > spaceLeft; + + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'bottom-and-right'; - } else if (deltaTop < graceMargin && deltaBottom >= 0) { + } + else if (shouldFlipVertical) { selector = 'bottom'; - } else if (deltaToLeft < graceMargin && deltaToRight >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'right'; } } + } + // selector is set in above if statement if it needs to flip if (selector && selector != 'none') { const newPosition = window.mudpopoverHelper.getPositionForFlippedPopver(classListArray, selector, boundingRect, selfRect); left = newPosition.left; top = newPosition.top; offsetX = newPosition.offsetX; offsetY = newPosition.offsetY; - popoverContentNode.setAttribute('data-mudpopover-flip', 'flipped'); + popoverContentNode.setAttribute('data-mudpopover-flip', selector); } else { - // did not flip, ensure the left and top are inside bounds - // appbaroffset is another section - if (left + offsetX < 0 && // it's starting left of the screen - Math.abs(left + offsetX) < selfRect.width) { // it's not starting so far left the entire box would be hidden - left = Math.max(0, left + offsetX); - // set offsetX to 0 to avoid double offset - offsetX = 0; - } + popoverContentNode.removeAttribute('data-mudpopover-flip'); + } - // will be covered by appbar so adjust zindex with appbar as parent - if (top + offsetY < appBarOffset && - appBarElements.length > 0) { - this.updatePopoverZIndex(popoverContentNode, appBarElements[0]); - //console.log(`top: ${top} | offsetY: ${offsetY} | total: ${top + offsetY} | appBarOffset: ${appBarOffset}`); + if (isFlipOnOpen) { // store flip direction on open so it's not recalculated + if (!popoverContentNode.mudPopoverFliped) { + popoverContentNode.mudPopoverFliped = selector || 'none'; } + } - if (top + offsetY < 0 && // it's starting above the screen - Math.abs(top + offsetY) < selfRect.height) { // it's not starting so far above the entire box would be hidden - top = Math.max(0, top + offsetY); - // set offsetY to 0 to avoid double offset - offsetY = 0; - } + // ensure the left is inside bounds + if (left + offsetX < window.mudpopoverHelper.overflowPadding && // it's starting left of the screen + Math.abs(left + offsetX) < selfRect.width) { // it's not starting so far left the entire box would be hidden + left = window.mudpopoverHelper.overflowPadding; + // set offsetX to 0 to avoid double offset + offsetX = 0; + } - // if it contains a mud-list set that mud-list max-height to be the remaining size on screen - const list = popoverContentNode.querySelector('.mud-list'); - const listPadding = 24; - const listMaxHeight = (window.innerHeight - top - offsetY); - // is list defined and does the list calculated height exceed the listmaxheight - if (list && list.offsetHeight > listMaxHeight) { - list.style.maxHeight = (listMaxHeight - listPadding) + 'px'; - } - popoverContentNode.removeAttribute('data-mudpopover-flip'); + // ensure the top is inside bounds + if (top + offsetY < window.mudpopoverHelper.overflowPadding && // it's starting above the screen + boundingRect.top >= 0 && // the popoverNode is still on screen + Math.abs(top + offsetY) < selfRect.height) { // it's not starting so far above the entire box would be hidden + top = window.mudpopoverHelper.overflowPadding; + // set offsetY to 0 to avoid double offset + offsetY = 0; } - if (classList.contains('mud-popover-overflow-flip-onopen')) { - if (!popoverContentNode.mudPopoverFliped) { - popoverContentNode.mudPopoverFliped = selector || 'none'; + // will be covered by appbar so adjust zindex with appbar as parent + if (top + offsetY < appBarOffset && + appBarElements.length > 0) { + this.updatePopoverZIndex(popoverContentNode, appBarElements[0]); + } + + const firstChild = popoverContentNode.firstElementChild; + + // adjust the popover position/maxheight if it or firstChild does not have a max-height set (even if set to 'none') + // exceeds the bounds and doesn't have a max-height set by the user + // maxHeight adjustments stop the minute popoverNode is no longer inside the window + // Check if max-height is set on popover or firstChild + const hasMaxHeight = popoverContentNode.style.maxHeight != '' || (firstChild && firstChild.style.maxHeight != ''); + + if (!hasMaxHeight) { + // in case of a reflow check it should show from top properly + let shouldShowFromTop = false; + // calculate new max height if it exceeds bounds + let newMaxHeight = window.innerHeight - top - offsetY - window.mudpopoverHelper.overflowPadding; // downwards + // moving upwards + if (top + offsetY < anchorY || top + offsetY == window.mudpopoverHelper.overflowPadding) { + shouldShowFromTop = true; + newMaxHeight = anchorY - window.mudpopoverHelper.overflowPadding; + } + + // if calculated height exceeds the new maxheight + if (popoverContentNode.offsetHeight > newMaxHeight) { + if (shouldShowFromTop) { // adjust top to show from top + top = window.mudpopoverHelper.overflowPadding; + offsetY = 0; + } + popoverContentNode.style.maxHeight = (newMaxHeight) + 'px'; + popoverContentNode.mudHeight = "setmaxheight"; } } } - if (window.getComputedStyle(popoverNode).position == 'fixed') { + if (isPositionFixed) { popoverContentNode.style['position'] = 'fixed'; } else if (!classList.contains('mud-popover-fixed')) { @@ -360,7 +483,7 @@ window.mudpopoverHelper = { offsetY += window.scrollY } - if (classList.contains('mud-popover-position-override')) { + if (isPositionOverride) { // no offset if popover position is hardcoded offsetX = 0; offsetY = 0; @@ -371,11 +494,10 @@ window.mudpopoverHelper = { // update z-index by sending the calling popover to update z-index, // and the parentnode of the calling popover (not content parent) - //console.log(popoverContentNode, popoverNode.parentNode); this.updatePopoverZIndex(popoverContentNode, popoverNode.parentNode); - if (window.getComputedStyle(popoverNode).getPropertyValue('z-index') != 'auto') { - popoverContentNode.style['z-index'] = Math.max(window.getComputedStyle(popoverNode).getPropertyValue('z-index'), popoverContentNode.style['z-index']); + if (!zIndexAuto) { + popoverContentNode.style['z-index'] = Math.max(popoverNodeStyle.getPropertyValue('z-index'), popoverContentNode.style['z-index']); popoverContentNode.skipZIndex = true; } } @@ -384,55 +506,35 @@ window.mudpopoverHelper = { } }, - popoverScrollListener: function (node) { - let currentNode = node.parentNode; - while (currentNode) { - //console.log(currentNode); - const isScrollable = - (currentNode.scrollHeight > currentNode.clientHeight) || // Vertical scroll - (currentNode.scrollWidth > currentNode.clientWidth); // Horizontal scroll - if (isScrollable) { - //console.log("scrollable"); - currentNode.addEventListener('scroll', () => { - //console.log("scrolled"); - window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-fixed'); - window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-overflow-flip-always'); - }); - } - // Stop if we reach the body, or head - if (currentNode.tagName === "BODY") { - break; - } - currentNode = currentNode.parentNode; - } - }, - + // cycles through popovers to reposition those that are open, classSelector is passed on placePopoverByClassSelector: function (classSelector = null) { var items = window.mudPopover.getAllObservedContainers(); - for (let i = 0; i < items.length; i++) { const popoverNode = document.getElementById('popover-' + items[i]); window.mudpopoverHelper.placePopover(popoverNode, classSelector); } }, + // used in the initial placement of a popover placePopoverByNode: function (target) { const id = target.id.substr(15); const popoverNode = document.getElementById('popover-' + id); window.mudpopoverHelper.placePopover(popoverNode); }, + // returns the count of providers countProviders: function () { - return document.querySelectorAll(".mud-popover-provider").length; + return document.querySelectorAll(`.${window.mudpopoverHelper.mainContainerClass}`).length; }, + // sets popoveroverlay to the right z-index updatePopoverOverlay: function (popoverContentNode) { // tooltips don't have an overlay if (popoverContentNode.classList.contains("mud-tooltip")) { return; } // set any associated overlay to equal z-index - const provider = popoverContentNode.closest('.mud-popover-provider'); + const provider = popoverContentNode.closest(`.${window.mudpopoverHelper.mainContainerClass}`); if (provider && popoverContentNode.classList.contains("mud-popover")) { const overlay = provider.querySelector('.mud-overlay'); // skip any overlay marked with mud-skip-overlay @@ -446,6 +548,7 @@ window.mudpopoverHelper = { } }, + // set zindex order updatePopoverZIndex: function (popoverContentNode, parentNode) { // find the first parent mud-popover if it exists const parentPopover = parentNode.closest('.mud-popover'); @@ -501,6 +604,27 @@ window.mudpopoverHelper = { popoverContentNode.style['z-index'] = newZIndex; } }, + + // adds scroll listeners to node + parents up to body + popoverScrollListener: function (node) { + let currentNode = node.parentNode; + const scrollableElements = []; + while (currentNode) { + const isScrollable = + (currentNode.scrollHeight > currentNode.clientHeight) || // Vertical scroll + (currentNode.scrollWidth > currentNode.clientWidth); // Horizontal scroll + if (isScrollable) { + currentNode.addEventListener('scroll', window.mudpopoverHelper.handleScroll, { passive: true }); + scrollableElements.push(currentNode); + } + // Stop if we reach the body, or head + if (currentNode.tagName === "BODY") { + break; + } + currentNode = currentNode.parentNode; + } + return scrollableElements; + }, } class MudPopover { @@ -508,210 +632,290 @@ class MudPopover { constructor() { this.map = {}; this.contentObserver = null; - this.mainContainerClass = null; } - callback(id, mutationsList, observer) { - for (const mutation of mutationsList) { - if (mutation.type === 'attributes') { - const target = mutation.target - if (mutation.attributeName == 'class') { - if (target.classList.contains('mud-popover-overflow-flip-onopen') && - target.classList.contains('mud-popover-open') == false) { - target.mudPopoverFliped = null; - target.removeAttribute('data-mudpopover-flip'); - } + createObservers(id) { + // this is the origin of the popover in the dom, it can be nested inside another popover's content + // e.g. the filter popover for datagrid, this would be the inside of where the mudpopover was placed + // popoverNode.parentNode is it's immediate parent or the actual element in the above example + const popoverNode = document.getElementById('popover-' + id); - window.mudpopoverHelper.placePopoverByNode(target); - } - else if (mutation.attributeName == 'data-ticks') { - // data-ticks are important for Direction and Location, it doesn't reposition - // if they aren't there - const tickAttribute = target.getAttribute('data-ticks'); - - const tickValues = []; - let max = -1; - if (parent && parent.children) { - for (let i = 0; i < parent.children.length; i++) { - const childNode = parent.children[i]; - const tickValue = parseInt(childNode.getAttribute('data-ticks')); - if (tickValue == 0) { - continue; - } - - if (tickValues.indexOf(tickValue) >= 0) { - continue; - } - - tickValues.push(tickValue); - - if (tickValue > max) { - max = tickValue; - } - } - } + // this is the content node in the provider regardless of the RenderFragment that exists when the popover is active + const popoverContentNode = document.getElementById('popovercontent-' + id); - // Iterate over the items in this.map to reset any open overlays - let highestTickItem = null; - let highestTickValue = -1; - // Iterate over the items in this.map to find the highest data-ticks value - for (const mapItem of Object.values(this.map)) { - const popoverContentNode = mapItem.popoverContentNode; - if (popoverContentNode) { - const tickValue = Number(popoverContentNode.getAttribute('data-ticks')); // Convert to Number - - if (tickValue > highestTickValue) { - highestTickValue = tickValue; - highestTickItem = popoverContentNode; - } + if (popoverNode && popoverNode.parentNode && popoverContentNode) { + // add a resize observer to catch resize events + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const target = entry.target; + for (const childNode of target.childNodes) { + if (childNode.id && childNode.id.startsWith('popover-')) { + window.mudpopoverHelper.debouncedResize(); } } + } + }); - if (highestTickItem) { - window.mudpopoverHelper.updatePopoverOverlay(highestTickItem); - } + resizeObserver.observe(popoverNode.parentNode); - if (tickValues.length == 0) { - continue; - } + // Add scroll event listeners to the content node and its parents up to the Body + const scrollableElements = window.mudpopoverHelper.popoverScrollListener(popoverNode); - const sortedTickValues = tickValues.sort((x, y) => x - y); - // z-index calculation not used here - continue; - for (let i = 0; i < parent.children.length; i++) { - const childNode = parent.children[i]; - const tickValue = parseInt(childNode.getAttribute('data-ticks')); - if (tickValue == 0) { - continue; - } + // Store all references needed for later cleanup + this.map[id].scrollableElements = scrollableElements; + this.map[id].parentResizeObserver = resizeObserver; + + } else { + console.warn(`Could not connect observers to popover with ID ${id}: One or more required elements not found`); + } + } + + disposeObservers(id) { + // Get references to items that need cleanup + const { scrollableElements, parentResizeObserver } = this.map[id]; - if (childNode.skipZIndex == true) { - continue; + // 1. Remove scroll event listeners from all scrollable parent elements + if (scrollableElements && Array.isArray(scrollableElements)) { + scrollableElements.forEach(element => { + if (element && typeof element.removeEventListener === 'function') { + element.removeEventListener('scroll', window.mudpopoverHelper.handleScroll); + } + }); + } + + // 2. Disconnect any resize observers + if (parentResizeObserver && typeof parentResizeObserver.disconnect === 'function') { + parentResizeObserver.disconnect(); + } + + // 3. Clear references to allow garbage collection + this.map[id].scrollableElements = null; + this.map[id].parentResizeObserver = null; + } + + callbackPopover(mutation) { + const target = mutation.target; + if (!target) return; + const id = target.id.substr(15); + if (mutation.type == 'attributes' && mutation.attributeName == 'class') { + if (target.classList.contains('mud-popover-open')) { + // setup for an open popover and create observers + if (this.map[id] && !this.map[id].isOpened) { + this.map[id].isOpened = true; + } + // create observers for this popover (resizeObserver and scroll Listeners) + this.createObservers(id); + + // reposition popover individually + window.mudpopoverHelper.placePopoverByNode(target); + } + else { + // tell the map that this popover is closed + if (this.map[id] && this.map[id].isOpened) { + this.map[id].isOpened = false; + } + // wait this long until we "move it off screen" + const delay = parseFloat(target.style['transition-duration']) || 0; + if (delay == 0) { + // remove left and top styles + target.style.removeProperty('left'); + target.style.removeProperty('top'); + } + else { + setTimeout(() => { + if (this.map[id] && this.map[id].isOpened) return; // in case it's reopened before the timeout is over + if (target && !target.classList.contains('mud-popover-open')) { + target.style.removeProperty('left'); + target.style.removeProperty('top'); + } + }, delay); + } + // reset flip status + target.mudPopoverFliped = null; + target.removeAttribute('data-mudpopover-flip'); + + // Remove individual observers and listeners that might exist + this.disposeObservers(id); + } + } + else if (mutation.type == 'attributes' && mutation.attributeName == 'data-ticks') { + // when data-ticks attribute is the mutation something has changed with the popover + // and it needs to be repositioned and shown, note we don't use mud-popover-open here + // instead we use data-ticks since we know the newest data-ticks > 0 is the top most. + const tickAttribute = target.getAttribute('data-ticks'); + // data ticks is not 0 so let's reposition the popover and overlay + + if (tickAttribute > 0 && target.parentNode && this.map[id] && this.map[id].isOpened && + target.parentNode.classList.contains(window.mudpopoverHelper.mainContainerClass)) { + // reposition popover individually + window.mudpopoverHelper.placePopoverByNode(target); + + // check and reposition overlay if needed, positions and z-index can change during a reflow so leave it in data-ticks + let highestTickItem = null; + let highestTickValue = -1; + + // Traverse children of target.parentNode that contain the class "mud-popover" + for (const child of target.parentNode.children) { + if (child.classList.contains("mud-popover")) { + const tickValue = Number(child.getAttribute("data-ticks")) || 0; + + if (tickValue > highestTickValue) { + highestTickValue = tickValue; + highestTickItem = child; } - const newIndex = window.mudpopoverHelper.basePopoverZIndex + sortedTickValues.indexOf(tickValue) + 3; - childNode.style['z-index'] = newIndex; } } + + const isNested = highestTickItem.classList.contains('mud-popover-nested'); + + if (highestTickItem && !isNested) { + window.mudpopoverHelper.updatePopoverOverlay(highestTickItem); + } } } } - initialize(containerClass, flipMargin) { + initialize(containerClass, flipMargin, overflowPadding) { + // only happens when the PopoverService is created which happens on application start and anytime the service might crash const mainContent = document.getElementsByClassName(containerClass); if (mainContent.length == 0) { + console.error(`No Popover Container found with class ${containerClass}`); return; } + // store options from PopoverOptions in mudpopoverHelper + window.mudpopoverHelper.mainContainerClass = containerClass; + window.mudpopoverHelper.overflowPadding = overflowPadding; if (flipMargin) { window.mudpopoverHelper.flipMargin = flipMargin; } + // create a single observer to watch all popovers in the provider + const provider = mainContent[0]; + + // options to observe for + const config = { + attributes: true, // only observe attributes + subtree: true, // all descendants of popover + attributeFilter: ['data-ticks','class'] // limit to just data-ticks and class changes + }; - this.mainContainerClass = containerClass; + // Dispose of any existing observer before creating a new one + if (this.contentObserver) { + this.contentObserver.disconnect(); + this.contentObserver = null; + } - if (!mainContent[0].mudPopoverMark) { - mainContent[0].mudPopoverMark = "mudded"; - if (this.contentObserver != null) { - this.contentObserver.disconnect(); - this.contentObserver = null; + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // if it's direct parent is the provider + // and contains the class mud-popover + if (mutation.target.parentNode === provider && mutation.target.classList.contains('mud-popover')) { + this.callbackPopover(mutation); + } } + }); - this.contentObserver = new ResizeObserver(entries => { - window.mudpopoverHelper.placePopoverByClassSelector(); - }); + observer.observe(provider, config); + // store it so we can dispose of it properly + this.contentObserver = observer; - this.contentObserver.observe(mainContent[0]); - } + // setup event listeners + window.addEventListener('resize', window.mudpopoverHelper.debouncedResize, { passive: true }); + window.addEventListener('scroll', window.mudpopoverHelper.handleScroll, { passive: true }); } + /** + * Connects a popover element to the system, setting up all necessary event listeners and observers + * @param {string} id - The ID of the popover to connect + */ connect(id) { - this.initialize(this.mainContainerClass); + // this happens when a popover is created in the dom (not necessarily displayed) + // Ensure we're not creating duplicate listeners for the same ID + if (this.map[id]) { + this.disconnect(id); + } + // this is the origin of the popover in the dom, it can be nested inside another popover's content + // e.g. the filter popover for datagrid, this would be the inside of where the mudpopover was placed + // popoverNode.parentNode is it's immediate parent or the actual element in the above example const popoverNode = document.getElementById('popover-' + id); - mudpopoverHelper.popoverScrollListener(popoverNode); - const popoverContentNode = document.getElementById('popovercontent-' + id); - if (popoverNode && popoverNode.parentNode && popoverContentNode) { - - window.mudpopoverHelper.placePopover(popoverNode); - - const config = { attributeFilter: ['class', 'data-ticks'] }; - - const observer = new MutationObserver(this.callback.bind(this, id)); - - observer.observe(popoverContentNode, config); - - const resizeObserver = new ResizeObserver(entries => { - for (let entry of entries) { - const target = entry.target; - for (const childNode of target.childNodes) { - if (childNode.id && childNode.id.startsWith('popover-')) { - window.mudpopoverHelper.placePopover(childNode); - } - } - } - }); - resizeObserver.observe(popoverNode.parentNode); - - const contentNodeObserver = new ResizeObserver(entries => { - for (let entry of entries) { - const target = entry.target; - if (target) - window.mudpopoverHelper.placePopoverByNode(target); - } - }); - - contentNodeObserver.observe(popoverContentNode); + // this is the content node in the provider regardless of the RenderFragment that exists when the popover is active + const popoverContentNode = document.getElementById('popovercontent-' + id); - this.map[id] = { - popoverContentNode: popoverContentNode, - mutationObserver: observer, - resizeObserver: resizeObserver, - contentNodeObserver: contentNodeObserver - }; - } + // Store all references needed for later cleanup + this.map[id] = { + popoverContentNode: popoverContentNode, + scrollableElements: null, + parentResizeObserver: null, + isOpened: false + }; } + /** + * Disconnects a popover element, properly cleaning up all event listeners and observers + * @param {string} id - The ID of the popover to disconnect + */ disconnect(id) { - if (this.map[id]) { + if (!this.map[id]) { + return; // Nothing to disconnect + } + + try { + // 1. Remove individual observers and listeners that might exist + this.disposeObservers(id); - const item = this.map[id] - item.mutationObserver.disconnect(); - item.resizeObserver.disconnect(); - item.contentNodeObserver.disconnect(); + // 2. Clear final reference to allow garbage collection + this.map[id].popoverContentNode = null; + // 3. Remove this entry from the map delete this.map[id]; + } catch (error) { + console.error(`Error disconnecting popover with ID ${id}:`, error); } } + /** + * Disposes all resources used by this MudPopover instance + * Should be called when the component is being unmounted + */ dispose() { - for (var i in this.map) { - disconnect(i); - } + try { + // 1. Disconnect all popovers + const ids = Object.keys(this.map); + for (const id of ids) { + this.disconnect(id); + } - this.contentObserver.disconnect(); - this.contentObserver = null; - } + // 2. Ensure map is empty + this.map = {}; - getAllObservedContainers() { - const result = []; - for (var i in this.map) { - result.push(i); + // 3. Disconnect the content observer + if (this.contentObserver) { + this.contentObserver.disconnect(); + this.contentObserver = null; + } + + // 4. Remove global event listeners (handled outside this class, listed here for reference) + window.removeEventListener('resize', window.mudpopoverHelper.debouncedResize); + window.removeEventListener('scroll', window.mudpopoverHelper.handleScroll); + } catch (error) { + console.error("Error disposing MudPopover:", error); } + } - return result; + getAllObservedContainers() { + return Object.keys(this.map); } } -window.mudPopover = new MudPopover(); - -const debouncedResize = window.mudpopoverHelper.debounce(() => { +window.mudpopoverHelper.debouncedResize = window.mudpopoverHelper.debounce(() => { window.mudpopoverHelper.placePopoverByClassSelector(); -}, 100); +}, 25); -const throttledScroll = window.mudpopoverHelper.rafThrottle(() => { +window.mudpopoverHelper.handleScroll = function () { window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-fixed'); window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-overflow-flip-always'); -}); +}; -window.addEventListener('resize', debouncedResize, { passive: true }); -window.addEventListener('scroll', throttledScroll, { passive: true }); \ No newline at end of file +window.mudPopover = new MudPopover(); \ No newline at end of file