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
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)}."]
];
}Tags: none.
Small
A smaller pillbox for more compact layouts.
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])
];
}Searchable
A search field over the options, for a long list; SearchPlaceholder says what it says while it is empty.
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)}."]
];
}Skills: none.
With icons
An option's children are drawn as they are; its pill shows the words alone.
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]
];
}Combobox
An input among the pills: typing narrows the list, and Backspace in the empty input takes the last pill off.
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)}."]
];
}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.
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));
}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.
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));
}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.
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))];
}
}Skills: none. Searches: 0.
Loading message
What the list says while a slow search is on its way, and when it finds nothing.
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))];
}
}With validation
The page refuses a tag longer than twenty characters: the input is marked invalid until the next search.
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 = "";
}
}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.
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;
}
}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, ArrowUp | Open the list. Enter on the closed trigger does nothing. |
| Enter, a click on a row | Switch the row; the list stays open. Pills stand in the order they were picked. |
| A letter on the closed trigger | Switches the next option that starts with it, without opening. |
| The cross on a pill | Takes that option off; the list stays as it was, and what was typed among the pills is emptied. |
| Searchable | The search field takes the focus; a pick empties it and sends the cursor back to the top. |
| Combobox | Typing 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
Several answers as pills. Its options, and the slots below, are its children.
Step | Takes | What it does |
|---|---|---|
.Bind(…) | () => a collection of T | The collection it reads and writes: () => m.Tags. Or Values with OnChange. |
.Value(…) | a collection of T | The answers, for a page that holds them itself: Values(tags). |
.OnChange(…) | a handler given the new value | Runs with the new answers at each pick and each pill taken off. |
.AfterBind(…) | a handler given the new value | Runs after Bind has written the model. |
.Validate(…) | a validator | A rule the answers are checked by when they are written. |
.Live() | bool | Writes the model as soon as an answer is picked, not with the next action. |
.Variant(…) | Ui.PillboxVariantDefault 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(…) | string | Shown while nothing is picked. |
.Label(…) | string | Draws a field around it, with this label and the error. |
.Description(…) | string | Help text between the label and the pillbox. |
.Size(…) | Ui.PillboxSizeBase 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() | bool | A search field over the options. |
.SearchPlaceholder(…) | string | What the search field says while it is empty. "Search..." when unset. |
.Filter() | bool | False leaves every option in the list whatever is typed: the page filters. |
.Disabled() | bool | A pillbox that cannot be opened or changed. |
.Invalid() | bool | Error styling the form did not ask for. |
.ShowValidation() | bool | False leaves the member's message to a Ui.Error placed elsewhere. |
Ui.PillboxOption
One answer. Its children are its words, or content of your own.
Step | Takes | What it does |
|---|---|---|
.Value(…) | a value of the pillbox's type | What picking it stores. Its own words when unset. |
.Label(…) | string | Its words, in place of children. |
.SelectedLabel(…) | string | What its pill says, when that is not its words. |
.Disabled() | bool | Shown but not pickable. |
.Filterable() | bool | False for an option the search never hides. |
Ui.PillboxOptionCreate
The row that makes a new option out of what was typed.
Step | Takes | What it does |
|---|---|---|
.MinLength(…) | int | How long the typed text must be before the row is offered. |
.OnClick(…) | a handler given the text | Runs with the text as typed when the row is picked: Flux's wire:click. |
Ui.PillboxOptionEmpty
What the list says when nothing matches. Its children are its words.
Step | Takes | What it does |
|---|---|---|
.WhenLoading(…) | string | What it says while results are on their way. "Loading..." when unset. |
Ui.PillboxSearch
The search field over a searchable pillbox's list, as a child.
Step | Takes | What it does |
|---|---|---|
.Placeholder(…) | string | Shown while it is empty. "Search..." when unset. |
.Icon(…) | Ui.IconName — one of 317 names | The icon at its start. A magnifying glass when unset. |
.Clearable() | bool | The button that empties it. Shown unless false. |
.Value(…) | string | The text, for a page that holds the search itself. |
.OnInput(…) | a handler given the new value | Runs with the text as typed, or once typing has paused for Debounce. |
.Debounce(…) | a TimeSpan: 250.Milliseconds | How long typing pauses before OnInput runs. |
Ui.PillboxInput
The input among a combobox pillbox's pills, as a child.
Step | Takes | What it does |
|---|---|---|
.Placeholder(…) | string | Shown while nothing is picked and nothing typed. |
.Invalid() | bool | Error styling: a refused search. |
.Value(…) | string | The text, for a page that holds it itself: set it empty to clear the input. |
.OnInput(…) | a handler given the new value | Runs with the text as typed, or once typing has paused for Debounce. |
.Debounce(…) | a TimeSpan: 250.Milliseconds | How long typing pauses before OnInput runs. |
Ui.PillboxTrigger
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(…) | string | Shown while nothing is picked. |
.Invalid() | bool | Error styling. |
.Size(…) | Ui.PillboxSizeBase 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() | bool | A button that takes every pill off, while there is one. |