feat(MilkStream.Client): add album search dropdown component

This commit is contained in:
2026-08-22 02:32:48 +02:00
parent 451c131711
commit 677a3816d2
2 changed files with 140 additions and 0 deletions
@@ -0,0 +1,135 @@
@using Butter.Dtos.Album
@inject AlbumService albumService
@inject IJSRuntime JSRuntime
<div class="position-relative">
<input type="text" class="form-control"
placeholder="@Placeholder"
value="@Query"
@ref="albumInputRef"
@oninput="OnInput"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showAlbumDropdown) {
<div class="dropdown-menu show album-dropdown-menu"
@ref="albumDropdownRef">
@if (isSearching) {
<span class="dropdown-item disabled">Searching…</span>
} else if (Query.Length < 2) {
<span class="dropdown-item disabled">Type at least 2 characters to search</span>
} else if (searchResults is null || searchResults.Count == 0) {
<span class="dropdown-item disabled">No matching albums</span>
} else {
@foreach (var album in searchResults) {
<button type="button"
class="dropdown-item @(SelectedAlbumId == album.Id ? "active" : "")"
@onclick="() => SelectAlbum(album)">
@album.Name
@if (!string.IsNullOrEmpty(album.PersonName)) {
<small class="text-muted ms-1">@album.PersonName</small>
}
</button>
}
}
</div>
}
</div>
@code {
/// <summary>
/// Gets or sets the current search text (two-way bound so the owner can prefill/reset it).
/// </summary>
[Parameter] public string Query { get; set; } = "";
/// <summary>
/// Gets or sets the callback fired as <see cref="Query"/> changes.
/// </summary>
[Parameter] public EventCallback<string> QueryChanged { get; set; }
/// <summary>
/// Gets or sets the currently selected album ID, used to highlight the active result.
/// </summary>
[Parameter] public Guid? SelectedAlbumId { get; set; }
/// <summary>
/// Gets or sets the callback fired when an album is selected.
/// </summary>
[Parameter] public EventCallback<AlbumPreviewDto> OnAlbumSelected { get; set; }
/// <summary>
/// Gets or sets the input placeholder text.
/// </summary>
[Parameter] public string Placeholder { get; set; } = "Search albums…";
bool showAlbumDropdown;
CancellationTokenSource? blurCts;
List<AlbumPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
ElementReference albumInputRef;
ElementReference albumDropdownRef;
bool pendingPosition;
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showAlbumDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", albumInputRef, albumDropdownRef);
}
}
void OnFocus() {
blurCts?.Cancel();
showAlbumDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
_ = Search(Query);
}
}
async Task OnInput(ChangeEventArgs e) {
Query = e.Value?.ToString() ?? string.Empty;
await QueryChanged.InvokeAsync(Query);
showAlbumDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
await Search(Query);
} else {
searchResults = null;
isSearching = false;
}
}
async Task Search(string query) {
var version = ++searchVersion;
isSearching = true;
searchResults = await albumService.GetAlbumsAsync(page: 0, pageSize: 20, search: query);
if (version == searchVersion) {
isSearching = false;
}
StateHasChanged();
}
async Task OnBlur() {
blurCts?.Cancel();
blurCts = new CancellationTokenSource();
try {
await Task.Delay(150, blurCts.Token);
showAlbumDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showAlbumDropdown = false;
}
}
void SelectAlbum(AlbumPreviewDto album) {
Query = album.Name;
_ = QueryChanged.InvokeAsync(Query);
showAlbumDropdown = false;
searchResults = null;
_ = OnAlbumSelected.InvokeAsync(album);
}
}
@@ -0,0 +1,5 @@
.album-dropdown-menu {
display: block;
max-height: 250px;
overflow-y: auto;
}