Roster Picker¶
The roster picker is a server-searched multi-picker. The user types into a search box, picks people
(or anything else) from the rows the server sends back, and each pick becomes a removable chip whose
hidden input posts with the form. The app supplies one search route. The JavaScript and CSS ship
with the component, so there is no script call. The engine is headless: it opens, searches, picks
and removes, then reports what the user did as pjx:roster-picker:* events. It is a separate family
from PJXSelect, which stays the select for a fixed list of options;
ADR 0005 records why.
Quick start¶
from urllib.parse import urlencode
from fastapi import FastAPI
from pyjinhx import setup
from pyjinhx.builtins import PJXRosterItem, PJXRosterPicker, PJXRosterResults
app = FastAPI()
setup(app)
PAGE_SIZE = 2
PEOPLE: list[PJXRosterItem] = [
PJXRosterItem(value="ada", label="Ada Lovelace", subtitle="Analyst", initials="AL"),
PJXRosterItem(
value="alan", label="Alan Turing", subtitle="Logician", initials="AT"
),
PJXRosterItem(
value="grace", label="Grace Hopper", subtitle="Admiral", initials="GH"
),
PJXRosterItem(
value="edsger", label="Edsger Dijkstra", subtitle="Professor", initials="ED"
),
]
@app.get("/team")
def team():
return PJXRosterPicker(
id="team",
name="members",
candidates_url="/people",
items=[PEOPLE[0]],
)
@app.get("/people")
def people(picker_id: str, q: str = "", exclude: str = "", cursor: int = 0):
excluded = set(exclude.split(",")) if exclude else set()
matches = [
person
for person in PEOPLE
if q.lower() in person.label.lower() and person.value not in excluded
]
page = matches[cursor : cursor + PAGE_SIZE]
next_cursor = cursor + PAGE_SIZE
next_url = ""
if next_cursor < len(matches):
# The sentinel fetches this URL on its own, so it carries the search too.
query = {
"q": q,
"exclude": exclude,
"picker_id": picker_id,
"cursor": next_cursor,
}
next_url = "/people?" + urlencode(query)
return PJXRosterResults(
items=page,
picker_id=picker_id,
next_url=next_url,
next_cursor=str(next_cursor) if next_url else "",
)
There is no script call. Give the picker a stable id: the results list's id (<id>-results), the
picker_id the search sends and the paging sentinel are all built from it. picker_id has no
default in the route, so a request without it is answered with a 422 instead of a page that cannot
find its picker.
PJXRosterPicker is built in Python. Its items is a list of models, which a .pjx tag cannot
pass, as with PJXSelect's options.
Components¶
Every component also takes id, class_name and extra_attrs, as described in
Builtin conventions. The tables below list the other props.
Text props are escaped. Slot props render raw HTML, so escape any plain text the app puts in them.
PJXRosterItem¶
One entry, shown as a chip or a result row. It is a frozen pydantic model, not a component, so it
takes no id, class_name or extra_attrs.
| Prop | Type | Default | Meaning |
|---|---|---|---|
value |
str |
required | Non-empty. The value the chip's hidden input posts. |
label |
str |
required | Non-empty. The name shown on the chip and the row. |
subtitle |
str |
"" |
A second line on the result row. Chips do not show it. |
initials |
str |
"" |
Shown in the avatar when there is no src. |
color |
str |
"" |
The avatar's background color. |
src |
str |
"" |
The avatar's image URL. |
icon |
str |
"" |
An icon name. When set, the icon replaces the avatar. |
extra |
Slot |
"" |
Raw HTML beside the chip's label. |
detail |
Slot |
"" |
Raw HTML in a second row under the chip. |
PJXRosterPicker¶
The picker: the search box, the results list, the chips, the limit hint and, when required, the validity input.
| Prop | Type | Default | Meaning |
|---|---|---|---|
name |
str |
required | Non-empty. Each chip's hidden input posts under this name. |
candidates_url |
str |
required | Non-empty. The search route. See Search route contract. |
items |
list[PJXRosterItem] |
[] |
The initial selection, one chip each, in order. |
max_items |
int |
0 |
The most chips the picker takes. 0 means no limit. See Behaviour. |
required |
bool |
False |
Blocks form submit while nothing is chosen. |
disabled |
bool |
False |
Shows the chips only: no search box, no remove buttons. The selection still submits. |
portal |
bool |
False |
Moves the open list to document.body, for ancestors that clip it. |
placeholder |
str |
"Search…" |
The search box's placeholder. |
remove_label |
str |
"Remove" |
Each remove button's aria-label starts with it. |
limit_label |
str |
"Limit reached" |
The hint shown while the picker is full. |
empty_message |
str |
"No results" |
Written to data-empty-message on the root. |
An empty name or candidates_url, a negative max_items, two items with the same value, or
more items than a non-zero max_items raises a pydantic.ValidationError.
PJXRosterResults¶
One page of search results: what the search route returns.
| Prop | Type | Default | Meaning |
|---|---|---|---|
items |
list[PJXRosterItem] |
[] |
The rows on this page, one pick button each. |
picker_id |
str |
required | The picker's id. Non-empty, no whitespace. |
next_url |
str |
"" |
Where the next page is. "" means this is the last page. |
next_cursor |
str |
"" |
Added to the sentinel's id, so each page's sentinel id is unique. |
empty_message |
str |
"No results" |
Shown when items is empty. |
remove_label |
str |
"Remove" |
The remove_label of the chips a pick adds. |
With no items, the page renders empty_message and no sentinel. With items and a next_url, the
page ends with a PJXLazyLoad sentinel. Its id is <picker_id>-more, plus -<next_cursor> when
next_cursor is set. It fires on intersect once root:#<picker_id>-results, when it scrolls into
view inside the list, and swaps outerHTML, so the next page replaces it.
PJXRosterChip¶
One selected entry: a hidden input, the avatar or icon, the label, extra and the remove button,
then the optional detail row.
| Prop | Type | Default | Meaning |
|---|---|---|---|
value |
str |
required | Non-empty. The hidden input's value. |
label |
str |
required | Non-empty. The chip's text. |
initials |
str |
"" |
As on PJXRosterItem. |
color |
str |
"" |
As on PJXRosterItem. |
src |
str |
"" |
As on PJXRosterItem. |
icon |
str |
"" |
As on PJXRosterItem. |
extra |
Slot |
"" |
As on PJXRosterItem. |
detail |
Slot |
"" |
As on PJXRosterItem. |
name |
str |
"" |
The hidden input's name. Must be the picker's name for the chip to post with it. |
remove_label |
str |
"Remove" |
The remove button's aria-label is "<remove_label>: <label>". |
disabled |
bool |
False |
Drops the remove button. The hidden input stays, so the chip still submits. |
The picker builds its chips itself, from items and from each result row. An app renders a
PJXRosterChip only for a custom layout or a swap; see Updating a picker.
Search route contract¶
The search box sends GET <candidates_url> with three parameters:
| Parameter | Value |
|---|---|
q |
The text in the search box. |
exclude |
The values already chipped, joined with ,. Read when the request is built. |
picker_id |
The picker's id. |
Typing searches after a 300 ms pause. The box has hx-sync="this:replace", so a newer search
replaces one still in flight. The answer is swapped into the results list (innerHTML).
- An empty
qis a normal request. Answer it with the first entries, as the quick start does. - Return
PJXRosterResults(picker_id=picker_id, ...). - Honouring
excludeis optional. The picker hides any row whose value is already chipped, so a route that ignores it still shows no duplicates. - Paging. The next page is fetched by the sentinel, not by the search box, so its request
carries no
q,excludeorpicker_idof its own.next_urlmust hold everything the route needs: the quick start passes all three pluscursor. The new page replaces the sentinel inside the list. - Values with commas.
excludeis joined with,, so a value that contains a comma cannot be split back reliably. A route that honoursexcludeneeds values without commas, or it should ignoreexcludeand rely on the picker hiding chipped rows. - The search box is detached from any enclosing form, so
qnever posts with the form. Only the chips' hidden inputs post, each as<name>=<value>. - Reopening the list over the same text sends no request: the rows it holds already answer it. A search that fails or is aborted keeps the rows it would have replaced.
Behaviour¶
- Open and close. A click on the search box, or focus arriving on it, opens the list and searches for the box's text. Typing into a closed list opens it. A pick, focus leaving the box and its list, or Escape closes it. Closing hides the rows but keeps them.
- Pick and remove. A pick appends the result's chip, or replaces the chip when
max_items=1. It then clears the box and puts focus back in it. Removing a chip moves focus to the next chip's remove button, or to the box when there is none. - Limit.
max_items=0takes any number of chips.max_items=1swaps its chip on each pick and is never full. Withmax_itemsabove 1, the picker is full once it holds that many chips: the search box is disabled, the hint showslimit_label, and focus goes to the new chip's remove button. Removing a chip undoes all three. A picker rendered withmax_itemsitems starts full. - Required.
required=Trueadds a visually hiddeninput.pjx-roster-picker__validitywithrequired. It has noname, so it never posts. While nothing is chosen it blocks form submit. When the browser reports it invalid, the search box takes focus and getsaria-invalid="true", and the root getspjx-roster-picker--invalid. The next pick clears both. A required picker's box also hasaria-required="true". - Disabled. A disabled picker renders no search box and no remove buttons. The chips' hidden inputs stay, so the selection still submits. Its validity input is disabled too, so a disabled picker never blocks its form.
- Portal. With
portal=True, the open list moves todocument.bodyand is fixed under the search box, so anoverflow: hiddenorautoancestor cannot clip it. It goes back into the picker when it closes.PJXSelect(portal=True)offers the same escape hatch, though it moves its whole root. - Placement. The list opens under the search box, or above it
(
pjx-roster-picker__results--above) when there is not room for it below. Its height is capped by--pjx-roster-picker-max-hand by the room on that side. It is placed again when the window is resized or an ancestor scrolls. - Keyboard. ArrowDown in the open search box moves focus to the first visible result. Results
are buttons, so Tab reaches them and Enter or Space picks. Escape closes the list and returns
focus to the box. Escape on a closed list is left alone, so it reaches an enclosing dialog. There
is no ArrowUp, Enter, Home or End roving from the box, and no
aria-activedescendant.
Updating a picker¶
Every update is a plain htmx swap of server-rendered markup. The picker listens on the document, so nothing needs rebinding after a swap.
- Swap the whole picker. Re-render
PJXRosterPickerwith the sameidand newitems, and swap it over the old one. - Swap one chip or the chip list. A swap that adds or removes chips, including an
outerHTMLswap whose answer is empty, brings the limit, the hint, the validity input and the hidden result rows back in step with the chips.
A swap fires no pjx:roster-picker:* events. Those report what the user did, not what the server
changed.
This route adds a chip from the server. A button with hx-post="/team/lead",
hx-target="#team .pjx-roster-picker__chips" and hx-swap="beforeend" appends it to the picker
from the quick start:
from pyjinhx.builtins import PJXRosterChip
@app.post("/team/lead")
def add_lead():
# Chips the picker builds get its name; a hand-rendered one needs it too,
# or its hidden input posts under no name.
return PJXRosterChip(value="grace", label="Grace Hopper", name="members")
Events and JavaScript API¶
Event list¶
Every event is dispatched on the picker root and bubbles. A before-* event is cancelable:
event.preventDefault() refuses the pick or keeps the chip. detail carries the value, not the
{reason, trigger} of the overlay builtins: a pick has no dismiss reason.
| Event | detail |
Cancelable | Fired when |
|---|---|---|---|
pjx:roster-picker:before-pick |
{value} |
yes | A result is about to be added. |
pjx:roster-picker:pick |
{value} |
no | The chip was added. |
pjx:roster-picker:before-remove |
{value} |
yes | A chip is about to be dropped. |
pjx:roster-picker:remove |
{value} |
no | The chip was dropped. |
pjx:roster-picker:change |
{values} |
no | After each pick or remove, once, with every chip's value in chip order. |
document.getElementById("team").addEventListener("pjx:roster-picker:before-remove", (event) => {
// The team's owner cannot be taken off it.
if (event.detail.value === "ada") event.preventDefault();
});
window.pjx.rosterPicker¶
| Call | Returns |
|---|---|
pjx.rosterPicker.values(root) |
A new array of the chips' values, in chip order. |
pjx.rosterPicker.open(root) |
true. Opens the list and searches, as a click does. A reopen over the same text sends no request. Focus does not move. |
pjx.rosterPicker.close(root) |
true. Closes the list and keeps its rows. Focus does not move. |
open and close return false and change nothing when the picker is disabled or full. All three
throw Error("pjx.rosterPicker.<name>: expected a roster picker root ([data-pjx-roster-picker])")
when root is anything but the picker root.
Theming¶
The picker reads these --pjx-roster-picker-* tokens, set on :where(:root). Override them
globally or on one picker, as described in Builtin conventions.
| Area | Tokens |
|---|---|
| Colors | --pjx-roster-picker-bg, --pjx-roster-picker-fg, --pjx-roster-picker-muted, --pjx-roster-picker-border, --pjx-roster-picker-focus, --pjx-roster-picker-invalid |
| Shape | --pjx-roster-picker-radius, --pjx-roster-picker-padding, --pjx-roster-picker-gap |
| Chips | --pjx-roster-picker-chip-bg |
| Results list | --pjx-roster-picker-results-bg, --pjx-roster-picker-shadow, --pjx-roster-picker-z, --pjx-roster-picker-max-h, --pjx-roster-picker-min-h, --pjx-roster-picker-room |
| Rows | --pjx-roster-picker-row-hover, --pjx-roster-picker-row-active |
--pjx-roster-picker-room is written by the script while the list is open: the room on the side the
list opens on. It defaults to --pjx-roster-picker-max-h. Do not set it yourself.
To restyle the block, scope your rules under an app class
(.team-form .pjx-roster-picker__chip { ... }). Do not redefine .pjx-roster-picker* rules
unscoped.
DOM contract¶
These classes, attributes, events and states are versioned API.
Classes¶
| Class | Element |
|---|---|
.pjx-roster-picker |
Picker root |
.pjx-roster-picker__search |
Wraps the search box and its results list |
.pjx-roster-picker__input |
The search box |
.pjx-roster-picker__results |
The results list (#<id>-results) |
.pjx-roster-picker__results--above |
The results list, opened above the box |
.pjx-roster-picker__results-page |
One PJXRosterResults page |
.pjx-roster-picker__pick |
A result row (a button) |
.pjx-roster-picker__pick-avatar, .pjx-roster-picker__pick-icon, .pjx-roster-picker__pick-text, .pjx-roster-picker__pick-label, .pjx-roster-picker__pick-subtitle, .pjx-roster-picker__pick-add |
Result row parts |
.pjx-roster-picker__empty |
The empty_message paragraph |
.pjx-roster-picker__more |
The paging sentinel |
.pjx-roster-picker__chips |
The chip list |
.pjx-roster-picker__chip |
Chip root |
.pjx-roster-picker__chip-input |
The chip's hidden input |
.pjx-roster-picker__chip-row, .pjx-roster-picker__chip-avatar, .pjx-roster-picker__chip-icon, .pjx-roster-picker__chip-label, .pjx-roster-picker__chip-extra, .pjx-roster-picker__chip-detail |
Chip parts |
.pjx-roster-picker__chip-remove |
The chip's remove button |
.pjx-roster-picker__hint |
The limit hint |
.pjx-roster-picker__validity |
The hidden input required adds |
.pjx-roster-picker--invalid |
The root, after a failed submit |
Attributes¶
| Attribute | On | Meaning |
|---|---|---|
data-pjx-roster-picker |
Picker root | Marks a picker. |
data-name, data-max-items, data-empty-message |
Picker root | The name, max_items and empty_message props. |
data-disabled |
Picker root | Present when disabled. |
data-pjx-roster-picker-portal |
Picker root | Present with portal=True. |
role="combobox", aria-autocomplete="list" |
Search box | Always. |
aria-expanded |
Search box | "true" while the list is open. |
aria-controls="<id>-results" |
Search box | Names the results list. |
aria-required="true" |
Search box | Present with required=True. |
aria-invalid="true" |
Search box | Set after a failed submit. |
data-last-query |
Search box | Set by the script: the text the shown rows answer. |
id="<id>-results", hidden |
Results list | Its id, and hidden while closed. |
data-pjx-roster-results |
Results page | Marks one page. |
data-pjx-roster-pick, data-value |
Result row | Marks a row and holds its value. |
data-pjx-roster-chip |
Chip root | Marks a chip. |
data-pjx-roster-remove |
Remove button | Marks the button that drops its chip. |
Events¶
All pjx:roster-picker:* events and their detail are listed in Event list.
State¶
| State | Meaning |
|---|---|
[aria-expanded="true"] on the search box |
The list is open. |
[hidden] on the results list |
The list is closed. |
disabled on the search box |
The picker is full. |
[hidden] on the hint |
The picker is not full. |
.pjx-roster-picker--invalid on the root, aria-invalid on the box |
A submit failed while nothing was chosen. Cleared by the next pick. |
.pjx-roster-picker__results--above on the results list |
The list opened above the box. |
hidden on a result row |
The row's data-value is already chipped. |