pict-section-picker
v1.9.0
Published
Pict-native themeable searchable select / combobox — single & multi select, server pagination, categorized groups and creatable entries, driven by a host-agnostic async DataProvider (with a built-in Meadow EntityProvider adapter). A jQuery/select2-free re
Readme
pict-section-picker
A Pict-native, themeable searchable select / combobox — a jQuery/select2-free replacement for entity pickers and tag inputs in Pict applications.
- Single & multi select — scalar value, or an array of values rendered as removable chips.
- Search with keyboard navigation (↑/↓ + Enter), click-outside to close.
- Server pagination — drive options from an async
DataProvider(searchTerm, page); "Load more" / infinite scroll. - Categorized options — group rows under headers.
- Creatable — let users mint new entries from the search term via
OnCreate. - Built-in Meadow adapter — point it at a Meadow entity and it builds the server
DataProvider(FoxHoundLIKEsearch + paging) and the pre-bound-value resolver for you. - Themeable via
--theme-color-*tokens. No jQuery, noselect2, noaddEventListener— pure Pict conventions.
Install
npm install pict-section-pickerQuick start
Register the provider, then create pickers through it. Each picker renders into a host DOM element and reads/writes its selection from an AppData address.
const libPictSectionPicker = require('pict-section-picker');
// In your application's onAfterInitializeAsync:
this.pict.addProvider('Pict-Section-Picker', libPictSectionPicker.default_configuration, libPictSectionPicker);
const tmpPicker = this.pict.providers['Pict-Section-Picker'];
// A simple static single-select.
tmpPicker.createPicker('CountryPicker',
{
DestinationAddress: '#CountryPicker', // where to render
ValueAddress: 'AppData.Form.Country', // selection is read from / written to here
Placeholder: 'Select a country…',
Options: [ { Value: 'us', Text: 'United States' }, { Value: 'ca', Text: 'Canada' } ],
OnChange: (pValue, pRecord) => { /* … */ },
});
this.pict.views['CountryPicker'].render();The control renders into #CountryPicker; AppData.Form.Country holds the selected value.
Picker modes
Single (default)
Mode: 'single' — ValueAddress holds the scalar value. Selecting closes the dropdown.
Clearable: set AllowClear: true to give a single-select a way back to empty — a pinned "Any" row at the top of the dropdown (checked while nothing is selected) and an inline × on the control while a value is selected. Either empties the selection and fires OnChange(null, null); clearing while already empty just closes. ClearLabel renames the row (default "Any"). The natural fit is filters, where empty means "no constraint" — the recordset quick filters enable it automatically. Multi mode ignores the option (chips already clear individually).
Multi
Mode: 'multi' — ValueAddress holds an array of values, rendered as chips with × buttons. Selecting toggles membership and keeps the dropdown open for rapid multi-pick. Two optional mirror bindings (the EntitySelectorMultiple contract):
| Option | Holds |
|---|---|
| ValueAddress | the array of values, e.g. [2, 10, 141] |
| StringArrayValueAddress | a csv string, e.g. "2,10,141" |
| SelectedValuesAddress | the full record list, e.g. [{Value, Text}, …] |
tmpPicker.createPicker('TagsPicker',
{
Mode: 'multi',
DestinationAddress: '#TagsPicker',
ValueAddress: 'AppData.Form.Tags',
Placeholder: 'Add tags…',
Options: [ { Value: 'urgent', Text: 'urgent' }, { Value: 'review', Text: 'review' } ],
});Async data (server search + pagination)
Pass a DataProvider instead of (or in addition to) static Options. It is called with the current search term and a zero-based page index, and resolves a page of results plus whether more remain:
DataProvider: (pSearchTerm, pPage) => Promise.resolve(
{
results: [ { Value: 1, Text: 'First' }, /* … up to PageSize … */ ],
hasMore: true, // show a "Load more" button
})Searches are debounced; "Load more" appends the next page. For a value that is already bound when the picker mounts (e.g. an ID with no text yet), supply ResolveValue(value) => Promise<{Value, Text}> so the control can show its label.
Meadow entity pickers
For the common case — picking a Meadow entity over the REST API — use createEntityPicker. It builds the server DataProvider (FoxHound LIKE search across your fields, offset/limit paging) and the ResolveValue resolver from pict.EntityProvider automatically.
tmpPicker.createEntityPicker('AuthorPicker',
{
Entity: 'Author', // the Meadow entity
SearchFields: [ 'Name' ], // fields to LIKE-search (default ['Name'])
ValueField: 'IDAuthor', // option Value (default 'ID<Entity>')
TextField: 'Name', // option Text (default 'Name')
PageSize: 20,
DestinationAddress: '#AuthorPicker',
ValueAddress: 'AppData.Form.IDAuthor',
Placeholder: 'Search authors…',
// Works in multi mode too — add Mode: 'multi'.
});
this.pict.views['AuthorPicker'].render();Entity-source configuration:
| Option | Default | Purpose |
|---|---|---|
| Entity | — (required) | The Meadow entity name. |
| SearchFields | ['Name'] | Fields OR'd together in the LIKE search. |
| ValueField | ID<Entity> | Record field used as the option Value. |
| TextField | 'Name' | Record field used as the option Text. |
| PageSize | 20 | Records per page. |
| Sort | — | Field to sort ascending (FSF~<field>~ASC~0). |
| BaseFilter | — | An always-applied FoxHound filter (AND), e.g. FBV~IDCustomer~EQ~1. A string, an array of stanzas, a { Filters, BackOffFilters } split (see below), or a function (term, page) returning any of those — re-evaluated on every search, the hook for host-injected contextual scoping. |
| MapRecord | — | (record) => {Value, Text} mapper, overriding Value/TextField. |
Back-off filters (auto-widen on empty)
BaseFilter's object form splits the scope into a mandatory set and a preferred (back-off) set:
BaseFilter: () => ({
Filters: [ 'FBV~IDProject~EQ~10' ], // always applied
BackOffFilters: [ 'FBL~IDMaterial~INN~1,2,3' ], // preferred narrowing
})Both sets apply together first. When the first page of a search comes back empty, the request is retried once without the back-off set — so a host can narrow aggressively ("this project's materials") yet auto-widen instead of showing "No matches" when the preferred scope has nothing. Later pages of the same search stay widened, so "Load more" pages the set the user is actually looking at; the next fresh search re-applies the back-off set.
The lower-level builders are also exposed: createEntityDataProvider(cfg) and createEntityResolveValue(cfg) return the raw functions if you want to wire them yourself.
Joined display (parent-entity context)
Sometimes a searched entity is ambiguous on its own — a LineItem only makes sense next to its Project, a Review next to its Book. Set JoinEntity and the picker renders a compound label by joining each searched row to a parent entity through a foreign key the row carries:
tmpPicker.createEntityPicker('ReviewPicker',
{
Entity: 'Review',
SearchFields: [ 'Summary' ],
JoinEntity: 'Book', // the parent entity to join
JoinField: 'IDBook', // the FK on the Review row -> Book
JoinEntityDisplayField: 'Title', // the Book field to show
DestinationAddress: '#ReviewPicker',
ValueAddress: 'AppData.Form.IDReview',
});
// options render as "Neuromancer - Loved it"; the Value is still IDReview.Meadow can't join in a single read, so this is fetch-then-merge: after each search page the picker collects the rows' unique FK ids and issues one FBL~ID{JoinEntity}~INN~<ids> request, then stitches the joined display onto every row (also exposed as Record.JoinName / Record.JoinRecord for MapRecord / templates). The same join resolves a pre-bound value's label on first render.
| Option | Default | Purpose |
|---|---|---|
| JoinEntity | — | Parent entity to join for the compound display. Setting it enables the feature. |
| JoinField | ID<JoinEntity> | The FK column on the searched row pointing at JoinEntity. |
| JoinEntityValueField | ID<JoinEntity> | The PK column on JoinEntity to match (the INN column). |
| JoinEntityDisplayField | 'Name' | The JoinEntity field shown in the compound label. |
| JoinEntityFirst | true | true → Parent - Row; false → Row - Parent. |
| JoinSeparator | ' - ' | Separator between the two parts. |
The same options ride through the form-input adapter (PictForm.JoinEntity, …) and the pict-section-recordset entity filters (set JoinEntity on the clause) — so an entity filter can show parent context for its options with no host code, layered on top of either the 1:1 (direct-FK / InternalJoin) or 1:many (junction / ExternalJoin) filter relationship.
Tag badge (EntityTag)
Show a small code/number badge next to each option — the select2 EntitySelector "tag" parity. For entity pickers, EntityTag names the record field to badge; the picker renders it as a pill beside the label:
tmpPicker.createEntityPicker('PayItemPicker',
{
Entity: 'PayItem',
SearchFields: [ 'Name' ],
EntityTag: 'ItemCode', // each option shows its ItemCode as a badge
DestinationAddress: '#PayItemPicker',
ValueAddress: 'AppData.Form.IDPayItem',
});
// options render as badge + label, e.g. [201-1] ExcavationStatic options can carry a Tag directly ({ Value, Text, Tag }). The badge rides on the dropdown options, the selected single value, and multi-select chips alike. It composes with JoinEntity — the join folds into the label, the tag stays a separate pill.
Multiple badges (EntityTags). For several disambiguation chips per option (e.g. a book's ISBN and publication year), pass EntityTags — an array of field specs:
tmpPicker.createEntityPicker('BookPicker',
{
Entity: 'Book',
SearchFields: [ 'Title', 'ISBN' ],
EntityTags: [ 'ISBN', { Field: 'PublicationYear', Label: 'Year' } ], // -> [1416524797] [Year: 2000]
TagLast: true, // chips after the title: The Da Vinci Code 1416524797 Year: 2000
DestinationAddress: '#BookPicker',
ValueAddress: 'AppData.Form.IDBook',
});Each spec is a field name (raw value) or { Field, Label?, Template? } — Label prefixes the value ("Year: 2000"); Template renders against the whole record. Options carry the results as a Tags array (static options can set Tags directly); it composes with the single Tag. The recordset association UIs drive this from a per-side ChipFields config.
| Option | Default | Purpose |
|---|---|---|
| EntityTag | — | (entity pickers) record field whose value becomes each option's single Tag badge. |
| EntityTags | — | (entity pickers) array of string \| {Field, Label?, Template?} specs → multiple chips (Tags). |
| TagLast | false | false → badge(s) before the label; true → after. |
Through the form-input adapter + the recordset entity filters these are PictForm.EntityTag / PictForm.EntityTagLast.
Categories
Give option rows an optional Group field and the list renders headered sections (preserving order; rows without a Group fall into a leading unlabeled section):
Options:
[
{ Value: 'us', Text: 'United States', Group: 'Americas' },
{ Value: 'gb', Text: 'United Kingdom', Group: 'Europe' },
{ Value: 'jp', Text: 'Japan', Group: 'Asia' },
]Async sources can return Group on each result row too.
Creatable
Set OnCreate(searchTerm) => {Value, Text} | Promise<{Value, Text}>. When the search term is non-empty and doesn't exactly match an existing row, a "Create …" row appears (also triggerable with Enter). The returned record is selected (single) or added as a chip (multi):
OnCreate: (pTerm) =>
{
// A real app would POST the new entity and return the saved row (sync or async).
return { Value: slugify(pTerm), Text: pTerm };
}Configuration reference
| Option | Default | Purpose |
|---|---|---|
| DestinationAddress | #<hash> | CSS selector to render the control into. |
| ValueAddress | — | AppData address the selection is read from / written to. |
| Mode | 'single' | 'single' (scalar) or 'multi' (array + chips). |
| Placeholder | 'Select…' | Text shown when nothing is selected. |
| Searchable | true | Show the search box. |
| Options | [] | Static option list ({Value, Text, Group?}). |
| DataProvider | — | Async source (term, page) => Promise<{results, hasMore}>. |
| PageSize | 20 | Page size for async sources. |
| ResolveValue | — | (value) => Promise<{Value, Text}> to label a pre-bound value. |
| StringArrayValueAddress | — | (multi) mirror the value array as a csv string. |
| SelectedValuesAddress | — | (multi) mirror the selection as a record array. |
| OnCreate | — | (term) => {Value, Text} to enable creatable entries. |
| OnChange | — | Called after a selection: single → (value, record), multi → (values, records). |
| SingleActivePicker | false | Opt-in: opening this picker closes any other open picker that also opted in (one dropdown per page). A picker left false neither closes a participant nor is closed by one. Usually enabled fleet-wide by setting SingleActivePicker: true on the provider's options — createPicker seeds it into every picker it creates; an explicit per-picker value overrides. |
View methods
Call these on the picker view instance — this.pict.views['<hash>']:
| Method | Description |
|--------|-------------|
| render() | Paint (or repaint) the control into its destination. |
| getValue() | The current selection — a scalar in single mode, an array of values in multi mode. |
| setValue(pValue) | Set the selection programmatically — the supported counterpart to getValue(). Single mode takes a scalar; multi mode takes an array (or a csv string). Writes through to the bound address(es), resolves the display label of any unknown value (from the loaded options, else via ResolveValue in async mode), and repaints. Does not fire OnChange — it is a programmatic set (e.g. a host marshaling a form value into the control), not a user pick. Returns the view for chaining. |
| getSelectedRecords() | (multi) The full {Value, Text} record list for the current selection. |
Contextual scope changes need no hook. An async picker drops its loaded pages when the dropdown closes, so every open re-queries and re-resolves
BaseFilter. A host whose filters change outside the picker (a "Show All" toggle, a dependent-field pick) can just change them — the next open reflects it. There is no cache to invalidate.
const tmpPicker = this.pict.views['AuthorPicker'];
tmpPicker.setValue(141); // single: select author 141 (label resolves via ResolveValue if async)
tmpPicker.setValue([ 2, 10, 141 ]); // multi: select these values (array or "2,10,141" csv both accepted)
const tmpSelected = tmpPicker.getValue();Dropdown anchoring
The dropdown anchors itself in CSS: .pps-pop is position: absolute against the control's own
.pps box, so it travels with the control when the page scrolls — no measuring, no reflow work, and
no scroll or resize listeners.
That breaks in one situation: an ancestor with overflow other than visible clips an absolutely
positioned panel. Such a container can't be un-clipped in CSS, because setting one axis to auto or
hidden forces the other off visible.
The escape is to give the panel a containing block outside the clip — an overflow ancestor only clips
an absolutely positioned box when it sits in that box's containing-block chain. So on open the view
walks up from the control, and when it finds a clipping ancestor it moves the panel out to <body>
and places it in document coordinates, flipping above when the room below is short.
It stays position: absolute there on purpose. An absolute box on <body> resolves against the
document, so the browser keeps travelling it with the page exactly like the default path —
position: fixed would resolve against the viewport and strand the panel the moment you scrolled.
Both paths therefore anchor once and need no repositioning, and neither uses an event listener.
The portal engages only when the clip would actually reach the panel — not merely because an
overflow ancestor exists. On open the view finds the nearest clipping ancestor and projects where the
panel would open (downward from the control, up to its max height); if that box fits inside the clipper
it stays on the CSS path, and only a dropdown near a clipping edge — the case the portal is for —
moves out. So overflow: hidden used for rounded corners or text-ellipsis, or a tall scroll container
the dropdown clears with room to spare, does not force a portal. It is still one measurement at open,
with no listeners.
While portaled the panel is pinned to the control's width and clamped to the room actually available, so its list scrolls in place rather than running off the screen. The mode is re-decided on each open and on any re-render that happens while open; closing brings the panel back inside its widget.
One thing to know about the portal path: it tracks page scroll, not scrolling inside the clipping
container itself. Scrolling that container while the dropdown is open would leave the panel behind — in
practice unreachable, since the open dropdown's backdrop covers the viewport and takes the wheel and the
container's scrollbar. (Theme tokens are not a concern: a portaled panel carries the host's resolved
--theme-color-* values across with it, so it paints identically whether the tokens are defined at
:root or scoped to a container above the control.)
Theming
The widget paints from --theme-color-* tokens with sensible hex fallbacks, so it inherits the host app's theme. Define them at :root, on body, or on a container around the picker — a dropdown that portals out (see Dropdown anchoring) carries the resolved values with it, so a scoped container paints the same as :root. Relevant tokens: --theme-color-brand-primary, --theme-color-text-primary, --theme-color-text-secondary, --theme-color-text-muted, --theme-color-border-default, --theme-color-border-light, --theme-color-border-strong, --theme-color-background-primary, --theme-color-background-panel, --theme-color-background-tertiary.
Example application
example_applications/picker_demo exercises every mode (static / async / entity, single / multi, categorized, creatable). Build it with npm run build in that folder and open dist/index.html. The entity pickers in the demo talk to a live Meadow harness.
License
MIT
