@aquera/nile-elements 2.0.7 → 2.0.9

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.
Files changed (65) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +627 -265
  3. package/dist/nile-chip/nile-chip.css.cjs.js +1 -1
  4. package/dist/nile-chip/nile-chip.css.cjs.js.map +1 -1
  5. package/dist/nile-chip/nile-chip.css.esm.js +4 -8
  6. package/dist/nile-input/nile-input.css.cjs.js +1 -1
  7. package/dist/nile-input/nile-input.css.cjs.js.map +1 -1
  8. package/dist/nile-input/nile-input.css.esm.js +4 -5
  9. package/dist/nile-select/index.cjs.js +1 -1
  10. package/dist/nile-select/index.esm.js +1 -1
  11. package/dist/nile-select/nile-select.cjs.js +2 -1
  12. package/dist/nile-select/nile-select.cjs.js.map +1 -1
  13. package/dist/nile-select/nile-select.css.cjs.js +1 -1
  14. package/dist/nile-select/nile-select.css.cjs.js.map +1 -1
  15. package/dist/nile-select/nile-select.css.esm.js +126 -6
  16. package/dist/nile-select/nile-select.esm.js +77 -11
  17. package/dist/nile-select/virtual-scroll-helper.cjs.js +1 -1
  18. package/dist/nile-select/virtual-scroll-helper.cjs.js.map +1 -1
  19. package/dist/nile-select/virtual-scroll-helper.esm.js +3 -1
  20. package/dist/nile-virtual-select/index.cjs.js +1 -1
  21. package/dist/nile-virtual-select/index.esm.js +1 -1
  22. package/dist/nile-virtual-select/nile-virtual-select.cjs.js +4 -3
  23. package/dist/nile-virtual-select/nile-virtual-select.cjs.js.map +1 -1
  24. package/dist/nile-virtual-select/nile-virtual-select.css.cjs.js +1 -1
  25. package/dist/nile-virtual-select/nile-virtual-select.css.cjs.js.map +1 -1
  26. package/dist/nile-virtual-select/nile-virtual-select.css.esm.js +126 -6
  27. package/dist/nile-virtual-select/nile-virtual-select.esm.js +71 -12
  28. package/dist/src/nile-chip/nile-chip.css.js +4 -8
  29. package/dist/src/nile-chip/nile-chip.css.js.map +1 -1
  30. package/dist/src/nile-input/nile-input.css.js +4 -5
  31. package/dist/src/nile-input/nile-input.css.js.map +1 -1
  32. package/dist/src/nile-input/nile-input.test.js +5 -0
  33. package/dist/src/nile-input/nile-input.test.js.map +1 -1
  34. package/dist/src/nile-select/nile-select.css.js +126 -6
  35. package/dist/src/nile-select/nile-select.css.js.map +1 -1
  36. package/dist/src/nile-select/nile-select.d.ts +43 -5
  37. package/dist/src/nile-select/nile-select.js +445 -106
  38. package/dist/src/nile-select/nile-select.js.map +1 -1
  39. package/dist/src/nile-select/nile-select.test.d.ts +1 -0
  40. package/dist/src/nile-select/nile-select.test.js +342 -1
  41. package/dist/src/nile-select/nile-select.test.js.map +1 -1
  42. package/dist/src/nile-select/virtual-scroll-helper.js +3 -1
  43. package/dist/src/nile-select/virtual-scroll-helper.js.map +1 -1
  44. package/dist/src/nile-virtual-select/nile-virtual-select.css.js +126 -6
  45. package/dist/src/nile-virtual-select/nile-virtual-select.css.js.map +1 -1
  46. package/dist/src/nile-virtual-select/nile-virtual-select.d.ts +45 -4
  47. package/dist/src/nile-virtual-select/nile-virtual-select.js +385 -59
  48. package/dist/src/nile-virtual-select/nile-virtual-select.js.map +1 -1
  49. package/dist/src/nile-virtual-select/nile-virtual-select.test.js +253 -1
  50. package/dist/src/nile-virtual-select/nile-virtual-select.test.js.map +1 -1
  51. package/dist/src/version.js +1 -1
  52. package/dist/src/version.js.map +1 -1
  53. package/dist/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +1 -1
  55. package/src/nile-chip/nile-chip.css.ts +4 -8
  56. package/src/nile-input/nile-input.css.ts +4 -5
  57. package/src/nile-input/nile-input.test.ts +6 -0
  58. package/src/nile-select/nile-select.css.ts +126 -6
  59. package/src/nile-select/nile-select.test.ts +478 -1
  60. package/src/nile-select/nile-select.ts +499 -91
  61. package/src/nile-select/virtual-scroll-helper.ts +3 -1
  62. package/src/nile-virtual-select/nile-virtual-select.css.ts +126 -6
  63. package/src/nile-virtual-select/nile-virtual-select.test.ts +352 -1
  64. package/src/nile-virtual-select/nile-virtual-select.ts +460 -55
  65. package/vscode-html-custom-data.json +40 -6
@@ -15,10 +15,12 @@ import { styles } from './nile-select.css';
15
15
  import '../nile-icon';
16
16
  import '../nile-popup/nile-popup';
17
17
  import '../nile-tag/nile-tag';
18
+ import '../nile-lite-tooltip/nile-lite-tooltip';
18
19
  import { animateTo, stopAnimations } from '../internal/animate';
19
20
  import { classMap } from 'lit/directives/class-map.js';
20
21
  import { query, state } from 'lit/decorators.js';
21
22
  import { defaultValue } from '../internal/default-value';
23
+ import { KeyCode } from '../internal/enum';
22
24
  import { FormControlController } from '../internal/form';
23
25
  import {
24
26
  getAnimation,
@@ -34,7 +36,6 @@ import type { NileFormControl } from '../internal/nile-element';
34
36
  import type NileOption from '../nile-option/nile-option';
35
37
  import type NilePopup from '../nile-popup/nile-popup';
36
38
  import type { RenderItemConfig } from '../nile-virtual-select/types.js';
37
- import { unsafeHTML } from 'lit/directives/unsafe-html.js';
38
39
  import { ifDefined } from 'lit/directives/if-defined.js';
39
40
  import '../nile-virtual-select/nile-virtual-select';
40
41
  import { VirtualScrollHelper } from './virtual-scroll-helper';
@@ -127,8 +128,22 @@ export class NileSelect extends NileElement implements NileFormControl{
127
128
  private scrollTimeout: number | undefined;
128
129
  private scrolling = false;
129
130
  private options: NileOption[] = [];
130
- private resizeController?: ResizeController;
131
+ private observedTagsContainer?: Element;
132
+
133
+ private resizeController = new ResizeController(this, {
134
+ callback: (entries: ResizeObserverEntry[]) => {
135
+ for (const entry of entries) {
136
+ if (entry.target.classList.contains('select__tags')) {
137
+ this.calculateTotalWidthOfTags();
138
+ }
139
+ }
140
+ }
141
+ });
131
142
  private visibilityManager?: VisibilityManager;
143
+ private chipFitFrame = 0;
144
+ private lastChipLayout = { hidden: -1, visible: -1 };
145
+ @state() private clippedChips: boolean[] = [];
146
+ private clipFrame = 0;
132
147
 
133
148
 
134
149
  @query('.select') popup: NilePopup;
@@ -142,6 +157,10 @@ export class NileSelect extends NileElement implements NileFormControl{
142
157
  @state() currentOption: NileOption;
143
158
  @state() selectedOptions: NileOption[] = [];
144
159
  @state() oldValue: string | string[] = '';
160
+ @state() private hiddenChipCount = 0;
161
+
162
+ /** Last measured width of the overflow button, in px. */
163
+ private lastOverflowWidth = 0;
145
164
 
146
165
  /** The name of the select, submitted as a name/value pair with form data. */
147
166
  @property() name = '';
@@ -214,6 +233,7 @@ export class NileSelect extends NileElement implements NileFormControl{
214
233
 
215
234
  @property({ attribute: true, reflect: true }) helpText = '';
216
235
  @property({ attribute: 'help-text', reflect: true }) help_text = '';
236
+ /** Re-runs the fitted-chip layout when the control's own width changes. */
217
237
  @property({ type: Boolean, attribute: true, reflect: true }) autoResize = false;
218
238
 
219
239
  @property({ attribute: 'error-message', reflect: true }) errorMessage = '';
@@ -231,8 +251,39 @@ export class NileSelect extends NileElement implements NileFormControl{
231
251
  * The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to
232
252
  * indicate the number of additional items that are selected. Set to 0 to remove the limit.
233
253
  */
254
+ private _maxOptionsVisible = 3;
255
+
256
+ private maxOptionsVisibleExplicit = false;
257
+
234
258
  @property({ attribute: 'max-options-visible', type: Number })
235
- maxOptionsVisible = 3;
259
+ get maxOptionsVisible(): number {
260
+ return this._maxOptionsVisible;
261
+ }
262
+
263
+ set maxOptionsVisible(value: number) {
264
+ const previous = this._maxOptionsVisible;
265
+ this._maxOptionsVisible = value;
266
+ // Any assignment opts this instance out of automatic chip fitting.
267
+ this.maxOptionsVisibleExplicit = true;
268
+ this.requestUpdate('maxOptionsVisible', previous);
269
+ }
270
+
271
+ private get chipFittingEnabled(): boolean {
272
+ return !!this.multiple && !this.maxOptionsVisibleExplicit;
273
+ }
274
+
275
+ /** Minimum width, in px, of a squeezed chip. Needs `showtooltip`. */
276
+ @property({ type: Number, reflect: true, attribute: true }) chipMinWidth = 110;
277
+
278
+ /** Maximum width, in px, of a squeezed chip. Needs `showtooltip`. */
279
+ @property({ type: Number, reflect: true, attribute: true }) chipMaxWidth = 180;
280
+
281
+ /**
282
+ * Shows a chip's full value in a tooltip when its text is clipped, and is
283
+ * what allows a chip to be truncated at all.
284
+ */
285
+ @property({ type: Boolean, attribute: true, reflect: true }) showTooltip = false;
286
+
236
287
 
237
288
  /** Disables the select control. */
238
289
  @property({ type: Boolean, reflect: true }) disabled = false;
@@ -278,9 +329,6 @@ export class NileSelect extends NileElement implements NileFormControl{
278
329
  @property({ type: Boolean, reflect: true }) required = false;
279
330
 
280
331
  @property({ type: Boolean }) showSelected = false;
281
-
282
- @state() oldMaxOptionsVisible: number = 1;
283
-
284
332
 
285
333
  @property({ type: Boolean, reflect: true, attribute: true }) enableTabClose = false;
286
334
 
@@ -364,23 +412,47 @@ export class NileSelect extends NileElement implements NileFormControl{
364
412
  this.visibilityManager?.cleanup();
365
413
  // Clean up body append elements
366
414
  this.portalManager.cleanupPortalAppend();
415
+ this.destroyChipFit();
367
416
  this.emit('nile-destroy');
368
417
  }
369
418
 
370
- private setupResizeObserver() {
371
- if (this.autoResize) {
372
- const tagsContainer = this.shadowRoot?.querySelector('.select__tags');
373
- if (tagsContainer) {
374
- this.resizeController = new ResizeController(this, {
375
- callback: () => this.calculateTotalWidthOfTags()
376
- });
377
- this.resizeController.observe(tagsContainer);
419
+ private setupResizeObserver(): void {
420
+ const wanted =
421
+ this.autoResize && this.chipFittingEnabled
422
+ ? (this.shadowRoot?.querySelector('.select__tags') as HTMLElement | null) ??
423
+ undefined
424
+ : undefined;
425
+
426
+ if (wanted === this.observedTagsContainer) {
427
+ if (!this.chipFittingEnabled) this.destroyChipFit();
428
+ return;
378
429
  }
379
- } else {
380
- this.resizeController?.unobserve?.(
381
- this.shadowRoot?.querySelector('.select__tags') as Element
382
- );
383
- this.resizeController = undefined;
430
+
431
+ if (this.observedTagsContainer) {
432
+ this.resizeController.unobserve(this.observedTagsContainer);
433
+ this.observedTagsContainer = undefined;
434
+ }
435
+
436
+ if (wanted) {
437
+ this.resizeController.observe(wanted);
438
+ this.observedTagsContainer = wanted;
439
+ return;
440
+ }
441
+
442
+ if (!this.chipFittingEnabled) {
443
+ this.destroyChipFit();
444
+ }
445
+ }
446
+
447
+ private syncVirtualSelectMaxOptionsVisible() {
448
+ if (!this.enableVirtualScroll || !this.maxOptionsVisibleExplicit) return;
449
+
450
+ const inner = this.shadowRoot?.querySelector('nile-virtual-select') as
451
+ | { maxOptionsVisible: number }
452
+ | null;
453
+
454
+ if (inner && inner.maxOptionsVisible !== this._maxOptionsVisible) {
455
+ inner.maxOptionsVisible = this._maxOptionsVisible;
384
456
  }
385
457
  }
386
458
 
@@ -397,12 +469,29 @@ export class NileSelect extends NileElement implements NileFormControl{
397
469
  if(_changedProperties.has('tickOnSelect')) {
398
470
  this.setTickOnSelectInOptions(this.tickOnSelect);
399
471
  }
400
- if (_changedProperties.has('autoResize')) {
472
+ if (
473
+ _changedProperties.has('autoResize') ||
474
+ _changedProperties.has('multiple') ||
475
+ _changedProperties.has('maxOptionsVisible')
476
+ ) {
401
477
  this.setupResizeObserver();
402
478
  }
479
+ this.syncVirtualSelectMaxOptionsVisible();
403
480
  if(_changedProperties.has('enableDescription')) {
404
481
  this.setEnableDescriptionInOptions();
405
482
  }
483
+ if (
484
+ _changedProperties.has('chipMinWidth') ||
485
+ _changedProperties.has('chipMaxWidth')
486
+ ) {
487
+ this.destroyChipFit();
488
+ }
489
+ if (this.chipFittingEnabled) {
490
+ this.calculateTotalWidthOfTags();
491
+ }
492
+ if (this.multiple && this.showTooltip) {
493
+ this.scheduleClipCheck();
494
+ }
406
495
  }
407
496
 
408
497
  protected firstUpdated(_changedProperties: PropertyValues): void {
@@ -751,9 +840,14 @@ export class NileSelect extends NileElement implements NileFormControl{
751
840
  el =>
752
841
  el instanceof Element && el.tagName.toLowerCase() === 'nile-icon-button'
753
842
  );
843
+ const isOverflowButton = path.some(
844
+ el =>
845
+ el instanceof HTMLButtonElement &&
846
+ el.classList.contains('select__tags-count')
847
+ );
754
848
 
755
849
  // Ignore disabled controls and clicks on tags (remove buttons)
756
- if (this.disabled || isIconButton) {
850
+ if (this.disabled || isIconButton || isOverflowButton) {
757
851
  return;
758
852
  }
759
853
 
@@ -951,25 +1045,13 @@ export class NileSelect extends NileElement implements NileFormControl{
951
1045
  return options;
952
1046
  }
953
1047
 
954
- private getOptionPrefix(option: NileOption): string {
1048
+ private getOptionPrefixNodes(option: NileOption): Node[] {
955
1049
  const prefixSlot = option.shadowRoot?.querySelector(
956
1050
  'slot[name="prefix"]'
957
- ) as HTMLSlotElement;
958
- if (!prefixSlot) return '';
959
-
960
- const assignedNodes = prefixSlot.assignedNodes();
961
- const htmlStrings: string[] = [];
962
-
963
- assignedNodes.forEach(node => {
964
- if (node instanceof HTMLElement) {
965
- htmlStrings.push(node.outerHTML);
966
- } else if (node.nodeType === Node.TEXT_NODE) {
967
- // Text node
968
- htmlStrings.push(node.textContent || '');
969
- }
970
- });
1051
+ ) as HTMLSlotElement | null;
1052
+ if (!prefixSlot) return [];
971
1053
 
972
- return htmlStrings.join('');
1054
+ return prefixSlot.assignedElements().map(el => el.cloneNode(true));
973
1055
  }
974
1056
 
975
1057
  // Gets the first <nile-option> element
@@ -1038,6 +1120,9 @@ export class NileSelect extends NileElement implements NileFormControl{
1038
1120
 
1039
1121
  // Update the value and display label
1040
1122
  if (this.multiple) {
1123
+ const hasOptions = this.getAllOptions().length > 0;
1124
+
1125
+ if (hasOptions) {
1041
1126
  this.value = this.selectedOptions.map(el => el.value);
1042
1127
 
1043
1128
  if (this.placeholder && this.value.length === 0) {
@@ -1045,6 +1130,7 @@ export class NileSelect extends NileElement implements NileFormControl{
1045
1130
  this.displayLabel = '';
1046
1131
  } else {
1047
1132
  this.displayLabel = this.selectedOptions.length + ' selected';
1133
+ }
1048
1134
  }
1049
1135
 
1050
1136
  if (this.selectedOptions.length === 0) {
@@ -1371,58 +1457,363 @@ export class NileSelect extends NileElement implements NileFormControl{
1371
1457
  event.stopPropagation();
1372
1458
  }
1373
1459
 
1374
- calculateWidthOfSelectTagsDiv() {
1375
- if (this.shadowRoot) {
1376
- const selectTagsDiv = this.shadowRoot.querySelector('div.select__tags');
1377
- if (selectTagsDiv instanceof HTMLElement) {
1378
- const width = selectTagsDiv.offsetWidth;
1379
- return width - 70;
1460
+ calculateTotalWidthOfTags() {
1461
+ if (!this.chipFittingEnabled) return;
1462
+
1463
+ if (this.chipFitFrame) cancelAnimationFrame(this.chipFitFrame);
1464
+ this.chipFitFrame = requestAnimationFrame(() => {
1465
+ this.chipFitFrame = 0;
1466
+ this.layoutChips();
1467
+ });
1380
1468
  }
1469
+
1470
+ private allocateChipWidths(
1471
+ natural: number[],
1472
+ available: number
1473
+ ): number[] | null {
1474
+ const min = this.chipMinWidth;
1475
+ const max = this.chipMaxWidth;
1476
+ const total = natural.reduce((sum, w) => sum + w, 0);
1477
+ if (total <= available) {
1478
+ return natural.slice();
1479
+ }
1480
+ // Nothing reveals a truncated value without a tooltip, so chips keep their
1481
+ // natural width and the surplus moves into the +N button instead.
1482
+ if (!this.showTooltip) return null;
1483
+ const capped = natural.map(w => Math.min(w, max));
1484
+ const cappedTotal = capped.reduce((sum, w) => sum + w, 0);
1485
+ if (cappedTotal <= available) {
1486
+ return capped;
1487
+ }
1488
+
1489
+ const widths = new Array<number>(natural.length).fill(0);
1490
+ let pool = capped.map((_, i) => i);
1491
+ let remaining = available;
1492
+
1493
+ for (;;) {
1494
+ if (pool.length === 0) break;
1495
+ const share = remaining / pool.length;
1496
+ const settled = pool.filter(i => capped[i] <= share);
1497
+ if (settled.length === 0) break;
1498
+ for (const i of settled) {
1499
+ widths[i] = capped[i];
1500
+ remaining -= capped[i];
1501
+ }
1502
+ pool = pool.filter(i => capped[i] > share);
1503
+ }
1504
+
1505
+ if (pool.length > 0) {
1506
+ const share = remaining / pool.length;
1507
+ if (share < min) {
1508
+ return null;
1509
+ }
1510
+
1511
+ const width = Math.floor(share);
1512
+ for (const i of pool) {
1513
+ widths[i] = width;
1381
1514
  }
1382
1515
  }
1383
1516
 
1384
- calculateTotalWidthOfTags() {
1385
- if (this.maxOptionsVisible !== Infinity) {
1386
- this.oldMaxOptionsVisible = this.maxOptionsVisible;
1387
- }
1388
-
1389
- this.maxOptionsVisible = Infinity;
1390
- setTimeout(() => {
1391
- let widths: number[] = [];
1392
- if (this.shadowRoot) {
1393
- const tags = this.shadowRoot.querySelectorAll('nile-tag');
1394
- tags.forEach(tag => {
1395
- if (tag instanceof HTMLElement) {
1396
- widths.push(tag.offsetWidth);
1517
+ return widths;
1518
+ }
1519
+
1520
+ private computeChipFit(
1521
+ natural: number[],
1522
+ available: number,
1523
+ overflowWidth: number,
1524
+ gap: number
1525
+ ): { visibleCount: number; widths: number[]; hiddenCount: number } {
1526
+ const total = natural.length;
1527
+
1528
+ if (total === 0) {
1529
+ return { visibleCount: 0, widths: [], hiddenCount: 0 };
1397
1530
  }
1398
- });
1531
+
1532
+ for (let count = total; count >= 1; count--) {
1533
+ const hidden = total - count;
1534
+ const reserved = hidden > 0 ? overflowWidth + gap : 0;
1535
+ const row = available - reserved - gap * (count - 1);
1536
+
1537
+ if (row <= 0) continue;
1538
+
1539
+ const widths = this.allocateChipWidths(natural.slice(0, count), row);
1540
+ if (widths) {
1541
+ return { visibleCount: count, widths, hiddenCount: hidden };
1399
1542
  }
1400
- if (this.value.length !== widths.length) {
1543
+ }
1544
+
1545
+ const room = available - overflowWidth - gap;
1546
+ const fallback = Math.max(
1547
+ 0,
1548
+ // A single chip too wide for the row has to be clipped either way; only
1549
+ // the tooltip variant squeezes it to chipMinWidth.
1550
+ Math.min(natural[0], this.showTooltip ? Math.max(this.chipMinWidth, room) : room)
1551
+ );
1552
+ return { visibleCount: 1, widths: [fallback], hiddenCount: total - 1 };
1553
+ }
1554
+
1555
+ private layoutChips(): void {
1556
+ const root = this.shadowRoot;
1557
+ if (!root) return;
1558
+
1559
+ const container = root.querySelector('.select__tags') as HTMLElement | null;
1560
+ if (!container) return;
1561
+
1562
+ const chips = Array.from(
1563
+ container.querySelectorAll('nile-tag')
1564
+ ) as HTMLElement[];
1565
+ if (chips.length === 0) {
1566
+ this.hiddenChipCount = 0;
1401
1567
  return;
1402
1568
  }
1403
1569
 
1404
- let sum = widths.reduce(
1405
- (accumulator, currentValue) => accumulator + currentValue,
1406
- 0
1570
+ const overflowEl = container.querySelector(
1571
+ '.select__tags-count'
1572
+ ) as HTMLElement | null;
1573
+
1574
+ for (const chip of chips) {
1575
+ chip.style.display = '';
1576
+ chip.style.width = 'auto';
1577
+ chip.style.maxWidth = 'none';
1578
+ chip.style.minWidth = '0';
1579
+ chip.style.flex = '0 0 auto';
1580
+ }
1581
+ const natural = chips.map(chip =>
1582
+ Math.ceil(chip.getBoundingClientRect().width)
1407
1583
  );
1408
- const widthOfSelectTagsDiv = this.calculateWidthOfSelectTagsDiv();
1409
- if (!widthOfSelectTagsDiv) {
1410
- return;
1584
+ // Not rendered yet on the pass that first finds an overflow; reuse the last width.
1585
+ const overflowWidth = overflowEl
1586
+ ? Math.ceil(overflowEl.getBoundingClientRect().width)
1587
+ : this.lastOverflowWidth;
1588
+ if (overflowEl) this.lastOverflowWidth = overflowWidth;
1589
+
1590
+ const containerStyles = getComputedStyle(container);
1591
+ const gap =
1592
+ parseFloat(containerStyles.columnGap || containerStyles.gap || '0') || 0;
1593
+
1594
+ const fit = this.computeChipFit(
1595
+ natural,
1596
+ container.clientWidth,
1597
+ overflowWidth,
1598
+ gap
1599
+ );
1600
+
1601
+ chips.forEach((chip, index) => {
1602
+ if (index < fit.visibleCount) {
1603
+ // Never squeeze a chip below chipMinWidth: narrower than that it is
1604
+ // also narrower than the tag's own box - the label is already at zero
1605
+ // while the remove button is fixed - so the box overflows the chip and
1606
+ // paints the X over the next chip or the +N button. A chip whose
1607
+ // natural width is already smaller is left alone, not padded out.
1608
+ const width = Math.min(
1609
+ natural[index],
1610
+ Math.max(fit.widths[index], this.chipMinWidth)
1611
+ );
1612
+ chip.style.display = '';
1613
+ chip.style.width = `${width}px`;
1614
+ chip.style.maxWidth = `${width}px`;
1615
+ chip.style.flex = '0 0 auto';
1616
+ } else {
1617
+ chip.style.display = 'none';
1411
1618
  }
1619
+ });
1412
1620
 
1413
- let summ = 0;
1414
- let lastindex = 0;
1621
+ if (
1622
+ this.lastChipLayout.hidden !== fit.hiddenCount ||
1623
+ this.lastChipLayout.visible !== fit.visibleCount
1624
+ ) {
1625
+ this.lastChipLayout = {
1626
+ hidden: fit.hiddenCount,
1627
+ visible: fit.visibleCount
1628
+ };
1629
+ this.hiddenChipCount = fit.hiddenCount;
1630
+ }
1415
1631
 
1416
- for (let i = 0; i < widths.length; i++) {
1417
- summ += widths[i];
1632
+ this.refreshClippedChips();
1633
+ }
1418
1634
 
1419
- if (summ > widthOfSelectTagsDiv) {
1420
- lastindex = i;
1421
- break;
1635
+ private isChipClipped(el: HTMLElement | null | undefined): boolean {
1636
+ if (!el) return false;
1637
+
1638
+ const root = el.shadowRoot;
1639
+ if (root) {
1640
+ const content = root.querySelector(
1641
+ '[part="content"]'
1642
+ ) as HTMLElement | null;
1643
+ if (content && content.scrollWidth > content.clientWidth + 1) return true;
1644
+
1645
+ const inner = root.querySelector(
1646
+ '[part="prefix_content_wrapper"]'
1647
+ ) as HTMLElement | null;
1648
+ if (inner && inner.scrollWidth > inner.clientWidth + 1) return true;
1649
+ }
1650
+
1651
+ return el.scrollWidth > el.clientWidth + 1;
1652
+ }
1653
+
1654
+ private refreshClippedChips(): void {
1655
+ if (!this.multiple) return;
1656
+
1657
+ const root = this.shadowRoot;
1658
+ if (!root) return;
1659
+
1660
+ const chips = Array.from(
1661
+ root.querySelectorAll('.select__tags nile-tag')
1662
+ ) as HTMLElement[];
1663
+
1664
+ const next = chips.map(chip => this.isChipClipped(chip));
1665
+
1666
+ const same =
1667
+ next.length === this.clippedChips.length &&
1668
+ next.every((v, i) => v === this.clippedChips[i]);
1669
+
1670
+ if (!same) {
1671
+ this.clippedChips = next;
1422
1672
  }
1423
1673
  }
1424
- this.maxOptionsVisible = lastindex;
1425
- }, 1);
1674
+
1675
+ private scheduleClipCheck(): void {
1676
+ if (this.clipFrame) cancelAnimationFrame(this.clipFrame);
1677
+ this.clipFrame = requestAnimationFrame(() => {
1678
+ this.clipFrame = 0;
1679
+ this.refreshClippedChips();
1680
+ });
1681
+ }
1682
+
1683
+ /** A chip gets a tooltip only while its text is measured as clipped. */
1684
+ private chipTooltipDisabled(index: number): boolean {
1685
+ return !this.showTooltip || !(this.clippedChips[index] ?? false);
1686
+ }
1687
+
1688
+ private destroyChipFit(): void {
1689
+ if (this.chipFitFrame) cancelAnimationFrame(this.chipFitFrame);
1690
+ this.chipFitFrame = 0;
1691
+ if (this.clipFrame) cancelAnimationFrame(this.clipFrame);
1692
+ this.clipFrame = 0;
1693
+ this.lastChipLayout = { hidden: -1, visible: -1 };
1694
+ }
1695
+
1696
+ private getChipLabel(option: NileOption): string {
1697
+ return (option.getTextLabel?.() ?? '').trim();
1698
+ }
1699
+
1700
+ private handleChipKeyDown(event: KeyboardEvent, option: NileOption): void {
1701
+ if (event.key !== KeyCode.BACKSPACE && event.key !== KeyCode.DELETE) return;
1702
+ if (this.disabled) return;
1703
+
1704
+ event.preventDefault();
1705
+ event.stopPropagation();
1706
+
1707
+ const removedIndex = this.selectedOptions.indexOf(option);
1708
+
1709
+ this.handleTagRemove(
1710
+ new CustomEvent('nile-remove') as unknown as NileRemoveEvent,
1711
+ option
1712
+ );
1713
+
1714
+ this.restoreFocusAfterChipRemoval(removedIndex);
1715
+ }
1716
+
1717
+ /** Moves focus to the next chip, the last chip, or the field after a removal. */
1718
+ private async restoreFocusAfterChipRemoval(
1719
+ removedIndex: number
1720
+ ): Promise<void> {
1721
+ if (removedIndex < 0) return;
1722
+
1723
+ await this.updateComplete;
1724
+
1725
+ // Wait for the fit pass so hidden chips are not focused.
1726
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
1727
+
1728
+ const visibleChips = Array.from(
1729
+ this.shadowRoot?.querySelectorAll<HTMLElement>('.select__tags nile-tag') ??
1730
+ []
1731
+ ).filter(chip => chip.style.display !== 'none');
1732
+
1733
+ const target =
1734
+ visibleChips[removedIndex] ?? visibleChips[visibleChips.length - 1];
1735
+
1736
+ if (target) {
1737
+ target.focus({ preventScroll: true });
1738
+ } else {
1739
+ this.displayInput?.focus({ preventScroll: true });
1740
+ }
1741
+ }
1742
+
1743
+ private handleOverflowActivate(event: Event): void {
1744
+ event.preventDefault();
1745
+ event.stopPropagation();
1746
+ if (this.disabled) return;
1747
+ if (this.open) {
1748
+ this.hide();
1749
+ } else {
1750
+ this.show();
1751
+ }
1752
+ }
1753
+
1754
+ private handleOverflowKeyDown(event: KeyboardEvent): void {
1755
+ if (event.key !== KeyCode.ENTER && event.key !== KeyCode.SPACE) return;
1756
+ this.handleOverflowActivate(event);
1757
+ }
1758
+
1759
+ private renderFittedTags() {
1760
+ return html`
1761
+ <div part="tags" class="select__tags select__tags--fitted">
1762
+ ${this.selectedOptions.map((option, index) => {
1763
+ const label = this.getChipLabel(option);
1764
+ const prefixNodes = this.getOptionPrefixNodes(option);
1765
+ return html`
1766
+ <nile-lite-tooltip
1767
+ class="select__tag-tip"
1768
+ .content=${label}
1769
+ ?disabled=${this.chipTooltipDisabled(index)}
1770
+ >
1771
+ <nile-tag
1772
+ part="tag"
1773
+ exportparts="
1774
+ base:tag__base,
1775
+ content:tag__content,
1776
+ remove-button:tag__remove-button,
1777
+ remove-button__base:tag__remove-button__base,
1778
+ prefix:tag__prefix,
1779
+ prefix_content_wrapper:tag__prefix_content_wrapper
1780
+ "
1781
+ ?pill=${this.pill}
1782
+ size=${this.size}
1783
+ removable
1784
+ tabindex="0"
1785
+ aria-label=${`${label}. Press Backspace or Delete to remove.`}
1786
+ @nile-remove=${(event: NileRemoveEvent) =>
1787
+ this.handleTagRemove(event, option)}
1788
+ @keydown=${(event: KeyboardEvent) =>
1789
+ this.handleChipKeyDown(event, option)}
1790
+ >
1791
+ ${prefixNodes.length
1792
+ ? html`<span class="tag__prefix" slot="prefix"
1793
+ >${prefixNodes}</span
1794
+ >`
1795
+ : ''}
1796
+ ${label}
1797
+ </nile-tag>
1798
+ </nile-lite-tooltip>
1799
+ `;
1800
+ })}
1801
+ ${this.hiddenChipCount > 0
1802
+ ? html`<button
1803
+ type="button"
1804
+ class="select__tags-count"
1805
+ ?disabled=${this.disabled}
1806
+ aria-controls="listbox"
1807
+ aria-expanded=${this.open ? 'true' : 'false'}
1808
+ aria-haspopup="listbox"
1809
+ aria-label=${`${this.hiddenChipCount} more selected`}
1810
+ @click=${this.handleOverflowActivate}
1811
+ @keydown=${this.handleOverflowKeyDown}
1812
+ >+${this.hiddenChipCount}</button
1813
+ >`
1814
+ : ''}
1815
+ </div>
1816
+ `;
1426
1817
  }
1427
1818
 
1428
1819
  handleInputAfterInit() {
@@ -1444,9 +1835,9 @@ export class NileSelect extends NileElement implements NileFormControl{
1444
1835
  const isPlaceholderVisible = this.placeholder && this.value.length === 0;
1445
1836
  const hasHelpText = (this.helpText || this.help_text) ? true : false;
1446
1837
  const hasErrorMessage = this.errorMessage ? true : false;
1447
- const prefixContent = this.selectedOptions[0]
1448
- ? this.getOptionPrefix(this.selectedOptions[0])
1449
- : '';
1838
+ const prefixNodes = this.selectedOptions[0]
1839
+ ? this.getOptionPrefixNodes(this.selectedOptions[0])
1840
+ : [];
1450
1841
 
1451
1842
  return html`
1452
1843
  <div
@@ -1521,9 +1912,9 @@ export class NileSelect extends NileElement implements NileFormControl{
1521
1912
  @keydown=${this.handleComboboxKeyDown}
1522
1913
  @mousedown=${this.handleComboboxMouseDown}
1523
1914
  >
1524
- ${prefixContent && !this.multiple
1915
+ ${prefixNodes.length && !this.multiple
1525
1916
  ? html`<div class="select__prefix--from-options">
1526
- ${unsafeHTML(prefixContent)}
1917
+ ${prefixNodes}
1527
1918
  </div>`
1528
1919
  : html`<slot
1529
1920
  part="prefix"
@@ -1558,21 +1949,24 @@ export class NileSelect extends NileElement implements NileFormControl{
1558
1949
  />
1559
1950
 
1560
1951
  ${this.multiple
1561
- ? html`
1952
+ ? this.chipFittingEnabled
1953
+ ? this.renderFittedTags()
1954
+ : html`
1562
1955
  <div part="tags" class="select__tags">
1563
1956
  ${this.selectedOptions.map((option, index) => {
1564
1957
  if (
1565
1958
  index < this.maxOptionsVisible ||
1566
1959
  this.maxOptionsVisible <= 0
1567
1960
  ) {
1568
- const classes = {
1569
- select__invisible:
1570
- index + 1 > this.oldMaxOptionsVisible &&
1571
- this.maxOptionsVisible === Infinity,
1572
- };
1961
+ const prefixNodes =
1962
+ this.getOptionPrefixNodes(option);
1573
1963
  return html`
1964
+ <nile-lite-tooltip
1965
+ class="select__tag-tip"
1966
+ .content=${option.getTextLabel()}
1967
+ ?disabled=${this.chipTooltipDisabled(index)}
1968
+ >
1574
1969
  <nile-tag
1575
- class=${classMap(classes)}
1576
1970
  part="tag"
1577
1971
  exportparts="
1578
1972
  base:tag__base,
@@ -1582,21 +1976,21 @@ export class NileSelect extends NileElement implements NileFormControl{
1582
1976
  prefix:tag__prefix,
1583
1977
  prefix_content_wrapper:tag__prefix_content_wrapper
1584
1978
  "
1979
+ style="max-width:${this.chipMaxWidth}px"
1585
1980
  ?pill=${this.pill}
1586
1981
  size=${this.size}
1587
1982
  removable
1588
1983
  @nile-remove=${(event: NileRemoveEvent) =>
1589
1984
  this.handleTagRemove(event, option)}
1590
1985
  >
1591
- ${this.getOptionPrefix(option)
1986
+ ${prefixNodes.length
1592
1987
  ? html`<span class="tag__prefix" slot="prefix"
1593
- >${unsafeHTML(
1594
- this.getOptionPrefix(option)
1595
- )}</span
1988
+ >${prefixNodes}</span
1596
1989
  >`
1597
1990
  : ''}
1598
1991
  ${option.getTextLabel()}
1599
1992
  </nile-tag>
1993
+ </nile-lite-tooltip>
1600
1994
  `;
1601
1995
  } else if (index === this.maxOptionsVisible) {
1602
1996
  return html`
@@ -1737,21 +2131,35 @@ export class NileSelect extends NileElement implements NileFormControl{
1737
2131
  @click="${this.handleFooterClick}"
1738
2132
  >
1739
2133
  <span
2134
+ class="select__footer-action"
1740
2135
  @click="${this.toggleShowSelected}"
1741
2136
  style="cursor: pointer;"
2137
+ title="Show selected"
1742
2138
  >
1743
2139
  <nile-checkbox
1744
2140
  ?disabled="${this.selectedOptions.length === 0}"
1745
2141
  ?checked="${this.showSelected}"
1746
2142
  >
1747
- &nbsp; Show Selected</nile-checkbox
2143
+ <span class="select__footer-label"
2144
+ >Selected</span
2145
+ ></nile-checkbox
1748
2146
  >
1749
2147
  </span>
1750
2148
  ${this.selectedOptions.length !== 0
1751
2149
  ? html`<span
1752
2150
  class="select__clear"
1753
2151
  @click="${this.unSlectAll}"
1754
- >Clear All</span
2152
+ title="Clear"
2153
+ ><nile-glyph
2154
+ class="select__footer-glyph"
2155
+ name="ng-eraser"
2156
+ size="16"
2157
+ method="var(--nile-svg-method-stroke, var(--ng-svg-method-stroke))"
2158
+ color="var(--nile-colors-primary-600, var(--ng-colors-text-brand-secondary-700))"
2159
+ ></nile-glyph
2160
+ ><span class="select__footer-label"
2161
+ >Clear</span
2162
+ ></span
1755
2163
  >`
1756
2164
  : ``}
1757
2165
  </div>`