Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/Core/Components/DateTime/FluentTimePicker.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,9 +15,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components;
/// <summary />
public partial class FluentTimePicker<TValue> : FluentInputBase<TValue>
{
private const string JavaScriptFile = FluentJSModule.JAVASCRIPT_ROOT + "DateTime/FluentTimePicker.razor.js";

private static readonly IEqualityComparer<DateTime?> TimeComparer = new TimeEqualityComparer();
private DateTime DefaultTime => Culture.Calendar.MinSupportedDateTime;
private FluentCombobox<DateTime?, DateTime?> _fluentCombobox = default!;
private string? _lastScrolledValue;
private bool _scrollModuleInitialized;

/// <summary />
public FluentTimePicker(LibraryConfiguration configuration) : base(configuration)
Expand Down Expand Up @@ -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<ILoggerFactory>();
loggerFactory?.CreateLogger(GetType()).LogError(exception, "Unable to initialize FluentTimePicker auto-scroll.");
}
}
}

/// <summary />
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<ILoggerFactory>();
loggerFactory?.CreateLogger(GetType()).LogError(exception, "Unable to dispose FluentTimePicker auto-scroll.");
}
}
}

/// <summary />
Expand Down
105 changes: 105 additions & 0 deletions src/Core/Components/DateTime/FluentTimePicker.razor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export namespace Microsoft {
export namespace FluentUI {
export namespace Blazor {
export namespace TimePicker {

const pendingScrolls = new Map<string, number>();
const initializedDropdowns = new WeakMap<HTMLElement, boolean>();
const cleanupCallbacks = new WeakMap<HTMLElement, () => 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;
}
}
}
}
}
27 changes: 27 additions & 0 deletions tests/Core/Components/DateTimes/FluentTimePickerTests.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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(@<FluentTimePicker TValue="DateTime"
Culture="@InvariantCulture"
RenderStyle="DatePickerRenderStyle.FluentUI"
Value="@(new DateTime(2023, 1, 1, 17, 30, 0))" />);
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()
{
Expand Down