Web component
<p-select>
A select that can be searched, built as an editable combobox
- Import
@parallelogram-js/core/components/PSelect- Tag
p-select
Playground
<form class="form" action="#chosen">
<div class="form__group">
<label class="form__label" for="dining-area">Dining area</label>
<div class="form__control">
<p-select id="dining-area" name="area" placeholder="Choose an area" required>
<optgroup label="Inside">
<option value="bar">Bar</option>
<option value="main">Main room</option>
</optgroup>
<optgroup label="Outside">
<option value="terrace" selected>Terrace</option>
<option value="garden" disabled>Garden (closed for winter)</option>
</optgroup>
</p-select>
</div>
</div>
<div class="form__actions">
<button type="submit">Continue</button>
<button type="reset">Reset</button>
</div>
</form>
Use the example to see the events it sends.
An option can carry more than its label: data-secondary is shown muted after the label and searched with it, so typing an email finds the person; data-description is a smaller line beneath; data-image is a round thumbnail before the text. The input shows the label alone once someone is chosen. Remote options carry the same fields as secondary, description and image.
<div class="form">
<div class="form__group">
<label class="form__label" for="owner">Owner</label>
<div class="form__control">
<p-select id="owner" name="owner" placeholder="Choose an owner">
<option
value="amelia"
data-secondary="amelia@example.com"
data-description="Account manager, Sydney"
data-image="images/harbour-640.jpg"
>
Amelia Nguyen
</option>
<option
value="hudson"
data-secondary="hudson@example.com"
data-description="Head chef, Melbourne"
data-image="images/kitchen-640.jpg"
>
Hudson Ferraro
</option>
<option
value="priya"
data-secondary="priya@example.com"
data-description="Events, Brisbane"
data-image="images/terrace-640.jpg"
>
Priya Rahman
</option>
<option value="unassigned" data-description="Leave the record with no owner">Unassigned</option>
</p-select>
</div>
</div>
</div>
Use the example to see the events it sends.
With multiple the list toggles rather than replaces and stays open, and every chosen value sits in the control. A value goes back out by choosing its row again, or with Backspace from the empty input. Nothing is counted away behind "and 2 more": the field grows to about three rows and then scrolls. selection-rows="2" and list-rows="2" put the secondary text under the label in the control and in the list, and select-all adds the bar that takes or gives back whatever the search has narrowed to. The form carries one entry per value under the one name.
<div class="form">
<div class="form__group">
<label class="form__label" for="shift">Staff on shift</label>
<div class="form__control">
<p-select
id="shift"
name="staff"
multiple
select-all
selection-rows="2"
list-rows="2"
placeholder="Nobody chosen yet"
>
<option value="amelia" data-secondary="Duty manager, front of house">Amelia Nguyen</option>
<option value="hudson" data-secondary="Head chef, kitchen">Hudson Ferraro</option>
<option value="priya" data-secondary="Coordinator, events">Priya Rahman</option>
<option value="marcus" data-secondary="Supervisor, kitchen">Marcus Bell</option>
<option value="ines" data-secondary="Technician, maintenance">Ines Duarte</option>
<option value="rosa" data-secondary="Sommelier, front of house">Rosa Iglesias</option>
</p-select>
</div>
</div>
</div>
Use the example to see the events it sends.
Around a hundred options, loaded from a URL so the sample stays readable. The list scrolls once it outgrows its maximum height, typing narrows it and the arrow keys, Page Up and Page Down walk it. With data-select-min="0" the options arrive as soon as the list opens.
<div class="form">
<div class="form__group">
<label class="form__label" for="delivery-suburb">Delivery suburb</label>
<div class="form__control">
<p-select
id="delivery-suburb"
name="suburb"
placeholder="Choose a suburb"
data-select-src="/api/places?q={q}"
data-select-min="0"
></p-select>
</div>
</div>
</div>
Use the example to see the events it sends.
The search runs on the server: each query is sent to data-select-src with {q} replaced by the typed text, and the options that come back replace the list. The URL also carries {page} and {limit}, so the server sends a page at a time — 25 rows here, from data-select-limit — and answers more: true while there are further pages; scrolling to the end of the list, or arrowing onto its last row, asks for the next one. Each row carries an email as its secondary and a role as its description, so the list shows both and a search on nguyen, perth or an address like noah.tran finds its person. Type two characters to start a search, and the list shows its busy state while the request is out and "No results found" when nothing matches.
<div class="form">
<div class="form__group">
<label class="form__label" for="customer">Customer</label>
<div class="form__control">
<p-select
id="customer"
name="customer"
placeholder="Search customers"
data-select-src="/api/directory?q={q}&page={page}&limit={limit}"
data-select-min="2"
data-select-debounce="300"
></p-select>
</div>
</div>
</div>
Use the example to see the events it sends.
About
Follows the WAI-ARIA combobox pattern with list autocomplete. The text input carries the combobox role and is named after the element's aria-label or its <label for>. Typing filters the options and announces how many match; the arrow keys, Home and End move through them, and Page Up and Page Down move ten at a time; Enter or Tab chooses the highlighted option; Alt+Down Arrow opens the list without moving the highlight and Alt+Up Arrow chooses the highlighted option and closes it; Escape closes the list and puts the chosen label back. Backspace or Delete clears a chosen value and returns the input to searching, which is the keyboard's way back: choosing a value makes the input read-only, and the clear button beside it is not in the tab order.
Options come from <option> and <optgroup> children, which are watched for changes, or from a URL that returns JSON: an array of { value, label, disabled?, group?, secondary?, description?, image? }, or an object with those in options. An option may carry a secondary text, shown muted after its label and searched along with it, a description, shown smaller beneath, and an image URL, shown as a round thumbnail before the text; in markup they are the data-secondary, data-description and data-image attributes of the <option>. The input shows the label alone. The element is form-associated: it submits its value under its name, supports required, and restores its value attribute, or else its selected option, when the form resets.
Without JavaScript
Until the module loads this element is empty markup with no control in it, and the package stylesheet hides it while scripts are running, so the page never shows a bare element nobody can use. It is the form field, so a form submitted before it upgrades carries no value for it and required cannot hold the form back. Where that matters, render an ordinary <select> on the server and replace it once the module is in.
Usage
Register the component and the framework loads it the first time a page contains p-select.
import { Parallelogram } from '@parallelogram-js/core';
const app = Parallelogram.create();
app.components.add('p-select', () => import('@parallelogram-js/core/components/PSelect'));
app.run();
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
name | string | none | The form field name Property name. |
value | string | none | The initial value, restored when the form resets; takes precedence over a selected option Property value. |
multiple | flag | none | Allows more than one value: choosing toggles rather than replaces, the field submits one entry per value under its name, and value reads and writes a list |
selection-rows | 1 | 2 | 1 | How many rows a chosen value takes in the control: its label, or its label over the option's secondary text |
list-rows | 1 | 2 | 1 | How many rows an option takes in the list: its label with the secondary text after it, or the secondary text on its own line beneath |
searchable | flag | none | Offers a box to type in that narrows the list. Without it a printable key moves the field to the option it names, as a native select does, which suits a handful of options where a search box is more furniture than help |
search-label | string | Search | The placeholder in the search box once something is chosen, when the field’s own placeholder has given way to the selections For every instance, PSelect.defaults.searchLabel. |
search-clear-label | string | Clear the search | The accessible name of the button that takes back what was typed in the search box For every instance, PSelect.defaults.searchClearLabel. |
select-all | flag | none | Offers a bar above the list that takes or gives back everything the search has narrowed to; only where multiple is set |
select-all-label | string | Select all ({count}) | The words on the button that takes every option the search left; {count} is how many For every instance, PSelect.defaults.selectAllLabel. |
select-none-label | string | None | The words on the button that gives every option the search left back For every instance, PSelect.defaults.selectNoneLabel. |
placeholder | string | Select… | Shown when nothing is chosen For every instance, PSelect.defaults.placeholder. |
search-hint | string | Type to search | What the list says before a search with data-select-min of 1 has begun For every instance, PSelect.defaults.searchHint. |
search-min-hint | string | Type {min} or more characters to search | The same when data-select-min is higher, with {min} for it For every instance, PSelect.defaults.searchMinHint. |
no-results | string | No results found | What the list says when nothing matches For every instance, PSelect.defaults.noResults. |
disabled | flag | none | Disables the select Property disabled. |
required | flag | none | A value must be chosen for the form to submit Property required. |
aria-label | string | none | Names the input when there is no <label for> |
data-select-src | url | none | URL for remote options, with {q} replaced by the search text. With {page} in it the list is paged: page 1 first, then the next page whenever the list is scrolled to its end or the arrow keys reach the last option, for as long as the response says more: true. {limit} is replaced by data-select-limit. |
data-select-min | number | 0 | Characters to type before a remote search |
data-select-debounce | number | 200 | Milliseconds to wait after typing before a remote search |
data-select-limit | number | 25 | Rows per page, replacing {limit} in data-select-src |
data-select-open-on-focus | boolean | false | Open the list when the input receives focus |
Properties
| Name | Type | Description |
|---|---|---|
value | string | The chosen value |
name | string | The form field name |
required | boolean | Reflects the required attribute |
disabled | boolean | Reflects the disabled attribute |
formread only | HTMLFormElement | null | The owning form |
labelsread only | NodeList | Labels for the element |
validityread only | ValidityState | The validity state |
validationMessageread only | string | The validation message |
willValidateread only | boolean | Whether the element is validated |
Methods
| Name | Signature | Description |
|---|---|---|
open | () => void | Open the list |
close | () => void | Close the list |
toggle | () => void | Open or close the list |
select | (value: string) => void | Choose an option as the user would, dispatching input, change and p-select:change when the value changes |
setOptions | (options: Array<{ value: string; label: string; disabled?: boolean; group?: string; secondary?: string; description?: string; image?: string }>) => void | Replace the options |
getValue | () => string | The chosen value |
clear | () => void | Clear the choice |
refreshOptions | () => void | Read the child options again |
checkValidity | () => boolean | Check the value |
reportValidity | () => boolean | Check the value and show the browser message |
Events
| Name | Detail | Description |
|---|---|---|
input | none | The user chose a different option; bubbles out of shadow roots DOM event |
change | none | The user chose a different option; bubbles out of shadow roots DOM event |
p-select:change | { value: string; label: string; secondary?: string; description?: string; image?: string } | A different option was chosen, with its secondary text, description and image when it has them; bubbles out of shadow roots DOM event |
p-select:open | none | The list opened DOM event |
p-select:close | none | The list closed DOM event |
Shadow parts
| Name | Description |
|---|---|
control | The field itself, which carries the combobox role and focus |
menu | The popup, holding the search bar, the bulk bar and the list |
search | The search bar at the top of the popup, where searchable is set |
input | The search box inside that bar |
search-clear | The button that takes back what was typed into it |
selections | The chosen values in the control |
selection | One chosen value |
bulk | The bar above the list, where select-all is set |
bulk-all | The button that takes every option the search left |
bulk-none | The button that gives those options back |
listbox | The list of options |
CSS custom properties
| Name | Default | Description |
|---|---|---|
--select-row | 1.75rem | How tall one row of the control is, which is what keeps the icons in the gutter level with the first row as the field grows. The element measures a real row and writes it here once anything is chosen, so a page rarely sets it |
--select-menu-max-height | 240px | How tall the list grows before it scrolls; set it to none and the list fits what is in it |
--select-secondary-font-size | var(--font-xs) | The size of an option's secondary text where list-rows is 2 |
--select-chosen-bg | var(--color-accent) | Background of a chosen row in the list; it is filled, not tinted, whether one value may be chosen or several |
--select-chosen-color | var(--color-accent-contrast) | Text colour of a chosen row in the list |
--select-chosen-hover-bg | var(--color-accent-hover) | Background of a chosen row under the pointer or the keyboard |
--select-selections-max-height | 7.2rem | How tall the chosen values grow in the control before they scroll |
--select-selection-bg | var(--select-selected-bg) | Background of one chosen value; the same tint a chosen option carries |
--select-selection-radius | 999px | Corner radius of one chosen value |
--select-selection-max-width | 16rem | How wide one chosen value grows before its label is cut short |
--select-selection-font-size | var(--font-sm) | The size of a chosen value's label |
--select-selection-secondary-font-size | var(--font-xs) | The size of a chosen value's second line, where selection-rows is 2 |
--select-bg | var(--surface-control-color-bg) | Background of the closed control; follows the control surface |
--select-border | var(--select-border-width) solid var(--select-border-color) | The control's border, built from its width and colour |
--select-border-width | var(--surface-control-border-width) | Width of that border; follows the control surface |
--select-border-radius | var(--surface-control-radius) | Corner radius of the control; follows the control surface |
--select-padding-block | 0.45rem | Padding above and below the control’s rows, which the icons in the gutter start from |
--select-padding-inline | 0.8rem | Padding at the leading and trailing edges of the control, which the chevron sits in from |
--select-selection-padding-inline | 0.6rem | A chosen value's own inset. The control gives back exactly this much of its leading padding, so a value's text starts in the same place whether it is drawn on a ground of its own or not |
--select-gutter | calc(var(--select-padding-inline) + 2.85rem) | The trailing strip the control keeps for the chevron and the slot beside it, whether or not anything is drawn there, so opening the list never moves what is already in the field |
--select-placeholder | var(--color-text-muted) | Colour of the placeholder while nothing is chosen |
--select-active-outline | var(--color-accent-hover) | Outline drawn around the control while its list is open |
--select-text | var(--surface-control-color-text) | Text colour |
--select-border-color | var(--surface-control-border-color) | Input border colour |
--select-focus-color | var(--color-accent) | Focus ring colour |
--select-hover-bg | var(--surface-dropdown-item-hover-bg) | Background of a hovered option |
--select-current-bg | var(--surface-dropdown-item-current-bg) | Background of the highlighted option |
--select-selected-bg | var(--surface-dropdown-item-selected-bg) | Background of the chosen option |
--select-description-color | var(--color-text-muted) | Colour of an option's description |
--select-image-size | 1.5rem | Width and height of an option's thumbnail |
--select-disabled-opacity | var(--form-control-disabled-opacity, 0.3) | Opacity while the select is disabled |
--menu-bg | var(--surface-dropdown-color-bg) | List background |
--menu-border-color | var(--surface-dropdown-border-color) | List border colour |
--menu-border-radius | none | List corner radius |
--menu-shadow | var(--surface-dropdown-shadow) | List shadow |