@pure-ds/core 0.5.33 → 0.5.35

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.
@@ -10,6 +10,7 @@
10
10
  * @attr {string} sprite - Override sprite sheet path
11
11
  * @attr {number} rotate - Rotation angle in degrees
12
12
  * @attr {boolean} no-sprite - Force fallback icon rendering
13
+ * @attr {boolean} morph - Morph the icon when the icon name changes
13
14
  *
14
15
  * @example
15
16
  * <pds-icon icon="house"></pds-icon>
@@ -51,6 +52,16 @@ export class SvgIcon extends HTMLElement {
51
52
  static fetchExternalIcon(iconName: string): Promise<boolean>;
52
53
  static ensureInlineSprite(spriteURL: any): Promise<any>;
53
54
  static notifyInstances(): void;
55
+ _currentIcon: any;
56
+ _pendingIcon: any;
57
+ _morphing: boolean;
58
+ _morphTimer: any;
59
+ _templateReady: boolean;
60
+ _stackEl: Element;
61
+ _svgOldEl: Element;
62
+ _svgNewEl: Element;
63
+ _iconOldGroupEl: Element;
64
+ _iconNewGroupEl: Element;
54
65
  connectedCallback(): void;
55
66
  disconnectedCallback(): void;
56
67
  attributeChangedCallback(name: any, oldValue: any, newValue: any): void;
@@ -1 +1 @@
1
- {"version":3,"file":"pds-icon.d.ts","sourceRoot":"","sources":["../../../../../../public/assets/pds/components/pds-icon.js"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;IACE,oCAAyE;IAGzE;;;;;;;;;;MAkBE;IAEF,qCAAkC;IAClC,oCAAiC;IAGjC,wCAAqC;IAErC,2CAAwC;IAExC,2BAA6B;IAE7B,4CAA8B;IAO9B,oDA2CC;IAmMD;;;;OAIG;IACH,mCAmBC;IAED;;;;OAIG;IACH,mCAHW,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CA6E5B;IAED,wDA8EC;IACD,+BAMC;IA7XD,0BAGC;IAED,6BAEC;IAED,wEAIC;IAED,eA6JC;IAMD;;;;;OAKG;IACH,0BAFa,OAAO,CAInB;;CAoMF"}
1
+ {"version":3,"file":"pds-icon.d.ts","sourceRoot":"","sources":["../../../../../../public/assets/pds/components/pds-icon.js"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;IACE,oCAAkF;IAGlF;;;;;;;;;;MAkBE;IAEF,qCAAkC;IAClC,oCAAiC;IAGjC,wCAAqC;IAErC,2CAAwC;IAExC,2BAA6B;IAE7B,4CAA8B;IAO9B,oDA2CC;IAqdD;;;;OAIG;IACH,mCAmBC;IAED;;;;OAIG;IACH,mCAHW,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CA6E5B;IAED,wDA8EC;IACD,+BAMC;IAjpBC,kBAAwB;IACxB,kBAAwB;IACxB,mBAAsB;IACtB,iBAAuB;IACvB,wBAA2B;IAC3B,kBAAoB;IACpB,mBAAqB;IACrB,mBAAqB;IACrB,yBAA2B;IAC3B,yBAA2B;IAG7B,0BAGC;IAED,6BAGC;IAED,wEAmBC;IAED,eAiQC;IA0JD;;;;;OAKG;IACH,0BAFa,OAAO,CAInB;;CAoMF"}
@@ -2,6 +2,7 @@ export class PdsOmnibox extends HTMLElement {
2
2
  static formAssociated: boolean;
3
3
  static get observedAttributes(): string[];
4
4
  connectedCallback(): void;
5
+ disconnectedCallback(): void;
5
6
  attributeChangedCallback(name: any, oldValue: any, newValue: any): void;
6
7
  set settings(value: any);
7
8
  get settings(): any;
@@ -17,6 +18,8 @@ export class PdsOmnibox extends HTMLElement {
17
18
  get required(): boolean;
18
19
  set autocomplete(value: string);
19
20
  get autocomplete(): string;
21
+ set icon(value: string);
22
+ get icon(): string;
20
23
  formAssociatedCallback(): void;
21
24
  formDisabledCallback(disabled: any): void;
22
25
  formResetCallback(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"pds-omnibox.d.ts","sourceRoot":"","sources":["../../../../../../public/assets/pds/components/pds-omnibox.js"],"names":[],"mappings":"AAkBA;IACC,+BAA6B;IAE7B,0CAEC;IAgBD,0BAIC;IAED,wEAGC;IAMD,yBAGC;IAPD,oBAEC;IAWD,wBAGC;IAPD,mBAEC;IAWD,+BAGC;IAPD,0BAEC;IAWD,sBAIC;IARD,iBAEC;IAYD,6BAGC;IAPD,wBAEC;IAWD,6BAGC;IAPD,wBAEC;IAWD,gCAGC;IAPD,2BAEC;IAOD,+BAA2B;IAE3B,0CAGC;IAED,0BAEC;IAED,2CAEC;IAED,qBAEC;IAED,sBAEC;;CAkSD"}
1
+ {"version":3,"file":"pds-omnibox.d.ts","sourceRoot":"","sources":["../../../../../../public/assets/pds/components/pds-omnibox.js"],"names":[],"mappings":"AAmBA;IACC,+BAA6B;IAE7B,0CAEC;IAuBD,0BAUC;IAED,6BAOC;IAED,wEAGC;IAMD,yBAEC;IAND,oBAEC;IAUD,wBAGC;IAPD,mBAEC;IAWD,+BAGC;IAPD,0BAEC;IAWD,sBAIC;IARD,iBAEC;IAYD,6BAGC;IAPD,wBAEC;IAWD,6BAGC;IAPD,wBAEC;IAWD,gCAGC;IAPD,2BAEC;IAWD,wBAGC;IAPD,mBAEC;IAOD,+BAA2B;IAE3B,0CAGC;IAED,0BAEC;IAED,2CAEC;IAED,qBAEC;IAED,sBAEC;;CAqcD"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pure-ds/core",
3
3
  "shortname": "pds",
4
- "version": "0.5.33",
4
+ "version": "0.5.35",
5
5
  "description": "Pure Design System - Why develop a Design System when you can generate one?",
6
6
  "repository": {
7
7
  "type": "git",
@@ -104,12 +104,11 @@ async function copyTemplateDirectory(sourceDir, targetDir, options) {
104
104
  }
105
105
  }
106
106
 
107
- async function copyBootstrapTemplate({ version, generatedAt, isModule }) {
107
+ async function copyBootstrapTemplate({ version, generatedAt }) {
108
108
  await copyTemplateDirectory(templateRoot, projectRoot, { version, generatedAt });
109
109
 
110
- const esbuildTemplate = isModule ? 'esbuild-dev.mjs' : 'esbuild-dev.cjs';
111
- const esbuildSource = path.join(templateRoot, esbuildTemplate);
112
- const esbuildTarget = path.join(projectRoot, 'esbuild-dev.js');
110
+ const esbuildSource = path.join(templateRoot, 'esbuild-dev.cjs');
111
+ const esbuildTarget = path.join(projectRoot, 'esbuild-dev.cjs');
113
112
  await copyFileIfMissing(esbuildSource, esbuildTarget);
114
113
  }
115
114
 
@@ -118,7 +117,13 @@ async function ensurePackageScripts(pkg, pkgPath) {
118
117
  let changed = false;
119
118
 
120
119
  if (!pkg.scripts.dev) {
121
- pkg.scripts.dev = 'node esbuild-dev.js';
120
+ if (existsSync(path.join(projectRoot, 'esbuild-dev.cjs'))) {
121
+ pkg.scripts.dev = 'node esbuild-dev.cjs';
122
+ } else if (existsSync(path.join(projectRoot, 'esbuild-dev.js'))) {
123
+ pkg.scripts.dev = 'node esbuild-dev.js';
124
+ } else {
125
+ pkg.scripts.dev = 'node esbuild-dev.cjs';
126
+ }
122
127
  changed = true;
123
128
  }
124
129
 
@@ -262,7 +267,6 @@ async function main() {
262
267
  log('\n⚡ PDS Bootstrap\n');
263
268
 
264
269
  const { pkgPath, pkg } = await readPackageJson();
265
- const isModule = pkg.type === 'module';
266
270
 
267
271
  await ensurePackageScripts(pkg, pkgPath);
268
272
  await ensureEsbuildDependency(pkg, pkgPath);
@@ -270,7 +274,7 @@ async function main() {
270
274
  const version = await getPdsCoreVersion();
271
275
  const generatedAt = new Date().toLocaleString();
272
276
 
273
- await copyBootstrapTemplate({ version, generatedAt, isModule });
277
+ await copyBootstrapTemplate({ version, generatedAt });
274
278
 
275
279
  await ensurePdsAssets();
276
280
 
@@ -10,6 +10,7 @@
10
10
  * @attr {string} sprite - Override sprite sheet path
11
11
  * @attr {number} rotate - Rotation angle in degrees
12
12
  * @attr {boolean} no-sprite - Force fallback icon rendering
13
+ * @attr {boolean} morph - Morph the icon when the icon name changes
13
14
  *
14
15
  * @example
15
16
  * <pds-icon icon="house"></pds-icon>
@@ -19,7 +20,7 @@
19
20
  */
20
21
 
21
22
  export class SvgIcon extends HTMLElement {
22
- static observedAttributes = ['icon', 'size', 'color', 'label', 'rotate'];
23
+ static observedAttributes = ['icon', 'size', 'color', 'label', 'rotate', 'morph'];
23
24
 
24
25
  // Inline fallback icons for critical UI elements (when sprite fails to load)
25
26
  static #fallbackIcons = {
@@ -107,6 +108,16 @@ export class SvgIcon extends HTMLElement {
107
108
  constructor() {
108
109
  super();
109
110
  this.attachShadow({ mode: 'open' });
111
+ this._currentIcon = null;
112
+ this._pendingIcon = null;
113
+ this._morphing = false;
114
+ this._morphTimer = null;
115
+ this._templateReady = false;
116
+ this._stackEl = null;
117
+ this._svgOldEl = null;
118
+ this._svgNewEl = null;
119
+ this._iconOldGroupEl = null;
120
+ this._iconNewGroupEl = null;
110
121
  }
111
122
 
112
123
  connectedCallback() {
@@ -116,16 +127,33 @@ export class SvgIcon extends HTMLElement {
116
127
 
117
128
  disconnectedCallback() {
118
129
  SvgIcon.instances.delete(this);
130
+ this.#clearMorphTimers();
119
131
  }
120
132
 
121
133
  attributeChangedCallback(name, oldValue, newValue) {
122
134
  if (oldValue !== newValue) {
135
+ if (name === 'icon') {
136
+ if (this.hasAttribute('morph')) {
137
+ this.#startMorph(newValue, oldValue);
138
+ return;
139
+ }
140
+ this._currentIcon = newValue;
141
+ this._pendingIcon = null;
142
+ this._morphing = false;
143
+ this.#clearMorphTimers();
144
+ this.render();
145
+ return;
146
+ }
147
+ if (name === 'morph') {
148
+ this.#clearMorphTimers();
149
+ }
123
150
  this.render();
124
151
  }
125
152
  }
126
153
 
127
154
  render() {
128
- const icon = this.getAttribute('icon') || 'missing';
155
+ const attrIcon = this.getAttribute('icon') || 'missing';
156
+ const icon = this._morphing ? (this._currentIcon || attrIcon) : attrIcon;
129
157
  const sizeAttr = this.getAttribute('size') || '24';
130
158
  const color = this.getAttribute('color') || 'currentColor';
131
159
  const label = this.getAttribute('label');
@@ -235,52 +263,299 @@ export class SvgIcon extends HTMLElement {
235
263
  const transform = rotate !== '0' ? `rotate(${rotate} 128 128)` : '';
236
264
  const defaultViewBox = '0 0 256 256';
237
265
 
238
- // Determine viewBox: external icons may have different viewBox (often 24x24)
239
- let viewBox = defaultViewBox;
240
- let preserveAspectRatioAttr = '';
241
-
242
- if (useExternalIcon && externalIconData) {
243
- viewBox = externalIconData.viewBox || '0 0 24 24';
244
- if (externalIconData.preserveAspectRatio) {
245
- preserveAspectRatioAttr = ` preserveAspectRatio="${externalIconData.preserveAspectRatio}"`;
266
+ const resolveIconData = (iconName) => {
267
+ let effectiveHrefLocal = spriteHref ? `${spriteHref}#${iconName}` : `#${iconName}`;
268
+ let inlineSymbolContentLocal = null;
269
+ let inlineSymbolViewBoxLocal = null;
270
+ let inlineSymbolPreserveAspectRatioLocal = null;
271
+ let useExternalIconLocal = false;
272
+ let externalIconDataLocal = null;
273
+ let useFallbackLocal = this.hasAttribute('no-sprite') || !this.spriteAvailable();
274
+ let spriteIconNotFoundLocal = false;
275
+ let spriteStillLoadingLocal = false;
276
+
277
+ if (!useFallbackLocal && typeof window !== 'undefined' && spriteHref) {
278
+ try {
279
+ const spriteURL = new URL(spriteHref, window.location.href);
280
+ const spriteKey = spriteURL.href;
281
+ const inlineSpriteData = SvgIcon.inlineSprites.get(spriteKey);
282
+
283
+ if (inlineSpriteData && inlineSpriteData.loaded) {
284
+ const symbolData = inlineSpriteData.symbols.get(iconName);
285
+ if (symbolData) {
286
+ inlineSymbolContentLocal = symbolData.content;
287
+ inlineSymbolViewBoxLocal = symbolData.viewBox;
288
+ inlineSymbolPreserveAspectRatioLocal = symbolData.preserveAspectRatio;
289
+ } else {
290
+ spriteIconNotFoundLocal = true;
291
+ }
292
+ } else if (inlineSpriteData && inlineSpriteData.error) {
293
+ spriteIconNotFoundLocal = true;
294
+ } else {
295
+ SvgIcon.ensureInlineSprite(spriteKey);
296
+ spriteStillLoadingLocal = true;
297
+ useFallbackLocal = true;
298
+ }
299
+ } catch (e) {
300
+ // Ignore URL errors and fall back to default behaviour
301
+ }
302
+ }
303
+
304
+ const hasFallbackLocal = SvgIcon.#fallbackIcons.hasOwnProperty(iconName);
305
+ if (spriteIconNotFoundLocal && !hasFallbackLocal && !spriteStillLoadingLocal) {
306
+ const cached = SvgIcon.externalIconCache.get(iconName);
307
+ if (cached) {
308
+ if (cached.loaded && cached.content) {
309
+ useExternalIconLocal = true;
310
+ externalIconDataLocal = cached;
311
+ } else if (cached.error) {
312
+ useFallbackLocal = true;
313
+ }
314
+ } else {
315
+ SvgIcon.fetchExternalIcon(iconName);
316
+ useFallbackLocal = true;
317
+ }
246
318
  }
247
- } else if (inlineSymbolViewBox) {
248
- viewBox = inlineSymbolViewBox;
249
- if (inlineSymbolPreserveAspectRatio) {
250
- preserveAspectRatioAttr = ` preserveAspectRatio="${inlineSymbolPreserveAspectRatio}"`;
319
+
320
+ if (spriteIconNotFoundLocal && !useExternalIconLocal) {
321
+ useFallbackLocal = true;
251
322
  }
252
- }
253
323
 
254
- const hasInlineSymbol = inlineSymbolContent !== null;
324
+ let viewBoxLocal = defaultViewBox;
325
+ let preserveAspectRatioLocal = '';
326
+
327
+ if (useExternalIconLocal && externalIconDataLocal) {
328
+ viewBoxLocal = externalIconDataLocal.viewBox || '0 0 24 24';
329
+ if (externalIconDataLocal.preserveAspectRatio) {
330
+ preserveAspectRatioLocal = externalIconDataLocal.preserveAspectRatio;
331
+ }
332
+ } else if (inlineSymbolViewBoxLocal) {
333
+ viewBoxLocal = inlineSymbolViewBoxLocal;
334
+ if (inlineSymbolPreserveAspectRatioLocal) {
335
+ preserveAspectRatioLocal = inlineSymbolPreserveAspectRatioLocal;
336
+ }
337
+ }
338
+
339
+ const hasInlineSymbolLocal = inlineSymbolContentLocal !== null;
340
+
341
+ let symbolMarkupLocal;
342
+ if (useExternalIconLocal && externalIconDataLocal?.content) {
343
+ symbolMarkupLocal = externalIconDataLocal.content;
344
+ } else if (useFallbackLocal) {
345
+ symbolMarkupLocal = this.#getFallbackIcon(iconName);
346
+ } else if (hasInlineSymbolLocal) {
347
+ symbolMarkupLocal = inlineSymbolContentLocal;
348
+ } else {
349
+ symbolMarkupLocal = `<use href="${effectiveHrefLocal}"></use>`;
350
+ }
351
+
352
+ return {
353
+ symbolMarkup: symbolMarkupLocal,
354
+ viewBox: viewBoxLocal,
355
+ preserveAspectRatio: preserveAspectRatioLocal,
356
+ };
357
+ };
358
+
359
+ const currentData = resolveIconData(icon);
360
+ const nextIcon = this._morphing ? (this._pendingIcon || attrIcon) : attrIcon;
361
+ const nextData = resolveIconData(nextIcon);
255
362
 
256
- // Determine symbol markup based on priority: external > inline sprite > use href > fallback
257
- let symbolMarkup;
258
- if (useExternalIcon && externalIconData?.content) {
259
- symbolMarkup = externalIconData.content;
260
- } else if (useFallback) {
261
- symbolMarkup = this.#getFallbackIcon(icon);
262
- } else if (hasInlineSymbol) {
263
- symbolMarkup = inlineSymbolContent;
363
+ const morphClass = this._morphing ? 'morphing' : '';
364
+
365
+ this.#ensureTemplate();
366
+
367
+ if (!this._stackEl || !this._svgOldEl || !this._svgNewEl || !this._iconOldGroupEl || !this._iconNewGroupEl) {
368
+ return;
369
+ }
370
+
371
+ this._stackEl.setAttribute('class', `icon-stack${morphClass ? ` ${morphClass}` : ''}`);
372
+ this._stackEl.style.width = `${size}px`;
373
+ this._stackEl.style.height = `${size}px`;
374
+
375
+ this._svgOldEl.setAttribute('width', size);
376
+ this._svgOldEl.setAttribute('height', size);
377
+ this._svgOldEl.setAttribute('fill', color);
378
+ this._svgOldEl.setAttribute('viewBox', currentData.viewBox);
379
+ if (currentData.preserveAspectRatio) {
380
+ this._svgOldEl.setAttribute('preserveAspectRatio', currentData.preserveAspectRatio);
264
381
  } else {
265
- symbolMarkup = `<use href="${effectiveHref}"></use>`;
382
+ this._svgOldEl.removeAttribute('preserveAspectRatio');
266
383
  }
267
-
384
+
385
+ this._svgNewEl.setAttribute('width', size);
386
+ this._svgNewEl.setAttribute('height', size);
387
+ this._svgNewEl.setAttribute('fill', color);
388
+ this._svgNewEl.setAttribute('viewBox', nextData.viewBox);
389
+ if (nextData.preserveAspectRatio) {
390
+ this._svgNewEl.setAttribute('preserveAspectRatio', nextData.preserveAspectRatio);
391
+ } else {
392
+ this._svgNewEl.removeAttribute('preserveAspectRatio');
393
+ }
394
+
395
+ if (label) {
396
+ this._svgNewEl.setAttribute('role', 'img');
397
+ this._svgNewEl.setAttribute('aria-label', label);
398
+ this._svgNewEl.setAttribute('aria-hidden', 'false');
399
+ } else {
400
+ this._svgNewEl.setAttribute('aria-hidden', 'true');
401
+ this._svgNewEl.removeAttribute('role');
402
+ this._svgNewEl.removeAttribute('aria-label');
403
+ }
404
+
405
+ this._svgOldEl.setAttribute('aria-hidden', 'true');
406
+
407
+ this._iconOldGroupEl.setAttribute('transform', transform);
408
+ this._iconOldGroupEl.innerHTML = currentData.symbolMarkup;
409
+ this._iconNewGroupEl.setAttribute('transform', transform);
410
+ this._iconNewGroupEl.innerHTML = nextData.symbolMarkup;
411
+ }
412
+
413
+ #ensureTemplate() {
414
+ if (this._templateReady) {
415
+ return;
416
+ }
417
+
268
418
  this.shadowRoot.innerHTML = `
269
- <svg
270
- width="${size}"
271
- height="${size}"
272
- fill="${color}"
273
- aria-hidden="${!label}"
274
- ${label ? `role="img" aria-label="${label}"` : ''}
275
- style="display: inline-block; vertical-align: middle; flex-shrink: 0;"
276
- viewBox="${viewBox}"
277
- ${preserveAspectRatioAttr}
278
- >
279
- <g transform="${transform}">
280
- ${symbolMarkup}
281
- </g>
282
- </svg>
419
+ <style>
420
+ :host {
421
+ --pds-icon-morph-duration: 300ms;
422
+ --pds-icon-morph-half-duration: calc(var(--pds-icon-morph-duration) / 2);
423
+ --pds-icon-morph-rotate: 12deg;
424
+ --pds-icon-morph-blur: 10px;
425
+ }
426
+
427
+ .icon-stack {
428
+ display: inline-block;
429
+ position: relative;
430
+ line-height: 0;
431
+ }
432
+
433
+ .icon-stack svg.pds-icon {
434
+ position: absolute;
435
+ inset: 0;
436
+ display: block;
437
+ width: 100%;
438
+ height: 100%;
439
+ will-change: transform, opacity, filter;
440
+ transform-origin: 50% 50%;
441
+ }
442
+
443
+ .icon-stack .layer-old {
444
+ opacity: 0;
445
+ }
446
+
447
+ .icon-stack .layer-new {
448
+ opacity: 1;
449
+ }
450
+
451
+ .icon-stack.morphing .layer-old {
452
+ opacity: 1;
453
+ animation: pds-icon-morph-out var(--pds-icon-morph-duration) ease-in-out;
454
+ }
455
+
456
+ .icon-stack.morphing .layer-new {
457
+ opacity: 0;
458
+ animation: pds-icon-morph-in var(--pds-icon-morph-duration) ease-in-out;
459
+ }
460
+
461
+ @keyframes pds-icon-morph-out {
462
+ 0% { opacity: 1; filter: blur(0); transform: rotate(0deg); }
463
+ 100% { opacity: 0; filter: blur(var(--pds-icon-morph-blur)); transform: rotate(var(--pds-icon-morph-rotate)); }
464
+ }
465
+
466
+ @keyframes pds-icon-morph-in {
467
+ 0% { opacity: 0; filter: blur(var(--pds-icon-morph-blur)); transform: rotate(calc(var(--pds-icon-morph-rotate) * -1)); }
468
+ 100% { opacity: 1; filter: blur(0); transform: rotate(0deg); }
469
+ }
470
+
471
+ @media (prefers-reduced-motion: reduce) {
472
+ .icon-stack.morphing .layer-old,
473
+ .icon-stack.morphing .layer-new {
474
+ animation: none;
475
+ }
476
+ }
477
+ </style>
478
+ <span class="icon-stack">
479
+ <svg class="pds-icon layer-old" aria-hidden="true">
480
+ <g id="icon-group-old"></g>
481
+ </svg>
482
+ <svg class="pds-icon layer-new" aria-hidden="true">
483
+ <g id="icon-group-new"></g>
484
+ </svg>
485
+ </span>
283
486
  `;
487
+
488
+ this._stackEl = this.shadowRoot.querySelector('.icon-stack');
489
+ this._svgOldEl = this.shadowRoot.querySelector('svg.layer-old');
490
+ this._svgNewEl = this.shadowRoot.querySelector('svg.layer-new');
491
+ this._iconOldGroupEl = this.shadowRoot.querySelector('#icon-group-old');
492
+ this._iconNewGroupEl = this.shadowRoot.querySelector('#icon-group-new');
493
+ this._templateReady = true;
494
+ }
495
+
496
+ #clearMorphTimers() {
497
+ if (this._morphTimer) {
498
+ clearTimeout(this._morphTimer);
499
+ this._morphTimer = null;
500
+ }
501
+ }
502
+
503
+ #startMorph(newIcon, oldIcon) {
504
+ if (!oldIcon) {
505
+ this._currentIcon = newIcon;
506
+ this._pendingIcon = null;
507
+ this._morphing = false;
508
+ this.render();
509
+ return;
510
+ }
511
+
512
+ this.#clearMorphTimers();
513
+
514
+ this._currentIcon = oldIcon;
515
+ this._pendingIcon = newIcon;
516
+ this._morphing = false;
517
+ this.render();
518
+
519
+ requestAnimationFrame(() => {
520
+ if (!this.isConnected) {
521
+ return;
522
+ }
523
+
524
+ this._morphing = true;
525
+ this.render();
526
+
527
+ const totalDuration = this.#getMorphDurationMs();
528
+ const halfDuration = totalDuration / 2;
529
+
530
+ this._morphTimer = setTimeout(() => {
531
+ this._currentIcon = this._pendingIcon || newIcon;
532
+ this._pendingIcon = null;
533
+ this.render();
534
+ this._morphTimer = setTimeout(() => {
535
+ this._morphing = false;
536
+ this.render();
537
+ }, halfDuration);
538
+ }, halfDuration);
539
+ });
540
+ }
541
+
542
+ #getMorphDurationMs() {
543
+ try {
544
+ const value = getComputedStyle(this).getPropertyValue('--pds-icon-morph-duration').trim();
545
+ if (value) {
546
+ if (value.endsWith('ms')) {
547
+ const ms = Number.parseFloat(value);
548
+ return Number.isFinite(ms) ? ms : 200;
549
+ }
550
+ if (value.endsWith('s')) {
551
+ const seconds = Number.parseFloat(value);
552
+ return Number.isFinite(seconds) ? seconds * 1000 : 200;
553
+ }
554
+ }
555
+ } catch (error) {
556
+ // ignore and fall back
557
+ }
558
+ return 200;
284
559
  }
285
560
 
286
561
  #getFallbackIcon(name) {
@@ -15,19 +15,27 @@
15
15
  */
16
16
  const LAYERS = ["tokens", "primitives", "components", "utilities"];
17
17
  const DEFAULT_PLACEHOLDER = "Search...";
18
+ const DEFAULT_ICON = "magnifying-glass";
18
19
 
19
20
  export class PdsOmnibox extends HTMLElement {
20
21
  static formAssociated = true;
21
22
 
22
23
  static get observedAttributes() {
23
- return ["name", "placeholder", "value", "disabled", "required", "autocomplete"];
24
+ return ["name", "placeholder", "value", "disabled", "required", "autocomplete", "icon"];
24
25
  }
25
26
 
26
27
  #root;
27
28
  #internals;
28
29
  #input;
30
+ #icon;
29
31
  #settings;
30
32
  #defaultValue = "";
33
+ #autoCompleteResizeHandler;
34
+ #autoCompleteScrollHandler;
35
+ #autoCompleteViewportHandler;
36
+ #lengthProbe;
37
+ #suggestionsUpdatedHandler;
38
+ #suggestionsObserver;
31
39
 
32
40
  constructor() {
33
41
  super();
@@ -41,6 +49,21 @@ export class PdsOmnibox extends HTMLElement {
41
49
  this.#defaultValue = this.getAttribute("value") || "";
42
50
  this.#syncAttributes();
43
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
+ }
44
67
  }
45
68
 
46
69
  attributeChangedCallback(name, oldValue, newValue) {
@@ -54,7 +77,6 @@ export class PdsOmnibox extends HTMLElement {
54
77
 
55
78
  set settings(value) {
56
79
  this.#settings = value;
57
- console.log('settings set', this.#settings);
58
80
  }
59
81
 
60
82
  get name() {
@@ -112,6 +134,15 @@ export class PdsOmnibox extends HTMLElement {
112
134
  else this.setAttribute("autocomplete", value);
113
135
  }
114
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
+
115
146
  formAssociatedCallback() {}
116
147
 
117
148
  formDisabledCallback(disabled) {
@@ -138,14 +169,20 @@ export class PdsOmnibox extends HTMLElement {
138
169
  #renderStructure() {
139
170
  this.#root.innerHTML = `
140
171
  <div class="ac-container input-icon">
141
- <pds-icon icon="magnifying-glass"></pds-icon>
172
+ <pds-icon morph icon="${DEFAULT_ICON}"></pds-icon>
142
173
  <input class="ac-input" type="search" placeholder="${DEFAULT_PLACEHOLDER}" autocomplete="off" />
143
174
  </div>
144
175
  `;
145
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
+
146
181
  this.#input = this.#root.querySelector("input");
182
+ this.#icon = this.#root.querySelector("pds-icon");
147
183
  this.#input.addEventListener("input", () => {
148
184
  this.#updateFormValue(this.#input.value);
185
+ this.#updateSuggestionMaxHeight();
149
186
  this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
150
187
  });
151
188
  this.#input.addEventListener("change", () => {
@@ -154,6 +191,15 @@ export class PdsOmnibox extends HTMLElement {
154
191
  this.#input.addEventListener("focus", (e) => {
155
192
  this.#handleAutoComplete(e);
156
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
+ });
157
203
  }
158
204
 
159
205
  async #adoptStyles() {
@@ -171,7 +217,9 @@ export class PdsOmnibox extends HTMLElement {
171
217
  --ac-margin: var(--spacing-0);
172
218
  --icon-size: var(--spacing-6);
173
219
  --ac-itm-height-default: 5rem;
174
- --ac-max-height-default: 300px;
220
+ --ac-max-height-default: 300px;
221
+ --ac-viewport-gap: var(--spacing-4);
222
+ --ac-suggest-offset: var(--spacing-1);
175
223
  }
176
224
 
177
225
  .ac-container {
@@ -184,10 +232,14 @@ export class PdsOmnibox extends HTMLElement {
184
232
 
185
233
  .ac-suggestion {
186
234
  background-color: var(--color-surface-base);
187
- max-height: var(--ac-max-height, var(--ac-max-height-default));
235
+ max-height: min(
236
+ var(--ac-max-height, var(--ac-max-height-default)),
237
+ calc(100dvh - var(--ac-viewport-gap))
238
+ );
188
239
  position: absolute;
189
240
  z-index: var(--z-dropdown);
190
241
  left: 0;
242
+ top: calc(100% + var(--ac-suggest-offset));
191
243
  padding: var(--ac-margin);
192
244
  border-radius: 0 0 var(--ac-rad) var(--ac-rad);
193
245
  box-shadow: var(--ac-box-shadow);
@@ -310,6 +362,11 @@ export class PdsOmnibox extends HTMLElement {
310
362
  border-top-right-radius: 0;
311
363
  }
312
364
 
365
+ .ac-suggestion {
366
+ top: auto;
367
+ bottom: calc(100% + var(--ac-suggest-offset));
368
+ }
369
+
313
370
  .ac-itm:last-child {
314
371
  border-bottom-left-radius: 0;
315
372
  border-bottom-right-radius: 0;
@@ -359,6 +416,7 @@ export class PdsOmnibox extends HTMLElement {
359
416
 
360
417
  this.#input.placeholder = this.placeholder;
361
418
  this.#input.autocomplete = this.autocomplete;
419
+ if (this.#icon) this.#icon.setAttribute("icon", this.icon);
362
420
 
363
421
  if (this.hasAttribute("value")) {
364
422
  const v = this.getAttribute("value") || "";
@@ -416,13 +474,149 @@ export class PdsOmnibox extends HTMLElement {
416
474
  // }
417
475
  //AutoComplete.connect(ev, settings, this.#root);
418
476
 
419
- this.#input._autoComplete = new AutoComplete(this.#input.parentNode, this.#input, settings);
420
- setTimeout(() => {
421
- this.#input._autoComplete.focusHandler(e);
422
- }, 100);
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);
423
485
 
424
486
  }
425
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
+ }
426
620
  }
427
621
 
428
622
  if (!customElements.get("pds-omnibox")) {