Rask
Rask
v0.23.1-alpha.0.494

Components

Pillbox

Several answers out of a list, each shown as a pill that can be taken off again, the box growing as they need. It holds a collection, and its options are its children.

Drawn and behaving as Flux UI's pillbox does, measured against its live docs.

Pillbox

PillboxExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxExample : Component
{
    private static readonly string[] Tags = ["Design", "Development", "Marketing", "Sales", "Support", "Engineering", "Product", "Operations"];

    private List<string> _tags = [];

    protected override Component? Render() =>
        Div.Class("max-w-80 space-y-3")[
            Ui.Pillbox.Values(_tags).OnChange(picked => { _tags = [.. picked]; }).Placeholder("Choose tags...").Id("ui-pillbox-tags")[
                Tags.Select(tag => Ui.PillboxOption.Key(tag)[tag])
            ],
            Ui.Text.Data("testid", "ui-pillbox-state")[_tags.Count == 0 ? "Tags: none." : $"Tags: {string.Join(", ", _tags)}."]
        ];
}
Live result
Design
Development
Marketing
Sales
Support
Engineering
Product
Operations

Tags: none.

Small

A smaller pillbox for more compact layouts.

PillboxSmallExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxSmallExample : Component
{
    private static readonly string[] Tags = ["Design", "Development", "Marketing", "Sales", "Support", "Engineering", "Product", "Operations"];

    private List<string> _tags = [];

    protected override Component? Render() =>
        Ui.Pillbox.Values(_tags).OnChange(picked => { _tags = [.. picked]; }).Sm.Placeholder("Choose tags...").Class("max-w-80")[
            Tags.Select(tag => Ui.PillboxOption.Key(tag)[tag])
        ];
}
Live result
Design
Development
Marketing
Sales
Support
Engineering
Product
Operations

Searchable

A search field over the options, for a long list; SearchPlaceholder says what it says while it is empty.

PillboxSearchableExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxSearchableExample : Component
{
    private static readonly string[] Skills = ["JavaScript", "TypeScript", "PHP", "Python", "Ruby", "Go", "Rust", "Java", "C#", "Swift"];

    private List<string> _skills = [];

    protected override Component? Render() =>
        Div.Class("max-w-80 space-y-3")[
            Ui.Pillbox.Values(_skills).OnChange(picked => { _skills = [.. picked]; }).Searchable().SearchPlaceholder("Filter skills...")
                .Placeholder("Choose skills...").Id("ui-pillbox-searchable")[
                Skills.Select(skill => Ui.PillboxOption.Key(skill)[skill])
            ],
            Ui.Text.Data("testid", "ui-pillbox-searchable-state")[_skills.Count == 0 ? "Skills: none." : $"Skills: {string.Join(", ", _skills)}."]
        ];
}
Live result
No results found
JavaScript
TypeScript
PHP
Python
Ruby
Go
Rust
Java
C#
Swift

Skills: none.

With icons

An option's children are drawn as they are; its pill shows the words alone.

PillboxIconsExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxIconsExample : Component
{
    private List<string> _platforms = [];

    protected override Component? Render() =>
        Ui.Pillbox.Values(_platforms).OnChange(picked => { _platforms = [.. picked]; }).Placeholder("Choose platforms...").Class("max-w-80")[
            Platform("github", Ui.IconName.CodeBracket, "GitHub"),
            Platform("gitlab", Ui.IconName.Server, "GitLab"),
            Platform("bitbucket", Ui.IconName.Cloud, "Bitbucket")
        ];

    private static Component Platform(string value, Ui.IconName icon, string name) =>
        Ui.PillboxOption.Key(value).Value(value)[
            Div.Class("flex items-center gap-2")[Ui.Icon.Name(icon).Mini.Class("text-zinc-400"), name]
        ];
}
Live result
GitHub
GitLab
Bitbucket

Combobox

An input among the pills: typing narrows the list, and Backspace in the empty input takes the last pill off.

PillboxComboboxExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxComboboxExample : Component
{
    private static readonly string[] Skills = ["JavaScript", "TypeScript", "PHP", "Python", "Ruby", "Go", "Rust", "Java", "C#", "Swift"];

    private List<string> _skills = [];

    protected override Component? Render() =>
        Div.Class("max-w-80 space-y-3")[
            Ui.Pillbox.Values(_skills).OnChange(picked => { _skills = [.. picked]; }).Combobox.Placeholder("Choose skills...").Id("ui-pillbox-combobox")[
                Skills.Select(skill => Ui.PillboxOption.Key(skill)[skill])
            ],
            Ui.Text.Data("testid", "ui-pillbox-combobox-state")[_skills.Count == 0 ? "Skills: none." : $"Skills: {string.Join(", ", _skills)}."]
        ];
}
Live result
No results found
JavaScript
TypeScript
PHP
Python
Ruby
Go
Rust
Java
C#
Swift

Skills: none.

Create option

Type a tag that is not there: from two characters on the create row offers to make it, and hides itself when the text names one that is.

PillboxCreateExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxCreateExample : Component
{
    private readonly List<(int Id, string Name)> _tags = [(1, "Design"), (2, "Development"), (3, "Marketing"), (4, "Sales")];
    private List<int> _selectedTags = [];
    private string _search = "";

    protected override Component? Render() =>
        Div.Class("max-w-80 space-y-3")[
            Ui.Pillbox.Values(_selectedTags).OnChange(picked => { _selectedTags = [.. picked]; }).Combobox.Id("ui-pillbox-create")[
                Ui.PillboxInput.Value(_search).OnInput(text => { _search = text; }).Placeholder("Choose tags..."),
                _tags.Select(tag => Ui.PillboxOption.Key(tag.Id).Value(tag.Id)[tag.Name]),
                Ui.PillboxOptionCreate.MinLength(2).OnClick(CreateTag)[$"Create new \"{_search}\""]
            ],
            Ui.Text.Data("testid", "ui-pillbox-create-state")[$"Tags: {Named(_selectedTags)}."]
        ];

    private void CreateTag(string name)
    {
        var id = _tags.Max(tag => tag.Id) + 1;
        _tags.Add((id, name.Trim()));
        _selectedTags = [.. _selectedTags, id];
        _search = "";
    }

    private string Named(List<int> ids) =>
        ids.Count == 0 ? "none" : string.Join(", ", ids.Select(id => _tags.Find(tag => tag.Id == id).Name));
}
Live result
No results found
Design
Development
Marketing
Sales
Create new ""

Tags: none.

With backend search

Filter(false) leaves the options to the page, asked once typing has paused. With the search cleared the query brings the picked tags back, so a tag just made is among them.

PillboxBackendSearchExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxBackendSearchExample : Component
{
    private readonly List<(int Id, string Name)> _tags =
        [(1, "Design"), (2, "Development"), (3, "Marketing"), (4, "Sales"), (5, "Support"), (6, "Engineering"), (7, "Product"), (8, "Operations")];

    private List<int> _selectedTags = [];
    private string _search = "";
    private int _searches;

    protected override Component? Render() =>
        Div.Class("max-w-80 space-y-3")[
            Ui.Pillbox.Values(_selectedTags).OnChange(picked => { _selectedTags = [.. picked]; }).Combobox.Filter(false).Id("ui-pillbox-backend")[
                Ui.PillboxInput.OnInput(Search).Debounce(250.Milliseconds).Placeholder("Choose tags..."),
                Matching().Select(tag => Ui.PillboxOption.Key(tag.Id).Value(tag.Id)[tag.Name]),
                Ui.PillboxOptionCreate.MinLength(2).OnClick(CreateTag)[$"Create \"{_search}\""]
            ],
            Ui.Text.Data("testid", "ui-pillbox-backend-state")[$"Tags: {Named(_selectedTags)}. Searches: {_searches}."]
        ];

    // The query: the tags that match, and with no search the picked ones too, so a tag just made is among them.
    private IEnumerable<(int Id, string Name)> Matching() =>
        _tags.Where(tag => tag.Name.Contains(_search, StringComparison.CurrentCultureIgnoreCase)
                           || (_search.Length == 0 && _selectedTags.Contains(tag.Id))).Take(20);

    private void Search(string text)
    {
        _search = text.Trim();
        _searches++;
    }

    private void CreateTag(string name)
    {
        var id = _tags.Max(tag => tag.Id) + 1;
        _tags.Add((id, name.Trim()));
        _selectedTags = [.. _selectedTags, id];
        _search = "";
    }

    private string Named(List<int> ids) =>
        ids.Count == 0 ? "none" : string.Join(", ", ids.Select(id => _tags.Find(tag => tag.Id == id).Name));
}
Live result
No results found
Design
Development
Marketing
Sales
Support
Engineering
Product
Operations
Create ""

Tags: none. Searches: 0.

Backend search in the search field

The same over a searchable list, through Ui.PillboxSearch: the rows drawn stay until the answer is here.

PillboxSearchStoreExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxSearchStoreExample : Component
{
    private static readonly string[] Skills = ["JavaScript", "TypeScript", "PHP", "Python", "Ruby", "Go", "Rust", "Java", "C#", "Swift"];

    private List<string> _skills = [];
    private string[] _found = Skills;
    private int _searches;

    protected override Component? Render() =>
        Div.Class("max-w-80 space-y-3")[
            Ui.Pillbox.Values(_skills).OnChange(picked => { _skills = [.. picked]; }).Filter(false).Placeholder("Choose skills...").Id("ui-pillbox-store")[
                Ui.PillboxSearch.Placeholder("Search skills...").OnInput(Search).Debounce(250.Milliseconds),
                _found.Select(skill => Ui.PillboxOption.Key(skill)[skill])
            ],
            Ui.Text.Data("testid", "ui-pillbox-store-state")[
                $"Skills: {(_skills.Count == 0 ? "none" : string.Join(", ", _skills))}. Searches: {_searches}."
            ]
        ];

    // The page's answer to the search: a query, here over a list.
    private void Search(string text)
    {
        _searches++;
        _found = [.. Skills.Where(skill => skill.Contains(text, StringComparison.CurrentCultureIgnoreCase))];
    }
}
Live result
No results found
JavaScript
TypeScript
PHP
Python
Ruby
Go
Rust
Java
C#
Swift

Skills: none. Searches: 0.

Loading message

What the list says while a slow search is on its way, and when it finds nothing.

PillboxLoadingExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxLoadingExample : Component
{
    private static readonly string[] Tags = ["Design", "Development", "Marketing", "Sales", "Support", "Engineering", "Product", "Operations"];

    private List<string> _selectedTags = [];
    private string[] _found = Tags;

    protected override Component? Render() =>
        Ui.Pillbox.Values(_selectedTags).OnChange(picked => { _selectedTags = [.. picked]; }).Combobox.Filter(false).Class("max-w-80")[
            Ui.PillboxInput.OnInput(Search).Debounce(250.Milliseconds).Placeholder("Choose tags..."),
            _found.Select(tag => Ui.PillboxOption.Key(tag)[tag]),
            Ui.PillboxOptionEmpty.WhenLoading("Loading tags...")["No tags found."]
        ];

    // A slow store: the list says "Loading tags..." until it answers.
    private async Task Search(string text)
    {
        await Task.Delay(600);
        _found = [.. Tags.Where(tag => tag.Contains(text.Trim(), StringComparison.CurrentCultureIgnoreCase))];
    }
}
Live result
No tags found.
Design
Development
Marketing
Sales
Support
Engineering
Product
Operations

With validation

The page refuses a tag longer than twenty characters: the input is marked invalid until the next search.

PillboxValidationExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxValidationExample : Component
{
    private readonly List<(int Id, string Name)> _tags = [(1, "Design"), (2, "Development"), (3, "Marketing"), (4, "Sales")];
    private List<int> _selectedTags = [];
    private string _search = "";
    private string? _error;

    protected override Component? Render() =>
        Ui.Field.Class("max-w-80")[
            Ui.Label["Tags"],
            Ui.Pillbox.Values(_selectedTags).OnChange(picked => { _selectedTags = [.. picked]; }).Combobox.Filter(false).Placeholder("Choose tags...")[
                Ui.PillboxInput.Value(_search).OnInput(Search).Invalid(_error is not null).Placeholder("Choose tags..."),
                _tags.Where(tag => tag.Name.Contains(_search, StringComparison.CurrentCultureIgnoreCase))
                    .Select(tag => Ui.PillboxOption.Key(tag.Id).Value(tag.Id)[tag.Name]),
                Ui.PillboxOptionCreate.MinLength(2).OnClick(CreateTag)[$"Create \"{_search}\""]
            ],
            Ui.Error.Message(_error)
        ];

    // A new search clears the last refusal, as Flux's example resets its error bag.
    private void Search(string text)
    {
        _search = text.Trim();
        _error = null;
    }

    private void CreateTag(string name)
    {
        name = name.Trim();
        if (name.Length > 20)
        {
            _error = "A tag is 20 characters at most.";
            return;
        }

        var id = _tags.Max(tag => tag.Id) + 1;
        _tags.Add((id, name));
        _selectedTags = [.. _selectedTags, id];
        _search = "";
    }
}
Live result
No results found
Design
Development
Marketing
Sales
Create ""

With modal

With nothing to type into, the create row is always there, and opens a form of the page's own in a modal.

PillboxModalExample.cs
namespace Rask.Site.Features.UiDocs.Examples;

public sealed partial class PillboxModalExample : Component
{
    private readonly List<(int Id, string Name)> _tags = [(1, "Design"), (2, "Development"), (3, "Marketing"), (4, "Sales")];
    private List<int> _selectedTags = [];
    private bool _creating;

    private string NewTagName { get; set; } = "";

    protected override Component? Render() =>
        Div.Class("max-w-80")[
            Ui.Pillbox.Values(_selectedTags).OnChange(picked => { _selectedTags = [.. picked]; }).Placeholder("Choose tags...").Id("ui-pillbox-modal")[
                Ui.PillboxOptionCreate.OnClick(_ => { _creating = true; })["Create new"],
                _tags.Select(tag => Ui.PillboxOption.Key(tag.Id).Value(tag.Id)[tag.Name])
            ],
            Ui.Modal.Open(_creating).OnClose(() => { _creating = false; }).Class("md:w-96")[
                Div.Class("space-y-6")[
                    Div[
                        Ui.Heading.Level(2).Lg["Create new tag"],
                        Ui.Text.Class("mt-2")["Enter the name of the new tag."]
                    ],
                    Ui.Input.Bind(() => NewTagName).Label("Name").Placeholder("e.g. 'Research'"),
                    Div.Class("flex")[
                        Ui.Spacer,
                        Ui.Button.Primary.OnClick(CreateTag)["Create"]
                    ]
                ]
            ]
        ];

    private void CreateTag()
    {
        if (string.IsNullOrWhiteSpace(NewTagName))
        {
            return;
        }

        var id = _tags.Max(tag => tag.Id) + 1;
        _tags.Add((id, NewTagName.Trim()));
        _selectedTags = [.. _selectedTags, id];
        NewTagName = "";
        _creating = false;
    }
}
Live result
Create new
Design
Development
Marketing
Sales

Create new tag

Enter the name of the new tag.

Behaviour

A collection of answers

Bind over a List<T>, a T[] or a HashSet<T>, or Values(…) with OnChange, stands where Flux has wire:model, and a bound collection gets its own kind back. An option with no Value stands for its own text; SelectedLabel is what its pill says when that is not its words.

Flux's trigger, search, input and empty slots are children here: Ui.PillboxTrigger, Ui.PillboxSearch, Ui.PillboxInput and Ui.PillboxOptionEmpty. Ui.PillboxOptionCreate is offered once the typed text is MinLength long and names no option; with nothing to type into it is always there. Written before the options, it is drawn before them.

Searching on the server

Filter(false) leaves the options to the page, and Debounce on the input among the pills, or on Ui.PillboxSearch over the list, asks it once typing has paused. Nothing is sent while the reader types into the open list. A row pressed, or Enter, during the pause adds the pill from the list as drawn, and the text typed since is dropped.

A pick, a pill taken off and the list shutting each empty what was typed, and OnInput then hears "" when the page had heard a search, as Flux's search model does.

The pills stay when the rows change

A search the page answers may bring rows that no longer hold a picked option. The pillbox keeps what each answer showed when it was last drawn, for as long as the value holds it: the pills stand in the order picked, the cross on one still takes it out of the value, and Backspace still takes the last. A value no render ever drew an option for has no pill: render its option once, or keep it among the rows the search answers with, as the backend example does.

The keyboard

It is the list a select's listbox draws, under another trigger, so its keys are the runtime's too. Where they differ from the select's, as recorded on Flux's page:

Key
What happens
A click, Space, ArrowDown, ArrowUpOpen the list. Enter on the closed trigger does nothing.
Enter, a click on a rowSwitch the row; the list stays open. Pills stand in the order they were picked.
A letter on the closed triggerSwitches the next option that starts with it, without opening.
The cross on a pillTakes that option off; the list stays as it was, and what was typed among the pills is emptied.
SearchableThe search field takes the focus; a pick empties it and sends the cursor back to the top.
ComboboxTyping opens and narrows the list; a pick empties the input; Backspace in the empty input takes the last pill off; Escape and Tab close and empty it.

No script of the kit's

The page behind an open list is locked (data-rask-lock), and the trigger keeps the keys Flux's keeps (data-rask-contain-keys), so opening it from the keyboard does not scroll the page. An open pillbox sends the page nothing until a row or a cross is pressed.

The trigger is role="combobox" while there is nothing to type into, and role="button" over a search field or an input, which is then the combobox; the list is role="listbox" with aria-multiselectable="true". Flux's modal prop has no step: the create row's OnClick opens the page's own modal.

Reference

Ui.Pillbox

flux:pillbox

Several answers as pills. Its options, and the slots below, are its children.

Step
Takes
What it does
.Bind(…)() => a collection of TThe collection it reads and writes: () => m.Tags. Or Values with OnChange.
.Value(…)a collection of TThe answers, for a page that holds them itself: Values(tags).
.OnChange(…)a handler given the new valueRuns with the new answers at each pick and each pill taken off.
.AfterBind(…)a handler given the new valueRuns after Bind has written the model.
.Validate(…)a validatorA rule the answers are checked by when they are written.
.Live()boolWrites the model as soon as an answer is picked, not with the next action.
.Variant(…)
Ui.PillboxVariant
Default
Combobox

Each is a step of its own too, unless an HTML tag has its name.

The pills alone, or an input among them. The pills when unset.
.Placeholder(…)stringShown while nothing is picked.
.Label(…)stringDraws a field around it, with this label and the error.
.Description(…)stringHelp text between the label and the pillbox.
.Size(…)
Ui.PillboxSize
Base
Sm

Each is a step of its own too, unless an HTML tag has its name.

How tall it is while it holds one line of pills.
.Searchable()boolA search field over the options.
.SearchPlaceholder(…)stringWhat the search field says while it is empty. "Search..." when unset.
.Filter()boolFalse leaves every option in the list whatever is typed: the page filters.
.Disabled()boolA pillbox that cannot be opened or changed.
.Invalid()boolError styling the form did not ask for.
.ShowValidation()boolFalse leaves the member's message to a Ui.Error placed elsewhere.

Ui.PillboxOption

flux:pillbox.option

One answer. Its children are its words, or content of your own.

Step
Takes
What it does
.Value(…)a value of the pillbox's typeWhat picking it stores. Its own words when unset.
.Label(…)stringIts words, in place of children.
.SelectedLabel(…)stringWhat its pill says, when that is not its words.
.Disabled()boolShown but not pickable.
.Filterable()boolFalse for an option the search never hides.

Ui.PillboxOptionCreate

flux:pillbox.option.create

The row that makes a new option out of what was typed.

Step
Takes
What it does
.MinLength(…)intHow long the typed text must be before the row is offered.
.OnClick(…)a handler given the textRuns with the text as typed when the row is picked: Flux's wire:click.

Ui.PillboxOptionEmpty

flux:pillbox.option.empty

What the list says when nothing matches. Its children are its words.

Step
Takes
What it does
.WhenLoading(…)stringWhat it says while results are on their way. "Loading..." when unset.

Ui.PillboxSearch

flux:pillbox.search

The search field over a searchable pillbox's list, as a child.

Step
Takes
What it does
.Placeholder(…)stringShown while it is empty. "Search..." when unset.
.Icon(…)Ui.IconName — one of 317 namesThe icon at its start. A magnifying glass when unset.
.Clearable()boolThe button that empties it. Shown unless false.
.Value(…)stringThe text, for a page that holds the search itself.
.OnInput(…)a handler given the new valueRuns with the text as typed, or once typing has paused for Debounce.
.Debounce(…)a TimeSpan: 250.MillisecondsHow long typing pauses before OnInput runs.

Ui.PillboxInput

flux:pillbox.input

The input among a combobox pillbox's pills, as a child.

Step
Takes
What it does
.Placeholder(…)stringShown while nothing is picked and nothing typed.
.Invalid()boolError styling: a refused search.
.Value(…)stringThe text, for a page that holds it itself: set it empty to clear the input.
.OnInput(…)a handler given the new valueRuns with the text as typed, or once typing has paused for Debounce.
.Debounce(…)a TimeSpan: 250.MillisecondsHow long typing pauses before OnInput runs.

Ui.PillboxTrigger

flux:pillbox.trigger

The box the pills sit in, as a child, for what the pillbox's own steps do not reach.

Step
Takes
What it does
.Placeholder(…)stringShown while nothing is picked.
.Invalid()boolError styling.
.Size(…)
Ui.PillboxSize
Base
Sm

Each is a step of its own too, unless an HTML tag has its name.

How tall it is while it holds one line of pills.
.Clearable()boolA button that takes every pill off, while there is one.
Confirm