@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) *@
}
@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);
}
}