pict-section-picker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/package.json +59 -0
- package/source/Pict-Section-Picker.js +20 -0
- package/source/providers/Pict-Provider-Picker.js +255 -0
- package/source/views/PictView-Picker.js +751 -0
- package/types/Pict-Section-Picker.d.ts +3 -0
- package/types/Pict-Section-Picker.d.ts.map +1 -0
- package/types/providers/Pict-Provider-Picker.d.ts +83 -0
- package/types/providers/Pict-Provider-Picker.d.ts.map +1 -0
- package/types/views/PictView-Picker.d.ts +110 -0
- package/types/views/PictView-Picker.d.ts.map +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015 - 2026 Steven Velozo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# pict-section-picker
|
|
2
|
+
|
|
3
|
+
A Pict-native, themeable searchable **select / combobox** — a jQuery/`select2`-free replacement for entity pickers and tag inputs in [Pict](https://github.com/fable-retold/pict) applications.
|
|
4
|
+
|
|
5
|
+
- **Single & multi select** — scalar value, or an array of values rendered as removable chips.
|
|
6
|
+
- **Search** with keyboard navigation (↑/↓ + Enter), click-outside to close.
|
|
7
|
+
- **Server pagination** — drive options from an async `DataProvider(searchTerm, page)`; "Load more" / infinite scroll.
|
|
8
|
+
- **Categorized options** — group rows under headers.
|
|
9
|
+
- **Creatable** — let users mint new entries from the search term via `OnCreate`.
|
|
10
|
+
- **Built-in Meadow adapter** — point it at a Meadow entity and it builds the server `DataProvider` (FoxHound `LIKE` search + paging) and the pre-bound-value resolver for you.
|
|
11
|
+
- **Themeable** via `--theme-color-*` tokens. No jQuery, no `select2`, no `addEventListener` — pure Pict conventions.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install pict-section-picker
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
const libPictSectionPicker = require('pict-section-picker');
|
|
25
|
+
|
|
26
|
+
// In your application's onAfterInitializeAsync:
|
|
27
|
+
this.pict.addProvider('Pict-Section-Picker', libPictSectionPicker.default_configuration, libPictSectionPicker);
|
|
28
|
+
const tmpPicker = this.pict.providers['Pict-Section-Picker'];
|
|
29
|
+
|
|
30
|
+
// A simple static single-select.
|
|
31
|
+
tmpPicker.createPicker('CountryPicker',
|
|
32
|
+
{
|
|
33
|
+
DestinationAddress: '#CountryPicker', // where to render
|
|
34
|
+
ValueAddress: 'AppData.Form.Country', // selection is read from / written to here
|
|
35
|
+
Placeholder: 'Select a country…',
|
|
36
|
+
Options: [ { Value: 'us', Text: 'United States' }, { Value: 'ca', Text: 'Canada' } ],
|
|
37
|
+
OnChange: (pValue, pRecord) => { /* … */ },
|
|
38
|
+
});
|
|
39
|
+
this.pict.views['CountryPicker'].render();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The control renders into `#CountryPicker`; `AppData.Form.Country` holds the selected value.
|
|
43
|
+
|
|
44
|
+
## Picker modes
|
|
45
|
+
|
|
46
|
+
### Single (default)
|
|
47
|
+
|
|
48
|
+
`Mode: 'single'` — `ValueAddress` holds the scalar value. Selecting closes the dropdown.
|
|
49
|
+
|
|
50
|
+
### Multi
|
|
51
|
+
|
|
52
|
+
`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):
|
|
53
|
+
|
|
54
|
+
| Option | Holds |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `ValueAddress` | the array of values, e.g. `[2, 10, 141]` |
|
|
57
|
+
| `StringArrayValueAddress` | a csv string, e.g. `"2,10,141"` |
|
|
58
|
+
| `SelectedValuesAddress` | the full record list, e.g. `[{Value, Text}, …]` |
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
tmpPicker.createPicker('TagsPicker',
|
|
62
|
+
{
|
|
63
|
+
Mode: 'multi',
|
|
64
|
+
DestinationAddress: '#TagsPicker',
|
|
65
|
+
ValueAddress: 'AppData.Form.Tags',
|
|
66
|
+
Placeholder: 'Add tags…',
|
|
67
|
+
Options: [ { Value: 'urgent', Text: 'urgent' }, { Value: 'review', Text: 'review' } ],
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Async data (server search + pagination)
|
|
72
|
+
|
|
73
|
+
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:
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
DataProvider: (pSearchTerm, pPage) => Promise.resolve(
|
|
77
|
+
{
|
|
78
|
+
results: [ { Value: 1, Text: 'First' }, /* … up to PageSize … */ ],
|
|
79
|
+
hasMore: true, // show a "Load more" button
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
## Meadow entity pickers
|
|
86
|
+
|
|
87
|
+
For the common case — picking a [Meadow](https://github.com/fable-retold/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.
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
tmpPicker.createEntityPicker('AuthorPicker',
|
|
91
|
+
{
|
|
92
|
+
Entity: 'Author', // the Meadow entity
|
|
93
|
+
SearchFields: [ 'Name' ], // fields to LIKE-search (default ['Name'])
|
|
94
|
+
ValueField: 'IDAuthor', // option Value (default 'ID<Entity>')
|
|
95
|
+
TextField: 'Name', // option Text (default 'Name')
|
|
96
|
+
PageSize: 20,
|
|
97
|
+
DestinationAddress: '#AuthorPicker',
|
|
98
|
+
ValueAddress: 'AppData.Form.IDAuthor',
|
|
99
|
+
Placeholder: 'Search authors…',
|
|
100
|
+
// Works in multi mode too — add Mode: 'multi'.
|
|
101
|
+
});
|
|
102
|
+
this.pict.views['AuthorPicker'].render();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Entity-source configuration:
|
|
106
|
+
|
|
107
|
+
| Option | Default | Purpose |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `Entity` | — (required) | The Meadow entity name. |
|
|
110
|
+
| `SearchFields` | `['Name']` | Fields OR'd together in the `LIKE` search. |
|
|
111
|
+
| `ValueField` | `ID<Entity>` | Record field used as the option `Value`. |
|
|
112
|
+
| `TextField` | `'Name'` | Record field used as the option `Text`. |
|
|
113
|
+
| `PageSize` | `20` | Records per page. |
|
|
114
|
+
| `Sort` | — | Field to sort ascending (`FSF~<field>~ASC~0`). |
|
|
115
|
+
| `BaseFilter` | — | An always-applied FoxHound filter (AND), e.g. `FBV~IDCustomer~EQ~1`. |
|
|
116
|
+
| `MapRecord` | — | `(record) => {Value, Text}` mapper, overriding `Value`/`TextField`. |
|
|
117
|
+
|
|
118
|
+
The lower-level builders are also exposed: `createEntityDataProvider(cfg)` and `createEntityResolveValue(cfg)` return the raw functions if you want to wire them yourself.
|
|
119
|
+
|
|
120
|
+
## Categories
|
|
121
|
+
|
|
122
|
+
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):
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
Options:
|
|
126
|
+
[
|
|
127
|
+
{ Value: 'us', Text: 'United States', Group: 'Americas' },
|
|
128
|
+
{ Value: 'gb', Text: 'United Kingdom', Group: 'Europe' },
|
|
129
|
+
{ Value: 'jp', Text: 'Japan', Group: 'Asia' },
|
|
130
|
+
]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Async sources can return `Group` on each result row too.
|
|
134
|
+
|
|
135
|
+
## Creatable
|
|
136
|
+
|
|
137
|
+
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):
|
|
138
|
+
|
|
139
|
+
```javascript
|
|
140
|
+
OnCreate: (pTerm) =>
|
|
141
|
+
{
|
|
142
|
+
// A real app would POST the new entity and return the saved row (sync or async).
|
|
143
|
+
return { Value: slugify(pTerm), Text: pTerm };
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Configuration reference
|
|
148
|
+
|
|
149
|
+
| Option | Default | Purpose |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `DestinationAddress` | `#<hash>` | CSS selector to render the control into. |
|
|
152
|
+
| `ValueAddress` | — | AppData address the selection is read from / written to. |
|
|
153
|
+
| `Mode` | `'single'` | `'single'` (scalar) or `'multi'` (array + chips). |
|
|
154
|
+
| `Placeholder` | `'Select…'` | Text shown when nothing is selected. |
|
|
155
|
+
| `Searchable` | `true` | Show the search box. |
|
|
156
|
+
| `Options` | `[]` | Static option list (`{Value, Text, Group?}`). |
|
|
157
|
+
| `DataProvider` | — | Async source `(term, page) => Promise<{results, hasMore}>`. |
|
|
158
|
+
| `PageSize` | `20` | Page size for async sources. |
|
|
159
|
+
| `ResolveValue` | — | `(value) => Promise<{Value, Text}>` to label a pre-bound value. |
|
|
160
|
+
| `StringArrayValueAddress` | — | (multi) mirror the value array as a csv string. |
|
|
161
|
+
| `SelectedValuesAddress` | — | (multi) mirror the selection as a record array. |
|
|
162
|
+
| `OnCreate` | — | `(term) => {Value, Text}` to enable creatable entries. |
|
|
163
|
+
| `OnChange` | — | Called after a selection: single → `(value, record)`, multi → `(values, records)`. |
|
|
164
|
+
|
|
165
|
+
## Theming
|
|
166
|
+
|
|
167
|
+
The widget paints from `--theme-color-*` tokens with sensible hex fallbacks, so it inherits the host app's theme. Relevant tokens: `--theme-color-brand-primary`, `--theme-color-text-primary`, `--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`.
|
|
168
|
+
|
|
169
|
+
## Example application
|
|
170
|
+
|
|
171
|
+
`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.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pict-section-picker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "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 replacement.",
|
|
5
|
+
"main": "source/Pict-Section-Picker.js",
|
|
6
|
+
"types": "types/Pict-Section-Picker.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"source",
|
|
9
|
+
"types"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "npx mocha -u tdd -R spec",
|
|
13
|
+
"tests": "npx mocha -u tdd -R spec -g",
|
|
14
|
+
"start": "node source/Pict-Section-Picker.js",
|
|
15
|
+
"coverage": "./node_modules/.bin/nyc --reporter=lcov --reporter=text-lcov ./node_modules/mocha/bin/_mocha -- -u tdd -R spec",
|
|
16
|
+
"build": "npx quack build",
|
|
17
|
+
"types": "tsc -p tsconfig.build.json"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/fable-retold/pict-section-picker.git"
|
|
22
|
+
},
|
|
23
|
+
"author": "steven velozo <steven@velozo.com>",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/fable-retold/pict-section-picker/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/fable-retold/pict-section-picker#readme",
|
|
29
|
+
"mocha": {
|
|
30
|
+
"diff": true,
|
|
31
|
+
"extension": [
|
|
32
|
+
"js"
|
|
33
|
+
],
|
|
34
|
+
"package": "./package.json",
|
|
35
|
+
"reporter": "spec",
|
|
36
|
+
"slow": "75",
|
|
37
|
+
"timeout": "5000",
|
|
38
|
+
"ui": "tdd",
|
|
39
|
+
"watch-files": [
|
|
40
|
+
"source/**/*.js",
|
|
41
|
+
"test/**/*.js"
|
|
42
|
+
],
|
|
43
|
+
"watch-ignore": [
|
|
44
|
+
"lib/vendor"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"pict-provider": "^1.0.13",
|
|
49
|
+
"pict-view": "^1.0.68"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/mocha": "^10.0.10",
|
|
53
|
+
"@types/node": "^16.18.126",
|
|
54
|
+
"browser-env": "^3.3.0",
|
|
55
|
+
"pict": "^1.0.372",
|
|
56
|
+
"quackage": "^1.3.0",
|
|
57
|
+
"typescript": "^5.9.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The container for all the Pict-Section-Picker related code.
|
|
2
|
+
//
|
|
3
|
+
// pict-section-picker is a themeable, pict-native searchable select / combobox: a jQuery- and
|
|
4
|
+
// select2-free widget supporting single & multi select, search, server pagination, categorized
|
|
5
|
+
// groups and creatable entries. It is driven by a host-agnostic async DataProvider, and ships a
|
|
6
|
+
// built-in adapter for the pict EntityProvider (Meadow entities) for the common case.
|
|
7
|
+
|
|
8
|
+
// The picker provider (primary API surface — registers the widget view + CSS, and exposes
|
|
9
|
+
// createPicker() / data-source adapters).
|
|
10
|
+
const PictProviderPicker = require('./providers/Pict-Provider-Picker.js');
|
|
11
|
+
|
|
12
|
+
// The widget view (also auto-registered by the provider).
|
|
13
|
+
const PictViewPicker = require('./views/PictView-Picker.js');
|
|
14
|
+
|
|
15
|
+
module.exports = PictProviderPicker;
|
|
16
|
+
|
|
17
|
+
module.exports.PictProviderPicker = PictProviderPicker;
|
|
18
|
+
module.exports.PictViewPicker = PictViewPicker;
|
|
19
|
+
|
|
20
|
+
module.exports.default_configuration = PictProviderPicker.default_configuration;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const libPictProvider = require('pict-provider');
|
|
2
|
+
|
|
3
|
+
const libPictViewPicker = require('../views/PictView-Picker.js');
|
|
4
|
+
|
|
5
|
+
// Themeable widget CSS. Registered once (by hash) regardless of how many picker instances exist.
|
|
6
|
+
// Host apps brand by defining the --theme-color-* tokens; the hardcoded values are fallbacks.
|
|
7
|
+
const _PickerCSS = /*css*/`
|
|
8
|
+
.pps { position: relative; width: 100%; box-sizing: border-box; }
|
|
9
|
+
.pps *, .pps *::before, .pps *::after { box-sizing: border-box; }
|
|
10
|
+
.pps-control { display: flex; align-items: center; gap: 0.5rem; width: 100%; cursor: pointer; font: inherit; font-size: 0.92rem;
|
|
11
|
+
padding: 0.45rem 0.7rem; border-radius: 8px; border: 1px solid var(--theme-color-border-default, #d7dce3);
|
|
12
|
+
background: var(--theme-color-background-primary, #fff); color: var(--theme-color-text-primary, #1f2733); text-align: left; }
|
|
13
|
+
.pps-control:hover { border-color: var(--theme-color-border-strong, #c2c9d2); }
|
|
14
|
+
.pps.pps-open .pps-control { border-color: var(--theme-color-brand-primary, #156dd1);
|
|
15
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 16%, transparent); }
|
|
16
|
+
.pps-valuearea { flex: 1 1 auto; min-width: 0; }
|
|
17
|
+
.pps-value { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
18
|
+
.pps-value.pps-placeholder { color: var(--theme-color-text-muted, #6b7686); }
|
|
19
|
+
.pps-chevron { flex: 0 0 auto; display: inline-flex; color: var(--theme-color-text-muted, #6b7686); font-size: 0.8rem; transition: transform 0.15s ease; }
|
|
20
|
+
.pps.pps-open .pps-chevron { transform: rotate(180deg); }
|
|
21
|
+
|
|
22
|
+
/* Multi-select chips. The control hosts a wrapping row of removable tags + a muted placeholder. */
|
|
23
|
+
.pps-multi .pps-control { align-items: flex-start; }
|
|
24
|
+
.pps-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; min-width: 0; }
|
|
25
|
+
.pps-chips-ph { color: var(--theme-color-text-muted, #6b7686); }
|
|
26
|
+
.pps-chip { display: inline-flex; align-items: center; gap: 0.3rem; max-width: 100%; font-size: 0.82rem; line-height: 1.4;
|
|
27
|
+
padding: 0.1rem 0.2rem 0.1rem 0.5rem; border-radius: 6px;
|
|
28
|
+
background: color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 12%, transparent);
|
|
29
|
+
color: var(--theme-color-brand-primary, #156dd1); }
|
|
30
|
+
.pps-chip-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
31
|
+
.pps-chip-x { flex: 0 0 auto; display: inline-flex; align-items: center; cursor: pointer; font-size: 0.78rem; border-radius: 4px; padding: 0.1rem; opacity: 0.7; }
|
|
32
|
+
.pps-chip-x:hover { opacity: 1; background: color-mix(in srgb, var(--theme-color-brand-primary, #156dd1) 22%, transparent); }
|
|
33
|
+
|
|
34
|
+
/* Transparent full-viewport backdrop: closes on outside click (no document listener). */
|
|
35
|
+
.pps-backdrop { position: fixed; inset: 0; z-index: 0; }
|
|
36
|
+
.pps-pop { position: absolute; z-index: 40; top: calc(100% + 0.3rem); left: 0; right: 0; min-width: 200px; display: none; }
|
|
37
|
+
.pps.pps-open .pps-pop { display: block; }
|
|
38
|
+
.pps-panel { position: relative; z-index: 1; display: flex; flex-direction: column; max-height: min(60vh, 360px);
|
|
39
|
+
background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3);
|
|
40
|
+
border-radius: 10px; box-shadow: 0 10px 28px rgba(17, 24, 39, 0.14); overflow: hidden; }
|
|
41
|
+
.pps-search { flex: 0 0 auto; display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--theme-color-border-light, #e8ebf0); }
|
|
42
|
+
.pps-search-ic { display: inline-flex; color: var(--theme-color-text-muted, #6b7686); font-size: 0.9rem; }
|
|
43
|
+
.pps-search input { flex: 1 1 auto; min-width: 0; font: inherit; font-size: 0.9rem; border: none; outline: none; background: transparent; color: var(--theme-color-text-primary, #1f2733); }
|
|
44
|
+
.pps-list { flex: 1 1 auto; overflow-y: auto; padding: 0.25rem; }
|
|
45
|
+
.pps-option { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; font: inherit; font-size: 0.9rem;
|
|
46
|
+
padding: 0.45rem 0.6rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-text-primary, #1f2733); }
|
|
47
|
+
.pps-option:hover, .pps-option.pps-highlight { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
48
|
+
.pps-option.pps-selected { color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
|
|
49
|
+
.pps-option-check { flex: 0 0 auto; display: inline-flex; width: 1em; color: var(--theme-color-brand-primary, #156dd1); }
|
|
50
|
+
.pps-option-check.pps-hidden { visibility: hidden; }
|
|
51
|
+
.pps-empty { padding: 0.7rem 0.6rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.86rem; text-align: center; }
|
|
52
|
+
.pps-loading { padding: 0.6rem; text-align: center; color: var(--theme-color-text-muted, #6b7686); font-size: 0.85rem; }
|
|
53
|
+
.pps-more { display: block; width: calc(100% - 0.5rem); margin: 0.25rem; padding: 0.4rem; cursor: pointer; font: inherit; font-size: 0.85rem;
|
|
54
|
+
border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 6px; background: transparent; color: var(--theme-color-brand-primary, #156dd1); }
|
|
55
|
+
.pps-more:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
56
|
+
|
|
57
|
+
/* Category header (groups) + creatable "Create …" row. */
|
|
58
|
+
.pps-group { padding: 0.45rem 0.6rem 0.2rem; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--theme-color-text-muted, #6b7686); }
|
|
59
|
+
.pps-create { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; font: inherit; font-size: 0.9rem;
|
|
60
|
+
padding: 0.45rem 0.6rem; border: none; border-radius: 6px; background: transparent; color: var(--theme-color-brand-primary, #156dd1); font-weight: 600; }
|
|
61
|
+
.pps-create:hover { background: var(--theme-color-background-tertiary, #eceef2); }
|
|
62
|
+
.pps-create-ic { flex: 0 0 auto; display: inline-flex; }
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
/** @type {Record<string, any>} */
|
|
66
|
+
const _DEFAULT_CONFIGURATION =
|
|
67
|
+
{
|
|
68
|
+
ProviderIdentifier: 'Pict-Section-Picker',
|
|
69
|
+
|
|
70
|
+
AutoInitialize: true,
|
|
71
|
+
AutoInitializeOrdinal: 0,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The pict-section-picker provider — the primary API surface. Registers the widget CSS once and
|
|
76
|
+
* creates/manages picker view instances.
|
|
77
|
+
*/
|
|
78
|
+
class PictProviderPicker extends libPictProvider
|
|
79
|
+
{
|
|
80
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
81
|
+
{
|
|
82
|
+
let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION, pOptions);
|
|
83
|
+
super(pFable, tmpOptions, pServiceHash);
|
|
84
|
+
|
|
85
|
+
if (this.pict && this.pict.CSSMap && typeof this.pict.CSSMap.addCSS === 'function')
|
|
86
|
+
{
|
|
87
|
+
this.pict.CSSMap.addCSS('Pict-Section-Picker-CSS', _PickerCSS, 500);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create (or reconfigure + reuse) a picker widget instance.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} pPickerHash - A unique hash/id for this picker; the widget renders its control
|
|
95
|
+
* into the DOM element `#<pPickerHash>` unless a DestinationAddress is given.
|
|
96
|
+
* @param {Record<string, any>} pConfig - Picker configuration:
|
|
97
|
+
* - DestinationAddress {string} - CSS selector to render into (default `#<pPickerHash>`).
|
|
98
|
+
* - ValueAddress {string} - AppData address the selection is read from / written to.
|
|
99
|
+
* - Mode {'single'} - selection mode (multi/categories/creatable land in later phases).
|
|
100
|
+
* - Placeholder {string} - text shown when nothing is selected.
|
|
101
|
+
* - Searchable {boolean} - show the search box (default true).
|
|
102
|
+
* - Options {Array<{Value:any, Text:string}>} - static option list.
|
|
103
|
+
* - OnChange {function} - optional callback invoked with the new value after a selection.
|
|
104
|
+
* @return {any} The picker view instance.
|
|
105
|
+
*/
|
|
106
|
+
createPicker(pPickerHash, pConfig)
|
|
107
|
+
{
|
|
108
|
+
const tmpConfig = Object.assign(
|
|
109
|
+
{
|
|
110
|
+
DestinationAddress: `#${pPickerHash}`,
|
|
111
|
+
Mode: 'single',
|
|
112
|
+
Searchable: true,
|
|
113
|
+
Placeholder: 'Select…',
|
|
114
|
+
Options: [],
|
|
115
|
+
},
|
|
116
|
+
pConfig || {},
|
|
117
|
+
{ PickerHash: pPickerHash });
|
|
118
|
+
|
|
119
|
+
if (this.pict.views[pPickerHash])
|
|
120
|
+
{
|
|
121
|
+
Object.assign(this.pict.views[pPickerHash].options, tmpConfig);
|
|
122
|
+
return this.pict.views[pPickerHash];
|
|
123
|
+
}
|
|
124
|
+
return this.pict.addView(pPickerHash, tmpConfig, libPictViewPicker);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the Meadow FoxHound filter for a search term across one or more fields.
|
|
129
|
+
*
|
|
130
|
+
* Single field → a clean AND-connected `FBV~Field~LK~%term%`. Multiple fields → the LIKEs are
|
|
131
|
+
* OR'd together inside a paren group (`FOP…FCP`) so the OR can't bleed into a sibling AND base
|
|
132
|
+
* filter. The term is `encodeURIComponent`-wrapped exactly as pict-section-recordset does, so the
|
|
133
|
+
* structural `~` separators stay literal in the URL path while the value is escaped.
|
|
134
|
+
*
|
|
135
|
+
* @param {Array<string>} pSearchFields - The entity fields to LIKE-match.
|
|
136
|
+
* @param {string} pTerm - The (raw) search term.
|
|
137
|
+
* @return {string} The FoxHound filter stanza(s).
|
|
138
|
+
*/
|
|
139
|
+
buildSearchFilter(pSearchFields, pTerm)
|
|
140
|
+
{
|
|
141
|
+
const tmpEncoded = encodeURIComponent(`%${pTerm}%`);
|
|
142
|
+
if (pSearchFields.length === 1)
|
|
143
|
+
{
|
|
144
|
+
return `FBV~${pSearchFields[0]}~LK~${tmpEncoded}`;
|
|
145
|
+
}
|
|
146
|
+
const tmpInner = pSearchFields.map((pField, pIndex) => `${pIndex === 0 ? 'FBV' : 'FBVOR'}~${pField}~LK~${tmpEncoded}`).join('~');
|
|
147
|
+
return `FOP~0~(~0~${tmpInner}~FCP~0~)~0`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build an async picker DataProvider backed by a Meadow entity (via `pict.EntityProvider`).
|
|
152
|
+
* Returns a `(searchTerm, page) => Promise<{ results:[{Value,Text,Record}], hasMore }>` function —
|
|
153
|
+
* the exact contract PictViewPicker consumes for server search + pagination.
|
|
154
|
+
*
|
|
155
|
+
* @param {Record<string, any>} pConfig - Entity source configuration:
|
|
156
|
+
* - Entity {string} (required) - the Meadow entity name (e.g. `Author`).
|
|
157
|
+
* - SearchFields {Array<string>} - fields to LIKE-search (default `['Name']`).
|
|
158
|
+
* - ValueField {string} - record field used as the option Value (default `ID<Entity>`).
|
|
159
|
+
* - TextField {string} - record field used as the option Text (default `Name`).
|
|
160
|
+
* - PageSize {number} - records per page (default 20).
|
|
161
|
+
* - Sort {string} - optional field to sort ascending (adds `FSF~<field>~ASC~0`).
|
|
162
|
+
* - BaseFilter {string} - optional always-applied FoxHound filter (AND), e.g. `FBV~IDCustomer~EQ~1`.
|
|
163
|
+
* - MapRecord {function} - optional `(record) => {Value, Text}` mapper (overrides Value/TextField).
|
|
164
|
+
* @return {(pSearchTerm: string, pPage: number) => Promise<{results: Array<any>, hasMore: boolean}>}
|
|
165
|
+
*/
|
|
166
|
+
createEntityDataProvider(pConfig)
|
|
167
|
+
{
|
|
168
|
+
const tmpEntity = pConfig.Entity;
|
|
169
|
+
const tmpSearchFields = (Array.isArray(pConfig.SearchFields) && pConfig.SearchFields.length > 0) ? pConfig.SearchFields : [ 'Name' ];
|
|
170
|
+
const tmpValueField = pConfig.ValueField || `ID${tmpEntity}`;
|
|
171
|
+
const tmpTextField = pConfig.TextField || 'Name';
|
|
172
|
+
const tmpPageSize = pConfig.PageSize || 20;
|
|
173
|
+
const tmpSort = pConfig.Sort || false;
|
|
174
|
+
const tmpBaseFilter = pConfig.BaseFilter || '';
|
|
175
|
+
const tmpMapRecord = (typeof pConfig.MapRecord === 'function') ? pConfig.MapRecord : false;
|
|
176
|
+
|
|
177
|
+
return (pSearchTerm, pPage) => new Promise((resolve, reject) =>
|
|
178
|
+
{
|
|
179
|
+
if (!this.pict.EntityProvider || typeof this.pict.EntityProvider.getEntitySetPage !== 'function')
|
|
180
|
+
{
|
|
181
|
+
return reject(new Error('Pict-Section-Picker: pict.EntityProvider is not available for entity-backed pickers.'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const tmpStanzas = [];
|
|
185
|
+
if (tmpBaseFilter) { tmpStanzas.push(tmpBaseFilter); }
|
|
186
|
+
if (pSearchTerm) { tmpStanzas.push(this.buildSearchFilter(tmpSearchFields, pSearchTerm)); }
|
|
187
|
+
if (tmpSort) { tmpStanzas.push(`FSF~${tmpSort}~ASC~0`); }
|
|
188
|
+
const tmpFilter = tmpStanzas.filter(Boolean).join('~');
|
|
189
|
+
|
|
190
|
+
const tmpCursor = (pPage || 0) * tmpPageSize;
|
|
191
|
+
this.pict.EntityProvider.getEntitySetPage(tmpEntity, tmpFilter, tmpCursor, tmpPageSize,
|
|
192
|
+
(pError, pRecords) =>
|
|
193
|
+
{
|
|
194
|
+
if (pError) { return reject(pError); }
|
|
195
|
+
const tmpList = Array.isArray(pRecords) ? pRecords : [];
|
|
196
|
+
const tmpResults = tmpList.map((pRecord) => tmpMapRecord
|
|
197
|
+
? tmpMapRecord(pRecord)
|
|
198
|
+
: { Value: pRecord[tmpValueField], Text: pRecord[tmpTextField], Record: pRecord });
|
|
199
|
+
// hasMore: a full page came back, so there is (probably) another. Avoids a Count round-trip.
|
|
200
|
+
return resolve({ results: tmpResults, hasMore: (tmpList.length >= tmpPageSize) });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build a `ResolveValue(value) => Promise<{Value,Text}>` for an entity-backed picker, so a
|
|
207
|
+
* pre-bound ID resolves to its display text on first render (fetched + cached by `getEntity`).
|
|
208
|
+
*
|
|
209
|
+
* @param {Record<string, any>} pConfig - Same shape as {@link createEntityDataProvider}.
|
|
210
|
+
* @return {(pValue: any) => Promise<any>}
|
|
211
|
+
*/
|
|
212
|
+
createEntityResolveValue(pConfig)
|
|
213
|
+
{
|
|
214
|
+
const tmpEntity = pConfig.Entity;
|
|
215
|
+
const tmpValueField = pConfig.ValueField || `ID${tmpEntity}`;
|
|
216
|
+
const tmpTextField = pConfig.TextField || 'Name';
|
|
217
|
+
const tmpMapRecord = (typeof pConfig.MapRecord === 'function') ? pConfig.MapRecord : false;
|
|
218
|
+
|
|
219
|
+
return (pValue) => new Promise((resolve) =>
|
|
220
|
+
{
|
|
221
|
+
if (pValue === undefined || pValue === null || pValue === '' || !this.pict.EntityProvider)
|
|
222
|
+
{
|
|
223
|
+
return resolve(null);
|
|
224
|
+
}
|
|
225
|
+
this.pict.EntityProvider.getEntity(tmpEntity, pValue,
|
|
226
|
+
(pError, pRecord) =>
|
|
227
|
+
{
|
|
228
|
+
if (pError || !pRecord) { return resolve(null); }
|
|
229
|
+
return resolve(tmpMapRecord ? tmpMapRecord(pRecord) : { Value: pRecord[tmpValueField], Text: pRecord[tmpTextField], Record: pRecord });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Create a picker backed by a Meadow entity — the high-level entry point for the real
|
|
236
|
+
* lims/config/bridge entity pickers. Wires a server DataProvider + ResolveValue from `pConfig`
|
|
237
|
+
* and delegates to {@link createPicker}. Any picker option (DestinationAddress, ValueAddress,
|
|
238
|
+
* Placeholder, OnChange, …) may also be supplied and is passed through.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} pPickerHash - Unique hash/id for this picker.
|
|
241
|
+
* @param {Record<string, any>} pConfig - {@link createEntityDataProvider} config + picker options.
|
|
242
|
+
* @return {any} The picker view instance.
|
|
243
|
+
*/
|
|
244
|
+
createEntityPicker(pPickerHash, pConfig)
|
|
245
|
+
{
|
|
246
|
+
const tmpConfig = Object.assign({}, pConfig);
|
|
247
|
+
tmpConfig.DataProvider = this.createEntityDataProvider(pConfig);
|
|
248
|
+
if (!tmpConfig.ResolveValue) { tmpConfig.ResolveValue = this.createEntityResolveValue(pConfig); }
|
|
249
|
+
return this.createPicker(pPickerHash, tmpConfig);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = PictProviderPicker;
|
|
254
|
+
|
|
255
|
+
module.exports.default_configuration = _DEFAULT_CONFIGURATION;
|