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.
@@ -0,0 +1,751 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ /** @type {Record<string, any>} */
4
+ const _DEFAULT_CONFIGURATION =
5
+ {
6
+ ViewIdentifier: 'Pict-Section-Picker-View',
7
+
8
+ AutoInitialize: false,
9
+ AutoRender: false,
10
+ AutoSolveWithApp: false,
11
+
12
+ DefaultRenderable: 'Pict-Section-Picker-Renderable',
13
+
14
+ // Per-instance options (supplied by PictProviderPicker.createPicker):
15
+ PickerHash: false,
16
+ DestinationAddress: false,
17
+ ValueAddress: false,
18
+ // 'single' (scalar value) or 'multi' (array of values, rendered as removable chips).
19
+ Mode: 'single',
20
+ Placeholder: 'Select…',
21
+ Searchable: true,
22
+ Options: [],
23
+ // Async data source (Phase 2): DataProvider(searchTerm, page) => Promise<{ results:[{Value,Text}], hasMore }>.
24
+ // When a function, the widget searches + paginates through it instead of the static Options list.
25
+ DataProvider: false,
26
+ PageSize: 20,
27
+ // Optional ResolveValue(value) => Promise<{Value,Text}> to resolve the display text of a pre-set
28
+ // value in async mode (e.g. fetch the entity for a bound ID so the control shows its name).
29
+ ResolveValue: false,
30
+ // Multi mode extra bindings (the EntitySelectorMultiple contract). All optional; ValueAddress
31
+ // always holds the array of values. These mirror it as a csv string and as the full record list.
32
+ StringArrayValueAddress: false,
33
+ SelectedValuesAddress: false,
34
+ // Creatable (Phase 4): OnCreate(searchTerm) => {Value,Text} | Promise<{Value,Text}>. When set, a
35
+ // "Create …" row appears for a non-empty search term that doesn't exactly match an existing option.
36
+ OnCreate: false,
37
+
38
+ Templates:
39
+ [
40
+ {
41
+ // The whole widget: control box + (transparent) backdrop + dropdown. The dropdown lives in
42
+ // the DOM whether open or closed (toggled by the .pps-open class) so open/close needs no
43
+ // re-render. The option list re-renders on its own for search (keeps the input focused).
44
+ // The control is a div (role=combobox) not a button so multi-mode chips can carry their own
45
+ // remove buttons without nesting <button> elements.
46
+ Hash: 'Pict-Section-Picker-Control',
47
+ Template: /*html*/`
48
+ <div class="pps{~NE:Record.IsMulti^ pps-multi~}" id="PPS_{~D:Record.PickerHash~}">
49
+ <div class="pps-control" role="combobox" tabindex="0" aria-haspopup="listbox" onclick="_Pict.views['{~D:Record.PickerHash~}'].toggle(event)" onkeydown="_Pict.views['{~D:Record.PickerHash~}'].onControlKey(event)">
50
+ <div class="pps-valuearea" id="PPS_Value_{~D:Record.PickerHash~}">{~T:Pict-Section-Picker-ValueArea~}</div>
51
+ <span class="pps-chevron">{~I:ChevronDown~}</span>
52
+ </div>
53
+ <div class="pps-backdrop" onclick="_Pict.views['{~D:Record.PickerHash~}'].close()"></div>
54
+ <div class="pps-pop">
55
+ <div class="pps-panel">
56
+ {~TS:Pict-Section-Picker-Search:Record.SearchSlot~}
57
+ <div class="pps-list" id="PPS_List_{~D:Record.PickerHash~}">
58
+ {~T:Pict-Section-Picker-List~}
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ `
64
+ },
65
+ {
66
+ // The control's value display: single-mode value span OR multi-mode chips, chosen by the
67
+ // single-element-array slots so we never render both.
68
+ Hash: 'Pict-Section-Picker-ValueArea',
69
+ Template: /*html*/`
70
+ {~TS:Pict-Section-Picker-Single:Record.SingleSlot~}
71
+ {~TS:Pict-Section-Picker-Multi:Record.MultiSlot~}
72
+ `
73
+ },
74
+ {
75
+ Hash: 'Pict-Section-Picker-Single',
76
+ Template: /*html*/`
77
+ <span class="pps-value{~NE:Record.NoValue^ pps-placeholder~}">{~D:Record.DisplayText~}</span>
78
+ `
79
+ },
80
+ {
81
+ // Multi-mode: the selected chips, plus a placeholder slot when nothing is selected.
82
+ Hash: 'Pict-Section-Picker-Multi',
83
+ Template: /*html*/`
84
+ <span class="pps-chips">{~TS:Pict-Section-Picker-Chip:Record.Chips~}{~TS:Pict-Section-Picker-Placeholder:Record.PlaceholderSlot~}</span>
85
+ `
86
+ },
87
+ {
88
+ Hash: 'Pict-Section-Picker-Placeholder',
89
+ Template: /*html*/`
90
+ <span class="pps-chips-ph">{~D:Record.Placeholder~}</span>
91
+ `
92
+ },
93
+ {
94
+ // One selected chip with an inline remove button. stopPropagation on the × so removing a
95
+ // chip never bubbles up to the control's open/close toggle.
96
+ Hash: 'Pict-Section-Picker-Chip',
97
+ Template: /*html*/`
98
+ <span class="pps-chip"><span class="pps-chip-text">{~D:Record.Text~}</span><span class="pps-chip-x" onclick="event.stopPropagation(); _Pict.views['{~D:Record.PickerHash~}'].removeChip('{~D:Record.ValueKey~}')">{~I:Close~}</span></span>
99
+ `
100
+ },
101
+ {
102
+ // Search box — its own template (gated by the single-element-array SearchSlot) because
103
+ // {~NE:~} does not recursively parse nested {~I:~}/{~D:~} tags.
104
+ Hash: 'Pict-Section-Picker-Search',
105
+ Template: /*html*/`
106
+ <div class="pps-search">
107
+ <span class="pps-search-ic">{~I:Search~}</span>
108
+ <input type="text" id="PPS_Search_{~D:Record.PickerHash~}" placeholder="Search…" autocomplete="off" oninput="_Pict.views['{~D:Record.PickerHash~}'].search(this.value)" onkeydown="_Pict.views['{~D:Record.PickerHash~}'].onSearchKey(event)">
109
+ </div>
110
+ `
111
+ },
112
+ {
113
+ Hash: 'Pict-Section-Picker-List',
114
+ Template: /*html*/`
115
+ {~TS:Pict-Section-Picker-Create:Record.CreateSlot~}
116
+ {~TS:Pict-Section-Picker-Group:Record.Groups~}
117
+ {~NE:Record.IsEmpty^<div class="pps-empty">No matches</div>~}
118
+ {~NE:Record.Loading^<div class="pps-loading">Loading…</div>~}
119
+ {~TS:Pict-Section-Picker-More:Record.MoreSlot~}
120
+ `
121
+ },
122
+ {
123
+ // A category: an optional header (single-element-array HeaderSlot) followed by its options.
124
+ // With no categories everything lands in one unlabeled group, so the list path is uniform.
125
+ Hash: 'Pict-Section-Picker-Group',
126
+ Template: /*html*/`
127
+ {~TS:Pict-Section-Picker-GroupHeader:Record.HeaderSlot~}
128
+ {~TS:Pict-Section-Picker-Option:Record.Options~}
129
+ `
130
+ },
131
+ {
132
+ Hash: 'Pict-Section-Picker-GroupHeader',
133
+ Template: /*html*/`
134
+ <div class="pps-group">{~D:Record.Label~}</div>
135
+ `
136
+ },
137
+ {
138
+ // "Create <term>" row — its own template (single-element-array CreateSlot), so the nested
139
+ // {~I:~}/{~D:~} tags parse (unlike inside an {~NE:~}).
140
+ Hash: 'Pict-Section-Picker-Create',
141
+ Template: /*html*/`
142
+ <button type="button" class="pps-create" onclick="_Pict.views['{~D:Record.PickerHash~}'].createFromSearch()"><span class="pps-create-ic">{~I:Plus~}</span><span>Create &ldquo;{~D:Record.Term~}&rdquo;</span></button>
143
+ `
144
+ },
145
+ {
146
+ // "Load more" — its own template (single-element-array MoreSlot) for the same nested-tag
147
+ // reason as the search box.
148
+ Hash: 'Pict-Section-Picker-More',
149
+ Template: /*html*/`
150
+ <button type="button" class="pps-more" onclick="_Pict.views['{~D:Record.PickerHash~}'].loadMore()">Load more</button>
151
+ `
152
+ },
153
+ {
154
+ Hash: 'Pict-Section-Picker-Option',
155
+ Template: /*html*/`
156
+ <button type="button" class="pps-option{~NE:Record.Selected^ pps-selected~}{~NE:Record.Highlight^ pps-highlight~}" onclick="_Pict.views['{~D:Record.PickerHash~}'].select('{~D:Record.ValueKey~}')">
157
+ <span class="pps-option-check{~NE:Record.NotSelected^ pps-hidden~}">{~I:Check~}</span>
158
+ <span>{~D:Record.Text~}</span>
159
+ </button>
160
+ `
161
+ },
162
+ ],
163
+
164
+ Renderables:
165
+ [
166
+ {
167
+ RenderableHash: 'Pict-Section-Picker-Renderable',
168
+ TemplateHash: 'Pict-Section-Picker-Control',
169
+ RenderMethod: 'replace',
170
+ },
171
+ ],
172
+ };
173
+
174
+ class PictViewPicker extends libPictView
175
+ {
176
+ constructor(pFable, pOptions, pServiceHash)
177
+ {
178
+ let tmpOptions = Object.assign({}, _DEFAULT_CONFIGURATION, pOptions);
179
+ super(pFable, tmpOptions, pServiceHash);
180
+
181
+ // Render the control into the host-supplied destination, and read state from this picker's
182
+ // AppData slot (keyed by hash so many pickers can share the global templates).
183
+ this.options.DefaultDestinationAddress = this.options.DestinationAddress || `#${this.options.PickerHash}`;
184
+ this._StateAddress = `AppData.PictSectionPicker.${this.options.PickerHash}`;
185
+ this.options.DefaultTemplateRecordAddress = this._StateAddress;
186
+ if (Array.isArray(this.options.Renderables) && this.options.Renderables[0])
187
+ {
188
+ this.options.Renderables[0].ContentDestinationAddress = this.options.DefaultDestinationAddress;
189
+ }
190
+
191
+ // Transient UI state (the data lives in AppData; these drive a single picker's interaction).
192
+ this._open = false;
193
+ this._search = '';
194
+ this._highlight = -1;
195
+ // Async-mode state (Phase 2): accumulated results across pages + paging/loading flags.
196
+ this._loadedResults = [];
197
+ this._page = 0;
198
+ this._hasMore = false;
199
+ this._loading = false;
200
+ this._loaded = false;
201
+ this._searchTimer = null;
202
+ this._selectedText = null;
203
+ // Multi-mode state: the authoritative {Value,Text} for each selected value, keyed by String(Value),
204
+ // so a chip keeps its label even after the search results that produced it have scrolled away.
205
+ this._values = [];
206
+ this._selectedRecords = {};
207
+
208
+ // Populate the AppData state slot now so the template Record (resolved from
209
+ // DefaultTemplateRecordAddress) reflects it on the very first render — pict resolves the
210
+ // Record before onBeforeRender runs.
211
+ try { this._buildState(); } catch (pError) { /* AppData/value not ready — onBeforeRender will build it */ }
212
+
213
+ // Async mode: resolve the display text of pre-set bound value(s) (e.g. fetch the entity for an ID).
214
+ if (this._isAsync() && typeof this.options.ResolveValue === 'function')
215
+ {
216
+ this._resolveInitialValues();
217
+ }
218
+ }
219
+
220
+ /** @return {boolean} True when a DataProvider function is configured (async/server mode). */
221
+ _isAsync()
222
+ {
223
+ return (typeof this.options.DataProvider === 'function');
224
+ }
225
+
226
+ /** @return {boolean} True when the picker is in multi-select (chips) mode. */
227
+ _isMulti()
228
+ {
229
+ return (this.options.Mode === 'multi');
230
+ }
231
+
232
+ /** @return {Record<string, any>} The AppData state slot for this picker. */
233
+ _state()
234
+ {
235
+ this.pict.AppData.PictSectionPicker = this.pict.AppData.PictSectionPicker || {};
236
+ this.pict.AppData.PictSectionPicker[this.options.PickerHash] = this.pict.AppData.PictSectionPicker[this.options.PickerHash] || {};
237
+ return this.pict.AppData.PictSectionPicker[this.options.PickerHash];
238
+ }
239
+
240
+ /** Resolve display text for any pre-bound value(s) via the async ResolveValue hook, then repaint. */
241
+ _resolveInitialValues()
242
+ {
243
+ const tmpResolveOne = (pValue) =>
244
+ {
245
+ if (pValue === undefined || pValue === null || pValue === '') { return; }
246
+ Promise.resolve(this.options.ResolveValue(pValue)).then((pResolved) =>
247
+ {
248
+ if (pResolved && pResolved.Text)
249
+ {
250
+ if (this._isMulti())
251
+ {
252
+ this._selectedRecords[String(pValue)] = { Value: pResolved.Value !== undefined ? pResolved.Value : pValue, Text: pResolved.Text };
253
+ this._renderValue();
254
+ }
255
+ else
256
+ {
257
+ this._selectedText = pResolved.Text;
258
+ this.render();
259
+ }
260
+ }
261
+ }).catch(() => { /* leave the raw value showing */ });
262
+ };
263
+
264
+ if (this._isMulti())
265
+ {
266
+ this.getValue().forEach(tmpResolveOne);
267
+ }
268
+ else
269
+ {
270
+ tmpResolveOne(this.getValue());
271
+ }
272
+ }
273
+
274
+ /**
275
+ * @return {any} The current selection: a scalar in single mode, or an array of values in multi mode
276
+ * (normalizing a csv string or scalar at the bound address into an array).
277
+ */
278
+ getValue()
279
+ {
280
+ const tmpRaw = this.options.ValueAddress
281
+ ? this.pict.manifest.getValueAtAddress(this.pict, this.options.ValueAddress)
282
+ : (this._isMulti() ? this._values : this._value);
283
+ if (!this._isMulti())
284
+ {
285
+ return tmpRaw;
286
+ }
287
+ if (tmpRaw === undefined || tmpRaw === null || tmpRaw === '') { return []; }
288
+ if (Array.isArray(tmpRaw)) { return tmpRaw; }
289
+ if (typeof tmpRaw === 'string') { return tmpRaw.split(',').filter((pPart) => pPart !== ''); }
290
+ return [ tmpRaw ];
291
+ }
292
+
293
+ /**
294
+ * Persist the selection to the bound address(es). Single mode writes the scalar; multi mode writes
295
+ * the array to ValueAddress and mirrors it to the optional csv / records addresses.
296
+ * @param {any} pValue - The new value (scalar in single mode, array in multi mode).
297
+ */
298
+ _setValue(pValue)
299
+ {
300
+ if (!this._isMulti())
301
+ {
302
+ this._value = pValue;
303
+ if (this.options.ValueAddress)
304
+ {
305
+ this.pict.manifest.setValueAtAddress(this.pict, this.options.ValueAddress, pValue);
306
+ }
307
+ return;
308
+ }
309
+
310
+ const tmpArray = Array.isArray(pValue) ? pValue : [];
311
+ this._values = tmpArray;
312
+ if (this.options.ValueAddress)
313
+ {
314
+ this.pict.manifest.setValueAtAddress(this.pict, this.options.ValueAddress, tmpArray);
315
+ }
316
+ if (this.options.StringArrayValueAddress)
317
+ {
318
+ this.pict.manifest.setValueAtAddress(this.pict, this.options.StringArrayValueAddress, tmpArray.join(','));
319
+ }
320
+ if (this.options.SelectedValuesAddress)
321
+ {
322
+ const tmpRecords = tmpArray.map((pVal) => this._selectedRecords[String(pVal)] || { Value: pVal, Text: String(pVal) });
323
+ this.pict.manifest.setValueAtAddress(this.pict, this.options.SelectedValuesAddress, tmpRecords);
324
+ }
325
+ }
326
+
327
+ /** @return {Array<{Value:any, Text:string}>} The current option source rows (async results or static Options). */
328
+ _sourceRows()
329
+ {
330
+ if (this._isAsync()) { return this._loadedResults; }
331
+ return Array.isArray(this.options.Options) ? this.options.Options : [];
332
+ }
333
+
334
+ /**
335
+ * (Re)compute the picker's render state into AppData: the displayed value / chips + the
336
+ * (search-filtered) option list with selected/highlight flags.
337
+ */
338
+ _buildState()
339
+ {
340
+ const tmpState = this._state();
341
+ const tmpAsync = this._isAsync();
342
+ const tmpMulti = this._isMulti();
343
+ const tmpSearch = (this._search || '').toLowerCase();
344
+
345
+ // Source rows: async = the accumulated server results (already filtered server-side);
346
+ // static = the configured Options, filtered locally by the search term.
347
+ const tmpStatic = Array.isArray(this.options.Options) ? this.options.Options : [];
348
+ const tmpSource = tmpAsync
349
+ ? this._loadedResults
350
+ : tmpStatic.filter((pOption) => !tmpSearch || String(pOption.Text).toLowerCase().includes(tmpSearch));
351
+
352
+ // Membership set used to flag options as selected (multi: every value; single: the one value).
353
+ const tmpSelectedKeys = new Set((tmpMulti ? this.getValue() : [ this.getValue() ])
354
+ .filter((pVal) => pVal !== undefined && pVal !== null && pVal !== '')
355
+ .map((pVal) => String(pVal)));
356
+
357
+ tmpState.Options = tmpSource.map((pOption, pIndex) =>
358
+ {
359
+ const tmpIsSelected = tmpSelectedKeys.has(String(pOption.Value));
360
+ return {
361
+ PickerHash: this.options.PickerHash,
362
+ ValueKey: String(pOption.Value),
363
+ Text: pOption.Text,
364
+ Selected: tmpIsSelected,
365
+ NotSelected: !tmpIsSelected,
366
+ Highlight: (pIndex === this._highlight),
367
+ };
368
+ });
369
+
370
+ // Cluster options into categories (preserving order), keyed by each source row's optional Group.
371
+ // With no Group fields everything lands in one unlabeled group, so the renderer has one path.
372
+ const tmpGroups = [];
373
+ const tmpGroupIndex = {};
374
+ tmpState.Options.forEach((pOption, pIndex) =>
375
+ {
376
+ const tmpLabel = (tmpSource[pIndex] && tmpSource[pIndex].Group) ? String(tmpSource[pIndex].Group) : '';
377
+ if (!(tmpLabel in tmpGroupIndex))
378
+ {
379
+ tmpGroupIndex[tmpLabel] = tmpGroups.length;
380
+ tmpGroups.push({ Label: tmpLabel, HeaderSlot: tmpLabel ? [ { Label: tmpLabel } ] : [], Options: [] });
381
+ }
382
+ tmpGroups[tmpGroupIndex[tmpLabel]].Options.push(pOption);
383
+ });
384
+ tmpState.Groups = tmpGroups;
385
+
386
+ // Creatable: offer "Create <term>" for a non-empty search that doesn't exactly match a known row.
387
+ const tmpTerm = (this._search || '').trim();
388
+ const tmpCanCreate = (typeof this.options.OnCreate === 'function') && tmpTerm.length > 0
389
+ && !this._sourceRows().some((pRow) => String(pRow.Text).trim().toLowerCase() === tmpTerm.toLowerCase());
390
+ tmpState.CreateSlot = tmpCanCreate ? [ { PickerHash: this.options.PickerHash, Term: tmpTerm } ] : [];
391
+
392
+ tmpState.PickerHash = this.options.PickerHash;
393
+ tmpState.IsMulti = tmpMulti;
394
+ tmpState.Placeholder = this.options.Placeholder;
395
+ tmpState.Searchable = !!this.options.Searchable;
396
+ // Single-element-array conditionals (render the search box / "Load more" only when applicable).
397
+ tmpState.SearchSlot = this.options.Searchable ? [ { PickerHash: this.options.PickerHash } ] : [];
398
+ tmpState.Loading = !!this._loading;
399
+ tmpState.IsEmpty = (tmpState.Options.length === 0 && !this._loading && !tmpCanCreate);
400
+ tmpState.HasMore = !!(tmpAsync && this._hasMore && !this._loading);
401
+ tmpState.MoreSlot = tmpState.HasMore ? [ { PickerHash: this.options.PickerHash } ] : [];
402
+
403
+ // The single/multi value-area is rendered via single-element-array slots; each slot's element
404
+ // IS the Record for its sub-template, so it must carry everything that template references.
405
+ if (tmpMulti)
406
+ {
407
+ const tmpValues = this.getValue();
408
+ const tmpNoValue = (tmpValues.length === 0);
409
+ const tmpChips = tmpValues.map((pVal) =>
410
+ {
411
+ const tmpRecord = this._lookupRecord(pVal);
412
+ return { PickerHash: this.options.PickerHash, ValueKey: String(pVal), Text: tmpRecord ? tmpRecord.Text : String(pVal) };
413
+ });
414
+ tmpState.SingleSlot = [];
415
+ tmpState.MultiSlot = [ {
416
+ PickerHash: this.options.PickerHash,
417
+ Chips: tmpChips,
418
+ PlaceholderSlot: tmpNoValue ? [ { Placeholder: this.options.Placeholder } ] : [],
419
+ } ];
420
+ }
421
+ else
422
+ {
423
+ const tmpValue = this.getValue();
424
+ const tmpHasValue = (tmpValue !== undefined && tmpValue !== null && tmpValue !== '');
425
+ const tmpSelected = this._lookupRecord(tmpValue);
426
+ tmpState.SingleSlot = [ {
427
+ PickerHash: this.options.PickerHash,
428
+ DisplayText: tmpSelected ? tmpSelected.Text : (this._selectedText || (tmpHasValue ? String(tmpValue) : this.options.Placeholder)),
429
+ NoValue: !tmpHasValue,
430
+ } ];
431
+ tmpState.MultiSlot = [];
432
+ }
433
+ return tmpState;
434
+ }
435
+
436
+ /**
437
+ * Find the {Value,Text} record for a value: the stored selection record (authoritative for chips /
438
+ * async), else a row in the current source (static Options or loaded results).
439
+ * @param {any} pValue
440
+ * @return {{Value:any, Text:string}|null}
441
+ */
442
+ _lookupRecord(pValue)
443
+ {
444
+ if (pValue === undefined || pValue === null || pValue === '') { return null; }
445
+ const tmpStored = this._selectedRecords[String(pValue)];
446
+ if (tmpStored) { return tmpStored; }
447
+ return this._sourceRows().find((pOption) => String(pOption.Value) === String(pValue)) || null;
448
+ }
449
+
450
+ /**
451
+ * Load a page of results from the async DataProvider, accumulating (append) or replacing the list.
452
+ * @param {number} pPage - zero-based page index.
453
+ * @param {boolean} pAppend - true to append (Load more), false to replace (new search / first open).
454
+ */
455
+ _loadPage(pPage, pAppend)
456
+ {
457
+ if (!this._isAsync()) { return; }
458
+ this._loading = true;
459
+ this._renderList();
460
+ const tmpSearchAtRequest = this._search;
461
+ Promise.resolve()
462
+ .then(() => this.options.DataProvider(this._search, pPage))
463
+ .then((pResult) =>
464
+ {
465
+ // Drop a stale first-page response if the search term changed while it was in flight.
466
+ if (!pAppend && pPage === 0 && tmpSearchAtRequest !== this._search) { return; }
467
+ const tmpResults = (pResult && Array.isArray(pResult.results)) ? pResult.results : [];
468
+ this._loadedResults = pAppend ? this._loadedResults.concat(tmpResults) : tmpResults;
469
+ this._hasMore = !!(pResult && pResult.hasMore);
470
+ this._page = pPage;
471
+ this._loaded = true;
472
+ this._loading = false;
473
+ this._renderList();
474
+ })
475
+ .catch((pError) =>
476
+ {
477
+ this.pict.log.warn(`Pict-Section-Picker [${this.options.PickerHash}] DataProvider error.`, pError);
478
+ this._loading = false;
479
+ this._renderList();
480
+ });
481
+ }
482
+
483
+ /**
484
+ * @param {import('pict-view').Renderable} pRenderable
485
+ */
486
+ onBeforeRender(pRenderable)
487
+ {
488
+ this._buildState();
489
+ return super.onBeforeRender(pRenderable);
490
+ }
491
+
492
+ /** Toggle the dropdown open/closed. */
493
+ toggle(pEvent)
494
+ {
495
+ if (pEvent) { pEvent.preventDefault(); }
496
+ return this._open ? this.close() : this.open();
497
+ }
498
+
499
+ /** Keyboard on the control: open the dropdown on Enter / Space / ArrowDown. */
500
+ onControlKey(pEvent)
501
+ {
502
+ if (pEvent.key === 'Enter' || pEvent.key === ' ' || pEvent.key === 'ArrowDown')
503
+ {
504
+ pEvent.preventDefault();
505
+ if (!this._open) { this.open(); }
506
+ }
507
+ else if (pEvent.key === 'Escape')
508
+ {
509
+ this.close();
510
+ }
511
+ }
512
+
513
+ /** Open the dropdown and focus the search box. */
514
+ open()
515
+ {
516
+ this._open = true;
517
+ this._highlight = -1;
518
+ this._paintOpen();
519
+ if (this._isAsync() && !this._loaded) { this._loadPage(0, false); }
520
+ const tmpSearch = /** @type {HTMLInputElement} */ (document.getElementById(`PPS_Search_${this.options.PickerHash}`));
521
+ if (tmpSearch) { tmpSearch.focus(); tmpSearch.select(); }
522
+ }
523
+
524
+ /** Async mode: load + append the next page of results. */
525
+ loadMore()
526
+ {
527
+ if (this._isAsync() && this._hasMore && !this._loading)
528
+ {
529
+ this._loadPage(this._page + 1, true);
530
+ }
531
+ }
532
+
533
+ /** Close the dropdown. */
534
+ close()
535
+ {
536
+ this._open = false;
537
+ this._highlight = -1;
538
+ this._paintOpen();
539
+ }
540
+
541
+ /** Reflect the open/closed state on the widget container. */
542
+ _paintOpen()
543
+ {
544
+ const tmpRoot = document.getElementById(`PPS_${this.options.PickerHash}`);
545
+ if (tmpRoot) { tmpRoot.classList.toggle('pps-open', !!this._open); }
546
+ }
547
+
548
+ /** Re-render only the option list (keeps the search input + its focus intact). */
549
+ _renderList()
550
+ {
551
+ this._buildState();
552
+ const tmpHTML = this.pict.parseTemplateByHash('Pict-Section-Picker-List', this._state());
553
+ this.pict.ContentAssignment.assignContent(`#PPS_List_${this.options.PickerHash}`, tmpHTML);
554
+ }
555
+
556
+ /** Re-render only the control's value area (the value span or the chips) — used in multi mode so
557
+ * toggling a selection updates the chips without tearing down the open dropdown + search box. */
558
+ _renderValue()
559
+ {
560
+ this._buildState();
561
+ const tmpHTML = this.pict.parseTemplateByHash('Pict-Section-Picker-ValueArea', this._state());
562
+ this.pict.ContentAssignment.assignContent(`#PPS_Value_${this.options.PickerHash}`, tmpHTML);
563
+ }
564
+
565
+ /** @param {string} pValue - Filter the option list by this search term. */
566
+ search(pValue)
567
+ {
568
+ this._search = pValue || '';
569
+ this._highlight = -1;
570
+ if (this._isAsync())
571
+ {
572
+ // Debounce server searches; reset to page 0.
573
+ if (this._searchTimer) { clearTimeout(this._searchTimer); }
574
+ this._searchTimer = setTimeout(() => { this._loadPage(0, false); }, 220);
575
+ }
576
+ else
577
+ {
578
+ this._renderList();
579
+ }
580
+ }
581
+
582
+ /** Keyboard navigation within the search box: arrows highlight, Enter selects, Escape closes. */
583
+ onSearchKey(pEvent)
584
+ {
585
+ const tmpOptions = this._state().Options || [];
586
+ if (pEvent.key === 'ArrowDown')
587
+ {
588
+ pEvent.preventDefault();
589
+ this._highlight = Math.min(this._highlight + 1, tmpOptions.length - 1);
590
+ this._renderList();
591
+ }
592
+ else if (pEvent.key === 'ArrowUp')
593
+ {
594
+ pEvent.preventDefault();
595
+ this._highlight = Math.max(this._highlight - 1, 0);
596
+ this._renderList();
597
+ }
598
+ else if (pEvent.key === 'Enter')
599
+ {
600
+ pEvent.preventDefault();
601
+ if (this._highlight >= 0 && tmpOptions[this._highlight])
602
+ {
603
+ this.select(tmpOptions[this._highlight].ValueKey);
604
+ }
605
+ else if ((this._state().CreateSlot || []).length > 0)
606
+ {
607
+ this.createFromSearch();
608
+ }
609
+ }
610
+ else if (pEvent.key === 'Escape')
611
+ {
612
+ pEvent.preventDefault();
613
+ this.close();
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Select an option. Single mode: set the value + close. Multi mode: toggle the value in/out of the
619
+ * selection, keep the dropdown open, and refocus the search box for rapid multi-pick.
620
+ * @param {string} pValueKey - String(Value) of the option.
621
+ */
622
+ select(pValueKey)
623
+ {
624
+ const tmpOption = this._sourceRows().find((pOption) => String(pOption.Value) === String(pValueKey));
625
+ if (!tmpOption) { return; }
626
+
627
+ if (!this._isMulti())
628
+ {
629
+ this._selectedText = tmpOption.Text;
630
+ this._setValue(tmpOption.Value);
631
+ this._search = '';
632
+ this._open = false;
633
+ this._highlight = -1;
634
+ this.render();
635
+ if (typeof this.options.OnChange === 'function')
636
+ {
637
+ this.options.OnChange(tmpOption.Value, tmpOption);
638
+ }
639
+ return;
640
+ }
641
+
642
+ // Multi: toggle membership.
643
+ const tmpValues = this.getValue().slice();
644
+ const tmpIndex = tmpValues.findIndex((pVal) => String(pVal) === String(pValueKey));
645
+ if (tmpIndex >= 0)
646
+ {
647
+ tmpValues.splice(tmpIndex, 1);
648
+ delete this._selectedRecords[String(pValueKey)];
649
+ }
650
+ else
651
+ {
652
+ tmpValues.push(tmpOption.Value);
653
+ this._selectedRecords[String(pValueKey)] = { Value: tmpOption.Value, Text: tmpOption.Text };
654
+ }
655
+ this._setValue(tmpValues);
656
+ this._renderValue();
657
+ this._renderList();
658
+ const tmpSearch = document.getElementById(`PPS_Search_${this.options.PickerHash}`);
659
+ if (tmpSearch) { tmpSearch.focus(); }
660
+ if (typeof this.options.OnChange === 'function')
661
+ {
662
+ this.options.OnChange(tmpValues, this.getSelectedRecords());
663
+ }
664
+ }
665
+
666
+ /** @return {Array<{Value:any, Text:string}>} The full record list for the current multi selection. */
667
+ getSelectedRecords()
668
+ {
669
+ return this.getValue().map((pVal) => this._selectedRecords[String(pVal)] || { Value: pVal, Text: String(pVal) });
670
+ }
671
+
672
+ /**
673
+ * Creatable: build a new option from the current search term via OnCreate, then select it (single:
674
+ * set + close; multi: add a chip). The created record is inserted into the source list so it shows
675
+ * as a normal, checked option.
676
+ */
677
+ createFromSearch()
678
+ {
679
+ const tmpTerm = (this._search || '').trim();
680
+ if (!tmpTerm || typeof this.options.OnCreate !== 'function') { return; }
681
+ Promise.resolve(this.options.OnCreate(tmpTerm)).then((pRecord) =>
682
+ {
683
+ if (!pRecord || pRecord.Value === undefined || pRecord.Value === null) { return; }
684
+ // Make the new record part of the source so the list can render it like any other option.
685
+ if (this._isAsync())
686
+ {
687
+ if (!this._loadedResults.some((pRow) => String(pRow.Value) === String(pRecord.Value))) { this._loadedResults.unshift(pRecord); }
688
+ }
689
+ else if (Array.isArray(this.options.Options) && !this.options.Options.some((pRow) => String(pRow.Value) === String(pRecord.Value)))
690
+ {
691
+ this.options.Options.unshift(pRecord);
692
+ }
693
+ this._selectedRecords[String(pRecord.Value)] = { Value: pRecord.Value, Text: pRecord.Text };
694
+
695
+ if (this._isMulti())
696
+ {
697
+ const tmpValues = this.getValue().slice();
698
+ if (!tmpValues.some((pVal) => String(pVal) === String(pRecord.Value))) { tmpValues.push(pRecord.Value); }
699
+ this._setValue(tmpValues);
700
+ this._search = '';
701
+ this._highlight = -1;
702
+ this._renderValue();
703
+ this._renderList();
704
+ const tmpSearchBox = /** @type {HTMLInputElement} */ (document.getElementById(`PPS_Search_${this.options.PickerHash}`));
705
+ if (tmpSearchBox) { tmpSearchBox.value = ''; tmpSearchBox.focus(); }
706
+ if (typeof this.options.OnChange === 'function') { this.options.OnChange(tmpValues, this.getSelectedRecords()); }
707
+ }
708
+ else
709
+ {
710
+ this._selectedText = pRecord.Text;
711
+ this._setValue(pRecord.Value);
712
+ this._search = '';
713
+ this._open = false;
714
+ this._highlight = -1;
715
+ this.render();
716
+ if (typeof this.options.OnChange === 'function') { this.options.OnChange(pRecord.Value, pRecord); }
717
+ }
718
+ }).catch((pError) =>
719
+ {
720
+ this.pict.log.warn(`Pict-Section-Picker [${this.options.PickerHash}] OnCreate error.`, pError);
721
+ });
722
+ }
723
+
724
+ /** Multi mode: remove a selected value (chip ×). Keeps the dropdown state as-is. */
725
+ removeChip(pValueKey)
726
+ {
727
+ const tmpValues = this.getValue().filter((pVal) => String(pVal) !== String(pValueKey));
728
+ delete this._selectedRecords[String(pValueKey)];
729
+ this._setValue(tmpValues);
730
+ this._renderValue();
731
+ if (this._open) { this._renderList(); }
732
+ if (typeof this.options.OnChange === 'function')
733
+ {
734
+ this.options.OnChange(tmpValues, this.getSelectedRecords());
735
+ }
736
+ }
737
+
738
+ /**
739
+ * @param {import('pict-view').Renderable} pRenderable
740
+ */
741
+ onAfterRender(pRenderable)
742
+ {
743
+ if (this.pict.CSSMap && typeof this.pict.CSSMap.injectCSS === 'function') { this.pict.CSSMap.injectCSS(); }
744
+ this._paintOpen();
745
+ return super.onAfterRender(pRenderable);
746
+ }
747
+ }
748
+
749
+ module.exports = PictViewPicker;
750
+
751
+ module.exports.default_configuration = _DEFAULT_CONFIGURATION;