@inherits LocalizableComponentBase @* A picker of emoji: the server's custom emoji its picker offers (EmojiService, by category) and common unicode ones (Helpers/UnicodeEmojis.cs, by group), searched by shortcode, category or word. It is a DDropdown, a sheet on a phone, whose trigger is the caller's ChildContent (data-emoji-picker-open); what it holds is rendered only while it is open (data-emoji-picker), so a feed whose every card has one costs nothing until one opens, and the server's emoji are read on the first opening. A choice (data-emoji-choice: the shortcode, or the character itself) is handed to OnPick as `:shortcode:` for a custom emoji and as the character for a unicode one, and closes the picker. The composer writes it into the words; a card and the announcements banner react with it, their quick reactions (Quick) first. *@ @ChildContent @if (isOpen) {
@* its form attribute names no form, so Enter in the search submits no form around the picker (the composer's) *@
@if (IsLoading) {

@Localizer["loading"]

} @if (QuickShown.Count > 0) {

@Localizer["Often used"]

@foreach (var emoji in QuickShown) { @emoji }
} @foreach (var group in CustomShown) {

@(group.Key is { Length: > 0 } category ? category : Localizer["This server's emoji"])

@foreach (var emoji in group) { @* alt="": the button is named by the shortcode. *@ }
} @foreach (var group in UnicodeShown) {

@Localizer[group.Name]

@foreach (var emoji in group.Emojis) { @emoji.Character }
} @if (!IsLoading && QuickShown.Count == 0 && CustomShown.Count == 0 && UnicodeShown.Count == 0) {

@Localizer["No emoji matches."]

}
}
@code { [Inject] EmojiService EmojiService { get; set; } /// The trigger's content: an icon, as a rule. [Parameter] public RenderFragment ChildContent { get; set; } /// `:shortcode:` for one of the server's custom emoji, the character itself for a unicode one. [Parameter] public EventCallback OnPick { get; set; } /// Emoji shown first while nothing is searched (a card's quick reactions). [Parameter] public IReadOnlyList Quick { get; set; } = []; /// The trigger's name, its title and aria-label. [Parameter] public string TriggerLabel { get; set; } [Parameter] public string TriggerClass { get; set; } = "btn btn-sm"; [Parameter] public string ActiveTriggerClass { get; set; } = "btn btn-sm btn-active"; [Parameter] public string ContentClass { get; set; } = "mt-2 p-2 rounded-box"; [Parameter] public DDropdownPlacement Placement { get; set; } [Parameter] public DDropdownAlign Align { get; set; } /// The choices can't be made now (the caller is busy with the last one). [Parameter] public bool ChoicesDisabled { get; set; } /// Passed to the dropdown: its class, a data- hook. [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary AdditionalAttributes { get; set; } bool isOpen; string query; List custom = []; IReadOnlyDictionary TriggerHooks => new Dictionary { ["data-emoji-picker-open"] = true, ["aria-label"] = TriggerLabel }; // what is searched: a shortcode may be typed with its colons string Searched => query?.Trim().Trim(':') is { Length: > 0 } searched ? searched : null; List QuickShown => Searched is null ? [.. Quick] : []; // the server's emoji by category, those with none first List> CustomShown => [ .. custom.Where(e => Searched is not { } searched || e.Shortcode.Contains(searched, StringComparison.OrdinalIgnoreCase) || e.Category?.Contains(searched, StringComparison.OrdinalIgnoreCase) == true) .GroupBy(e => e.Category ?? string.Empty) .OrderBy(g => g.Key.Length == 0 ? 0 : 1) .ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase) ]; List UnicodeShown => Searched is not { } searched ? [.. UnicodeEmojis.Groups] : [ .. UnicodeEmojis.Groups .Select(g => g with { Emojis = [.. g.Emojis.Where(e => e.Character == searched || e.Shortcode.Contains(searched, StringComparison.OrdinalIgnoreCase) || e.Words?.Contains(searched, StringComparison.OrdinalIgnoreCase) == true)] }) .Where(g => g.Emojis.Count > 0) ]; // the still picture when the reader asked for less motion (an animated GIF moves) string PictureOf(CustomEmoji emoji) => CascadingState.Status.PrefersReducedMotion() && emoji.StaticUrl is { Length: > 0 } still ? still : emoji.Url; async Task Opened(bool open) { isOpen = open; if (!open) { query = null; return; } custom = await EmojiService.Custom(); IsLoading = false; } void Search(string searched) => query = searched; // Enter takes the first emoji found async Task PickFirst(KeyboardEventArgs e) { if (e.Key != "Enter" || Searched is null || IsDefaultDisabled || ChoicesDisabled) return; if (CustomShown.SelectMany(g => g).FirstOrDefault() is { } first) { await Pick($":{first.Shortcode}:"); return; } if (UnicodeShown.SelectMany(g => g.Emojis).FirstOrDefault() is { } unicode) await Pick(unicode.Character); } // closed first: what the choice opens (the notice that reactions are public) is not under it async Task Pick(string emoji) { isOpen = false; query = null; await OnPick.InvokeAsync(emoji); } }