@pure-ds/core 0.5.32 → 0.5.34

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,624 @@
1
+ /**
2
+ * Omnibox search input with PDS styling and form-associated behavior.
3
+ *
4
+ * @element pds-omnibox
5
+ * @formAssociated
6
+ *
7
+ * @attr {string} name - Form field name for submitted data
8
+ * @attr {string} placeholder - Placeholder text for the search input
9
+ * @attr {string} value - Current input value
10
+ * @attr {boolean} disabled - Disable the input
11
+ * @attr {boolean} required - Mark the input as required
12
+ * @attr {string} autocomplete - Native autocomplete attribute (default: off)
13
+ *
14
+ * @property {Object} settings - AutoComplete settings object (required by consumer)
15
+ */
16
+ const LAYERS = ["tokens", "primitives", "components", "utilities"];
17
+ const DEFAULT_PLACEHOLDER = "Search...";
18
+ const DEFAULT_ICON = "magnifying-glass";
19
+
20
+ export class PdsOmnibox extends HTMLElement {
21
+ static formAssociated = true;
22
+
23
+ static get observedAttributes() {
24
+ return ["name", "placeholder", "value", "disabled", "required", "autocomplete", "icon"];
25
+ }
26
+
27
+ #root;
28
+ #internals;
29
+ #input;
30
+ #icon;
31
+ #settings;
32
+ #defaultValue = "";
33
+ #autoCompleteResizeHandler;
34
+ #autoCompleteScrollHandler;
35
+ #autoCompleteViewportHandler;
36
+ #lengthProbe;
37
+ #suggestionsUpdatedHandler;
38
+ #suggestionsObserver;
39
+
40
+ constructor() {
41
+ super();
42
+ this.#root = this.attachShadow({ mode: "open" });
43
+ this.#internals = this.attachInternals();
44
+ this.#renderStructure();
45
+ void this.#adoptStyles();
46
+ }
47
+
48
+ connectedCallback() {
49
+ this.#defaultValue = this.getAttribute("value") || "";
50
+ this.#syncAttributes();
51
+ this.#updateFormValue(this.#input.value || "");
52
+ if (!this.#suggestionsUpdatedHandler) {
53
+ this.#suggestionsUpdatedHandler = (event) => {
54
+ this.#handleSuggestionsUpdated(event);
55
+ };
56
+ this.addEventListener("suggestions-updated", this.#suggestionsUpdatedHandler);
57
+ }
58
+ }
59
+
60
+ disconnectedCallback() {
61
+ this.#teardownAutoCompleteSizing();
62
+ this.#teardownSuggestionsObserver();
63
+ if (this.#suggestionsUpdatedHandler) {
64
+ this.removeEventListener("suggestions-updated", this.#suggestionsUpdatedHandler);
65
+ this.#suggestionsUpdatedHandler = null;
66
+ }
67
+ }
68
+
69
+ attributeChangedCallback(name, oldValue, newValue) {
70
+ if (oldValue === newValue) return;
71
+ this.#syncAttributes();
72
+ }
73
+
74
+ get settings() {
75
+ return this.#settings;
76
+ }
77
+
78
+ set settings(value) {
79
+ this.#settings = value;
80
+ }
81
+
82
+ get name() {
83
+ return this.getAttribute("name") || "";
84
+ }
85
+
86
+ set name(value) {
87
+ if (value == null || value === "") this.removeAttribute("name");
88
+ else this.setAttribute("name", value);
89
+ }
90
+
91
+ get placeholder() {
92
+ return this.getAttribute("placeholder") || DEFAULT_PLACEHOLDER;
93
+ }
94
+
95
+ set placeholder(value) {
96
+ if (value == null || value === "") this.removeAttribute("placeholder");
97
+ else this.setAttribute("placeholder", value);
98
+ }
99
+
100
+ get value() {
101
+ return this.#input?.value || "";
102
+ }
103
+
104
+ set value(value) {
105
+ const next = value == null ? "" : String(value);
106
+ if (this.#input) this.#input.value = next;
107
+ this.#updateFormValue(next);
108
+ }
109
+
110
+ get disabled() {
111
+ return this.hasAttribute("disabled");
112
+ }
113
+
114
+ set disabled(value) {
115
+ if (value) this.setAttribute("disabled", "");
116
+ else this.removeAttribute("disabled");
117
+ }
118
+
119
+ get required() {
120
+ return this.hasAttribute("required");
121
+ }
122
+
123
+ set required(value) {
124
+ if (value) this.setAttribute("required", "");
125
+ else this.removeAttribute("required");
126
+ }
127
+
128
+ get autocomplete() {
129
+ return this.getAttribute("autocomplete") || "off";
130
+ }
131
+
132
+ set autocomplete(value) {
133
+ if (value == null || value === "") this.removeAttribute("autocomplete");
134
+ else this.setAttribute("autocomplete", value);
135
+ }
136
+
137
+ get icon() {
138
+ return this.getAttribute("icon") || DEFAULT_ICON;
139
+ }
140
+
141
+ set icon(value) {
142
+ if (value == null || value === "") this.removeAttribute("icon");
143
+ else this.setAttribute("icon", value);
144
+ }
145
+
146
+ formAssociatedCallback() {}
147
+
148
+ formDisabledCallback(disabled) {
149
+ if (!this.#input) return;
150
+ this.#input.disabled = disabled;
151
+ }
152
+
153
+ formResetCallback() {
154
+ this.value = this.#defaultValue;
155
+ }
156
+
157
+ formStateRestoreCallback(state) {
158
+ this.value = state ?? "";
159
+ }
160
+
161
+ checkValidity() {
162
+ return this.#input?.checkValidity?.() ?? true;
163
+ }
164
+
165
+ reportValidity() {
166
+ return this.#input?.reportValidity?.() ?? true;
167
+ }
168
+
169
+ #renderStructure() {
170
+ this.#root.innerHTML = `
171
+ <div class="ac-container input-icon">
172
+ <pds-icon morph icon="${DEFAULT_ICON}"></pds-icon>
173
+ <input class="ac-input" type="search" placeholder="${DEFAULT_PLACEHOLDER}" autocomplete="off" />
174
+ </div>
175
+ `;
176
+
177
+ this.#lengthProbe = document.createElement("div");
178
+ this.#lengthProbe.style.cssText = "position:absolute; visibility:hidden; width:0; height:0; pointer-events:none;";
179
+ this.#root.appendChild(this.#lengthProbe);
180
+
181
+ this.#input = this.#root.querySelector("input");
182
+ this.#icon = this.#root.querySelector("pds-icon");
183
+ this.#input.addEventListener("input", () => {
184
+ this.#updateFormValue(this.#input.value);
185
+ this.#updateSuggestionMaxHeight();
186
+ this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
187
+ });
188
+ this.#input.addEventListener("change", () => {
189
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
190
+ });
191
+ this.#input.addEventListener("focus", (e) => {
192
+ this.#handleAutoComplete(e);
193
+ });
194
+ this.#input.addEventListener("show-results", (event) => {
195
+ this.dispatchEvent(
196
+ new CustomEvent("suggestions-updated", {
197
+ detail: { results: event?.detail?.results ?? [] },
198
+ bubbles: true,
199
+ composed: true,
200
+ })
201
+ );
202
+ });
203
+ }
204
+
205
+ async #adoptStyles() {
206
+ const componentStyles = PDS.createStylesheet(/*css*/`
207
+ @layer omnibox {
208
+ :host {
209
+ display: block;
210
+ --ac-grid-default: 45px 1fr 80px;
211
+ --ac-bg-default: var(--color-surface-subtle);
212
+ --ac-color-default: var(--color-text-primary);
213
+ --ac-accent-color-default: var(--color-accent-500);
214
+ --ac-rad: var(--radius-lg);
215
+ --ac-box-shadow: var(--shadow-md);
216
+ --ac-color-muted: var(--color-text-muted);
217
+ --ac-margin: var(--spacing-0);
218
+ --icon-size: var(--spacing-6);
219
+ --ac-itm-height-default: 5rem;
220
+ --ac-max-height-default: 300px;
221
+ --ac-viewport-gap: var(--spacing-4);
222
+ --ac-suggest-offset: var(--spacing-1);
223
+ }
224
+
225
+ .ac-container {
226
+ position: relative;
227
+ width: 100%;
228
+
229
+ .ac-input {
230
+ width: 100%;
231
+ }
232
+
233
+ .ac-suggestion {
234
+ background-color: var(--color-surface-base);
235
+ max-height: min(
236
+ var(--ac-max-height, var(--ac-max-height-default)),
237
+ calc(100dvh - var(--ac-viewport-gap))
238
+ );
239
+ position: absolute;
240
+ z-index: var(--z-dropdown);
241
+ left: 0;
242
+ top: calc(100% + var(--ac-suggest-offset));
243
+ padding: var(--ac-margin);
244
+ border-radius: 0 0 var(--ac-rad) var(--ac-rad);
245
+ box-shadow: var(--ac-box-shadow);
246
+ overflow-y: auto;
247
+
248
+ &.ac-active {
249
+ box-shadow: var(--shadow-sm);
250
+ }
251
+
252
+ .ac-empty {
253
+ padding: var(--spacing-4);
254
+ border-radius: var(--ac-rad);
255
+ }
256
+
257
+ .ac-itm {
258
+ display: grid;
259
+ grid-template-columns: var(--ac-grid, var(--ac-grid-default));
260
+ align-items: center;
261
+ column-gap: var(--spacing-2);
262
+ border-color: var(--color-border);
263
+ background-color: var(--ac-bg, var(--ac-bg-default));
264
+ color: var(--ac-color, var(--ac-color-default));
265
+ cursor: pointer;
266
+ transition: background-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
267
+ max-height: var(--ac-itm-height, var(--ac-itm-height-default));
268
+ border-radius: 0;
269
+ padding: var(--spacing-2) var(--spacing-3);
270
+
271
+ > pds-icon,
272
+ > svg-icon,
273
+ > img {
274
+ grid-column: 1;
275
+ justify-self: center;
276
+ }
277
+
278
+ > .text {
279
+ grid-column: 2;
280
+ min-width: var(--spacing-0);
281
+ }
282
+
283
+ > .category {
284
+ grid-column: 3;
285
+ justify-self: end;
286
+ font-size: var(--font-size-sm);
287
+ }
288
+
289
+ .text {
290
+ overflow: hidden;
291
+ text-overflow: ellipsis;
292
+ white-space: nowrap;
293
+ }
294
+
295
+ img {
296
+ width: var(--icon-size);
297
+ height: var(--icon-size);
298
+ }
299
+
300
+ .category {
301
+
302
+ color: var(--ac-color, var(--ac-color-muted));
303
+ }
304
+
305
+
306
+ svg-icon,
307
+ pds-icon {
308
+ --icon-fill-color: var(--ac-icon-fill, var(--ac-accent-color-default));
309
+ color: var(--ac-icon-fill, var(--ac-accent-color-default));
310
+ }
311
+
312
+ small {
313
+ color: var(--ac-color-description, var(--ac-color-default));
314
+ }
315
+
316
+ &:hover,
317
+ &.selected {
318
+ background-color: var(--accent-color, var(--ac-accent-color-default));
319
+ color: var(--ac-bg, var(--ac-bg-default));
320
+
321
+ svg-icon,
322
+ pds-icon {
323
+ --icon-fill-color: var(--color-surface-base);
324
+ color: var(--color-surface-base);
325
+ }
326
+
327
+ small,
328
+ .category {
329
+ color: inherit;
330
+ }
331
+ }
332
+ }
333
+
334
+ .text {
335
+ overflow: visible;
336
+ text-overflow: unset;
337
+ white-space: unset;
338
+ height: auto !important;
339
+ }
340
+ }
341
+
342
+ &.ac-active[data-direction="down"] {
343
+ .ac-input {
344
+ border-bottom-left-radius: 0;
345
+ border-bottom-right-radius: 0;
346
+ }
347
+
348
+ .ac-itm:first-child {
349
+ border-top-left-radius: 0;
350
+ border-top-right-radius: 0;
351
+ }
352
+
353
+ .ac-itm:last-child {
354
+ border-bottom-left-radius: var(--ac-rad);
355
+ border-bottom-right-radius: var(--ac-rad);
356
+ }
357
+ }
358
+
359
+ &.ac-active[data-direction="up"] {
360
+ .ac-input {
361
+ border-top-left-radius: 0;
362
+ border-top-right-radius: 0;
363
+ }
364
+
365
+ .ac-suggestion {
366
+ top: auto;
367
+ bottom: calc(100% + var(--ac-suggest-offset));
368
+ }
369
+
370
+ .ac-itm:last-child {
371
+ border-bottom-left-radius: 0;
372
+ border-bottom-right-radius: 0;
373
+ }
374
+
375
+ .ac-itm:first-child {
376
+ border-top-left-radius: var(--ac-rad);
377
+ border-top-right-radius: var(--ac-rad);
378
+ }
379
+ }
380
+ }
381
+
382
+ @media (max-width: var(--breakpoint-sm)) {
383
+ .ac-container {
384
+ .ac-suggestion.full-mobile {
385
+ padding: var(--spacing-4);
386
+ display: flex;
387
+ flex-direction: column-reverse;
388
+ grid-area: suggest;
389
+ position: relative;
390
+ max-height: unset;
391
+ max-width: unset;
392
+
393
+ .ac-itm {
394
+ padding: var(--spacing-3) 0;
395
+ background-color: var(--color-surface-base);
396
+ margin: var(--spacing-0) var(--spacing-1) var(--spacing-1) var(--spacing-0);
397
+ border-radius: var(--radius-sm);
398
+ }
399
+
400
+ .ac-itm:hover,
401
+ .ac-itm.selected {
402
+ background-color: var(--color-accent-300);
403
+ color: var(--color-surface-base);
404
+ }
405
+ }
406
+ }
407
+ }
408
+ }
409
+ `);
410
+
411
+ await PDS.adoptLayers(this.#root, LAYERS, [componentStyles]);
412
+ }
413
+
414
+ #syncAttributes() {
415
+ if (!this.#input) return;
416
+
417
+ this.#input.placeholder = this.placeholder;
418
+ this.#input.autocomplete = this.autocomplete;
419
+ if (this.#icon) this.#icon.setAttribute("icon", this.icon);
420
+
421
+ if (this.hasAttribute("value")) {
422
+ const v = this.getAttribute("value") || "";
423
+ if (this.#input.value !== v) this.#input.value = v;
424
+ }
425
+
426
+ if (this.disabled) this.#input.setAttribute("disabled", "");
427
+ else this.#input.removeAttribute("disabled");
428
+
429
+ if (this.required) this.#input.setAttribute("required", "");
430
+ else this.#input.removeAttribute("required");
431
+
432
+ this.#updateFormValue(this.#input.value);
433
+ }
434
+
435
+ #updateFormValue(value) {
436
+ this.#internals.setFormValue(value);
437
+ this.#syncValidity();
438
+ }
439
+
440
+ #syncValidity() {
441
+ if (!this.#input) return;
442
+ if (this.#input.checkValidity()) {
443
+ this.#internals.setValidity({}, "", this.#input);
444
+ return;
445
+ }
446
+ this.#internals.setValidity(
447
+ { customError: true },
448
+ this.#input.validationMessage || "Invalid value",
449
+ this.#input
450
+ );
451
+ }
452
+
453
+ async #handleAutoComplete(e) {
454
+
455
+ if (!this.settings) return;
456
+
457
+ const AutoComplete =
458
+ (PDS && PDS.AutoComplete) ||
459
+ (PDS && typeof PDS.loadAutoComplete === "function"
460
+ ? await PDS.loadAutoComplete()
461
+ : null);
462
+
463
+ if (AutoComplete && typeof AutoComplete.connect === "function") {
464
+ const settings = {
465
+ //debug: true,
466
+ iconHandler: (item) => {
467
+ return item.icon ? `<pds-icon icon="${item.icon}"></pds-icon>` : null;
468
+ },
469
+ ...this.settings
470
+ };
471
+ // const ev = {
472
+ // ...e,
473
+ // target: this.#input
474
+ // }
475
+ //AutoComplete.connect(ev, settings, this.#root);
476
+
477
+ this.#input._autoComplete = new AutoComplete(this.#input.parentNode, this.#input, settings);
478
+ this.#wrapAutoCompleteResultsHandler(this.#input._autoComplete);
479
+ setTimeout(() => {
480
+ this.#input._autoComplete.focusHandler(e);
481
+ this.#setupAutoCompleteSizing();
482
+ this.#updateSuggestionMaxHeight();
483
+ this.#setupSuggestionsObserver();
484
+ }, 100);
485
+
486
+ }
487
+ }
488
+
489
+ #wrapAutoCompleteResultsHandler(autoComplete) {
490
+ if (!autoComplete || autoComplete.__pdsSuggestionsWrapped) return;
491
+ autoComplete.__pdsSuggestionsWrapped = true;
492
+ const originalResultsHandler = autoComplete.resultsHandler?.bind(autoComplete);
493
+ if (!originalResultsHandler) return;
494
+
495
+ autoComplete.resultsHandler = (results, options) => {
496
+ this.dispatchEvent(
497
+ new CustomEvent("suggestions-updated", {
498
+ detail: { results },
499
+ bubbles: true,
500
+ composed: true,
501
+ })
502
+ );
503
+ return originalResultsHandler(results, options);
504
+ };
505
+ }
506
+
507
+ #setupAutoCompleteSizing() {
508
+ if (this.#autoCompleteResizeHandler) return;
509
+ this.#autoCompleteResizeHandler = () => this.#updateSuggestionMaxHeight();
510
+ this.#autoCompleteScrollHandler = () => this.#updateSuggestionMaxHeight();
511
+ this.#autoCompleteViewportHandler = () => this.#updateSuggestionMaxHeight();
512
+
513
+ window.addEventListener("resize", this.#autoCompleteResizeHandler);
514
+ window.addEventListener("scroll", this.#autoCompleteScrollHandler, true);
515
+ if (window.visualViewport) {
516
+ window.visualViewport.addEventListener("resize", this.#autoCompleteViewportHandler);
517
+ window.visualViewport.addEventListener("scroll", this.#autoCompleteViewportHandler);
518
+ }
519
+ }
520
+
521
+ #setupSuggestionsObserver() {
522
+ if (this.#suggestionsObserver) return;
523
+ const container = this.#input?.parentElement;
524
+ const root = container?.shadowRoot ?? container;
525
+ const suggestion = root?.querySelector?.(".ac-suggestion");
526
+ if (!suggestion) return;
527
+ this.#suggestionsObserver = new MutationObserver(() => {
528
+ if (!suggestion.classList.contains("ac-active")) {
529
+ this.#resetIconToDefault();
530
+ }
531
+ });
532
+ this.#suggestionsObserver.observe(suggestion, {
533
+ attributes: true,
534
+ attributeFilter: ["class"],
535
+ });
536
+ }
537
+
538
+ #teardownSuggestionsObserver() {
539
+ if (!this.#suggestionsObserver) return;
540
+ this.#suggestionsObserver.disconnect();
541
+ this.#suggestionsObserver = null;
542
+ }
543
+
544
+ #teardownAutoCompleteSizing() {
545
+ if (!this.#autoCompleteResizeHandler) return;
546
+ window.removeEventListener("resize", this.#autoCompleteResizeHandler);
547
+ window.removeEventListener("scroll", this.#autoCompleteScrollHandler, true);
548
+ if (window.visualViewport) {
549
+ window.visualViewport.removeEventListener("resize", this.#autoCompleteViewportHandler);
550
+ window.visualViewport.removeEventListener("scroll", this.#autoCompleteViewportHandler);
551
+ }
552
+ this.#autoCompleteResizeHandler = null;
553
+ this.#autoCompleteScrollHandler = null;
554
+ this.#autoCompleteViewportHandler = null;
555
+ }
556
+
557
+ #updateSuggestionMaxHeight() {
558
+ if (!this.#input) return;
559
+ const container = this.#input.parentElement;
560
+ if (!container) return;
561
+
562
+ const rect = container.getBoundingClientRect();
563
+ const viewportHeight = window.visualViewport?.height || window.innerHeight;
564
+ const gap = this.#readSpacingToken(container, "--ac-viewport-gap") || 0;
565
+ const direction = container.getAttribute("data-direction") || "down";
566
+
567
+ const available = direction === "up"
568
+ ? rect.top - gap
569
+ : viewportHeight - rect.bottom - gap;
570
+
571
+ const maxHeight = Math.max(0, Math.floor(available));
572
+ container.style.setProperty("--ac-max-height", `${maxHeight}px`);
573
+ }
574
+
575
+ #readSpacingToken(element, tokenName) {
576
+ const value = getComputedStyle(element).getPropertyValue(tokenName).trim();
577
+ if (!value) return 0;
578
+ if (!this.#lengthProbe) return 0;
579
+ this.#lengthProbe.style.height = value;
580
+ const resolved = getComputedStyle(this.#lengthProbe).height;
581
+ const parsed = Number.parseFloat(resolved);
582
+ return Number.isFinite(parsed) ? parsed : 0;
583
+ }
584
+
585
+ #handleSuggestionsUpdated(event) {
586
+
587
+ const results = event?.detail?.results;
588
+ if (!Array.isArray(results) || !this.settings?.categories) return;
589
+ if (!results.length) {
590
+ this.#icon?.setAttribute("icon", this.icon);
591
+ return;
592
+ }
593
+
594
+ const categories = this.settings.categories;
595
+ const firstResult = results[0];
596
+ const categoryConfig = categories[firstResult?.category] || {};
597
+ const useIconForInput = categoryConfig?.useIconForInput ?? this.settings?.useIconForInput;
598
+
599
+ if (typeof useIconForInput === "string") {
600
+ this.#icon?.setAttribute("icon", useIconForInput);
601
+ return;
602
+ }
603
+
604
+ if (useIconForInput === true) {
605
+ const icon =
606
+ firstResult?.icon ||
607
+ firstResult?.element?.querySelector?.("pds-icon, svg-icon")?.getAttribute?.("icon");
608
+ if (icon) {
609
+ this.#icon?.setAttribute("icon", icon);
610
+ return;
611
+ }
612
+ }
613
+
614
+ this.#resetIconToDefault();
615
+ }
616
+
617
+ #resetIconToDefault() {
618
+ this.#icon?.setAttribute("icon", this.icon);
619
+ }
620
+ }
621
+
622
+ if (!customElements.get("pds-omnibox")) {
623
+ customElements.define("pds-omnibox", PdsOmnibox);
624
+ }
package/src/js/pds.d.ts CHANGED
@@ -174,6 +174,8 @@ export class PDS extends EventTarget {
174
174
  static enums?: Record<string, any>;
175
175
  static common?: Record<string, any>;
176
176
  static query?: (question: string) => Promise<any[]>;
177
+ static AutoComplete?: any;
178
+ static loadAutoComplete?: () => Promise<any>;
177
179
 
178
180
  /**
179
181
  * Display a toast notification.
package/src/js/pds.js CHANGED
@@ -127,6 +127,21 @@ PDS.isLiveMode = () => registry.isLive;
127
127
  PDS.ask = ask;
128
128
  PDS.toast = toast;
129
129
  PDS.common = common;
130
+ PDS.AutoComplete = null;
131
+ PDS.loadAutoComplete = async () => {
132
+ if (PDS.AutoComplete) return PDS.AutoComplete;
133
+ try {
134
+ const mod = await import("pure-web/ac");
135
+ const AutoComplete = mod?.AutoComplete || mod?.default || mod;
136
+ if (AutoComplete) {
137
+ PDS.AutoComplete = AutoComplete;
138
+ return AutoComplete;
139
+ }
140
+ } catch (error) {
141
+ __defaultLog("warn", "PDS.loadAutoComplete failed", error);
142
+ }
143
+ return null;
144
+ };
130
145
 
131
146
  function __emitPDSReady(detail) {
132
147
  const hasCustomEvent = typeof CustomEvent === "function";