diff --git a/src/Core/Components/DateTime/FluentTimePicker.razor.cs b/src/Core/Components/DateTime/FluentTimePicker.razor.cs index ccbdac17c9..b3f1776463 100644 --- a/src/Core/Components/DateTime/FluentTimePicker.razor.cs +++ b/src/Core/Components/DateTime/FluentTimePicker.razor.cs @@ -6,6 +6,7 @@ using System.Globalization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; using Microsoft.FluentUI.AspNetCore.Components.Calendar; using Microsoft.JSInterop; @@ -14,9 +15,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public partial class FluentTimePicker : FluentInputBase { + private const string JavaScriptFile = FluentJSModule.JAVASCRIPT_ROOT + "DateTime/FluentTimePicker.razor.js"; + private static readonly IEqualityComparer TimeComparer = new TimeEqualityComparer(); private DateTime DefaultTime => Culture.Calendar.MinSupportedDateTime; private FluentCombobox _fluentCombobox = default!; + private string? _lastScrolledValue; + private bool _scrollModuleInitialized; /// public FluentTimePicker(LibraryConfiguration configuration) : base(configuration) @@ -172,6 +177,54 @@ await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Utilities.Attributes. "[part='control']", "step", Increment); } + + if (IsFluentUIStyle) + { + try + { + if (!_scrollModuleInitialized) + { + var module = await JSModule.ImportJavaScriptModuleAsync(JavaScriptFile); + await module.InvokeVoidAsync("Microsoft.FluentUI.Blazor.TimePicker.Initialize", Id); + _scrollModuleInitialized = true; + } + + var selectedValue = FormatValueAsString(SelectedValue); + if (firstRender || !string.Equals(selectedValue, _lastScrolledValue, StringComparison.Ordinal)) + { + var module = JSModule.ObjectReference; + if (module is null) + { + throw new InvalidOperationException("The FluentTimePicker JavaScript module is not initialized."); + } + + await module.InvokeVoidAsync("Microsoft.FluentUI.Blazor.TimePicker.ScrollToSelectedValue", Id, selectedValue); + _lastScrolledValue = selectedValue; + } + } + catch (JSException exception) + { + var loggerFactory = GetCachedServiceOrNull(); + loggerFactory?.CreateLogger(GetType()).LogError(exception, "Unable to initialize FluentTimePicker auto-scroll."); + } + } + } + + /// + protected override async ValueTask DisposeAsync(IJSObjectReference jsModule) + { + if (_scrollModuleInitialized) + { + try + { + await jsModule.InvokeVoidAsync("Microsoft.FluentUI.Blazor.TimePicker.Dispose", Id); + } + catch (JSException exception) + { + var loggerFactory = GetCachedServiceOrNull(); + loggerFactory?.CreateLogger(GetType()).LogError(exception, "Unable to dispose FluentTimePicker auto-scroll."); + } + } } /// diff --git a/src/Core/Components/DateTime/FluentTimePicker.razor.ts b/src/Core/Components/DateTime/FluentTimePicker.razor.ts new file mode 100644 index 0000000000..8357c59953 --- /dev/null +++ b/src/Core/Components/DateTime/FluentTimePicker.razor.ts @@ -0,0 +1,105 @@ +export namespace Microsoft { + export namespace FluentUI { + export namespace Blazor { + export namespace TimePicker { + + const pendingScrolls = new Map(); + const initializedDropdowns = new WeakMap(); + const cleanupCallbacks = new WeakMap void>(); + const defaultScrollDelayMs = 50; + + function scheduleScroll(id: string, value: string | null | undefined, delay: number = 0) { + const key = `${id}:${value ?? ""}`; + const existing = pendingScrolls.get(key); + if (existing !== undefined) { + window.clearTimeout(existing); + } + + const timeoutId = window.setTimeout(() => { + pendingScrolls.delete(key); + ScrollToSelectedValue(id, value); + }, delay); + + pendingScrolls.set(key, timeoutId); + } + + export function Initialize(id: string) { + const dropdown = document.getElementById(id) as HTMLElement | null; + if (!dropdown || initializedDropdowns.has(dropdown)) { + return; + } + + initializedDropdowns.set(dropdown, true); + + // Use the DOM-selected option when the dropdown is opened or edited. + const handleOpen = () => scheduleScroll(id, null, defaultScrollDelayMs); + const handleInput = () => scheduleScroll(id, null); + const handleKeyDown = (event: KeyboardEvent) => { + if (["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End"].includes(event.key)) { + scheduleScroll(id, null); + } + }; + + dropdown.addEventListener("click", handleOpen); + dropdown.addEventListener("focusin", handleOpen); + dropdown.addEventListener("input", handleInput); + dropdown.addEventListener("change", handleInput); + dropdown.addEventListener("keydown", handleKeyDown); + + cleanupCallbacks.set(dropdown, () => { + dropdown.removeEventListener("click", handleOpen); + dropdown.removeEventListener("focusin", handleOpen); + dropdown.removeEventListener("input", handleInput); + dropdown.removeEventListener("change", handleInput); + dropdown.removeEventListener("keydown", handleKeyDown); + }); + + scheduleScroll(id, null, defaultScrollDelayMs); + } + + export function Dispose(id: string) { + const dropdown = document.getElementById(id) as HTMLElement | null; + if (!dropdown) { + return; + } + + const cleanup = cleanupCallbacks.get(dropdown); + cleanup?.(); + + cleanupCallbacks.delete(dropdown); + initializedDropdowns.delete(dropdown); + } + + export function ScrollToSelectedValue(id: string, value: string | null | undefined) { + const dropdown = document.getElementById(id) as HTMLElement | null; + if (!dropdown) { + return; + } + + const listbox = dropdown.querySelector("fluent-listbox") as HTMLElement | null; + if (!listbox) { + return; + } + + const options = Array.from(listbox.querySelectorAll("fluent-option")) as HTMLElement[]; + const selectedOption = findSelectedOption(options, value) ?? findSelectedOption(options, null); + + if (selectedOption) { + selectedOption.scrollIntoView({ block: "start", inline: "nearest" }); + } + } + + function findSelectedOption(options: HTMLElement[], value: string | null | undefined) { + if (value) { + const matchByValue = options.find(option => option.getAttribute("value") === value); + if (matchByValue) { + return matchByValue; + } + } + + return options.find(option => option.hasAttribute("selected") || option.getAttribute("aria-selected") === "true") ?? null; + } + } + } + } +} diff --git a/tests/Core/Components/DateTimes/FluentTimePickerTests.razor b/tests/Core/Components/DateTimes/FluentTimePickerTests.razor index 5499616076..835fa15d09 100644 --- a/tests/Core/Components/DateTimes/FluentTimePickerTests.razor +++ b/tests/Core/Components/DateTimes/FluentTimePickerTests.razor @@ -17,6 +17,11 @@ Services.AddFluentUIComponents(); } + private static bool IsTimePickerJsModule(object? argument) + { + return argument?.ToString()?.EndsWith("DateTime/FluentTimePicker.razor.js") == true; + } + [Fact] public void FluentTimePicker_Default() { @@ -240,6 +245,28 @@ Assert.Empty(timeInputs); } + [Fact] + public void FluentTimePicker_FluentUIStyle_InitializesDropdownScrollHook() + { + // Arrange + var module = JSInterop.SetupModule(matcher => matcher.Arguments.Any(IsTimePickerJsModule)); + module.SetupVoid("Microsoft.FluentUI.Blazor.TimePicker.Initialize", _ => true); + module.SetupVoid("Microsoft.FluentUI.Blazor.TimePicker.ScrollToSelectedValue", _ => true); + module.SetupVoid("Microsoft.FluentUI.Blazor.TimePicker.Dispose", _ => true); + + // Act + var cut = Render(@); + cut.Dispose(); + + // Assert + Assert.Contains(module.Invocations, invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.TimePicker.Initialize"); + Assert.Contains(module.Invocations, invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.TimePicker.ScrollToSelectedValue"); + Assert.Contains(module.Invocations, invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.TimePicker.Dispose"); + } + [Fact] public void FluentTimePicker_NativeStyle_ShowsTextInputWithTimeType() {