@smilodon/core 1.4.7 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -646,6 +646,28 @@ enhanced-select.dark-mode {
646
646
  --select-dropdown-bg /* Dropdown background (white) */
647
647
  --select-dropdown-border /* Dropdown border color (#ccc) */
648
648
  --select-dropdown-shadow /* Dropdown shadow */
649
+ --select-empty-padding /* Empty/no-results container padding */
650
+ --select-empty-color /* Text color for empty/no-results models */
651
+ --select-empty-font-size /* Font size */
652
+ --select-empty-bg /* Background for empty/no-results state */
653
+ --select-empty-min-height /* Minimum height of empty state box */
654
+
655
+ /* Arrow/button */
656
+ --select-arrow-size /* Width & height of SVG icon (16px default) */
657
+ --select-arrow-color /* Icon color (#667eea) */
658
+ --select-arrow-hover-color /* Icon color when hovered (#667eea) */
659
+ --select-arrow-hover-bg /* Background when hover (rgba(102,126,234,0.08)) */
660
+ --select-arrow-width /* Container width (40px) */
661
+ --select-arrow-border-radius /* Container border radius */
662
+
663
+ /* Group headers (when using groupedItems or flat items with `group` property) */
664
+ --select-group-header-padding /* Padding inside header (8px 12px) */
665
+ --select-group-header-color /* Text color (#6b7280) */
666
+ --select-group-header-bg /* Background (#f3f4f6) */
667
+ --select-group-header-font-size
668
+ --select-group-header-text-transform
669
+ --select-group-header-letter-spacing
670
+ --select-group-header-border-bottom
649
671
  ```
650
672
 
651
673
  **Dark Mode (Opt-in)**
@@ -659,6 +681,8 @@ enhanced-select.dark-mode {
659
681
  --select-dark-option-bg /* Dark option background (#1f2937) */
660
682
  --select-dark-option-hover-bg /* Dark hover background (#374151) */
661
683
  --select-dark-option-selected-bg /* Dark selected bg (#3730a3) */
684
+ --select-dark-group-header-color /* Dark header text */
685
+ --select-dark-group-header-bg /* Dark header background */
662
686
  ```
663
687
 
664
688
  **Complete CSS Variables List (60+ variables)**
@@ -698,6 +722,10 @@ enhanced-select {
698
722
  **Badge Remove/Delete Button (Multi-Select)**
699
723
  The × button that removes selected items in multi-select mode is fully customizable:
700
724
 
725
+ **Group Header & No‑Results Parts**
726
+ Both the group header and the no-results message are exposed as shadow parts (`group-header` and `no-results`) so you can target them with `::part()` selectors or CSS variables. This makes it straightforward to match the look of your host framework or UI kit.
727
+
728
+
701
729
  ```css
702
730
  enhanced-select {
703
731
  /* Customize badge appearance */
package/dist/index.cjs CHANGED
@@ -1875,7 +1875,22 @@ class EnhancedSelect extends HTMLElement {
1875
1875
  }
1876
1876
  set classMap(map) {
1877
1877
  this._classMap = map;
1878
- this._setGlobalStylesMirroring(Boolean(this._optionRenderer || map));
1878
+ this._setGlobalStylesMirroring(Boolean(this._optionRenderer || map || this._groupHeaderRenderer));
1879
+ if (!this.isConnected)
1880
+ return;
1881
+ this._renderOptions();
1882
+ }
1883
+ /**
1884
+ * DOM-based renderer for group headers. When provided, the component will
1885
+ * call this function for each group during rendering. The returned element
1886
+ * will receive `.group-header` and `part="group-header"` automatically.
1887
+ */
1888
+ get groupHeaderRenderer() {
1889
+ return this._groupHeaderRenderer;
1890
+ }
1891
+ set groupHeaderRenderer(renderer) {
1892
+ this._groupHeaderRenderer = renderer;
1893
+ this._setGlobalStylesMirroring(Boolean(this._optionRenderer || this._classMap || renderer));
1879
1894
  if (!this.isConnected)
1880
1895
  return;
1881
1896
  this._renderOptions();
@@ -1934,6 +1949,8 @@ class EnhancedSelect extends HTMLElement {
1934
1949
  this._initializeObservers();
1935
1950
  }
1936
1951
  connectedCallback() {
1952
+ // register instance
1953
+ EnhancedSelect._instances.add(this);
1937
1954
  // WORKAROUND: Force display style on host element for Angular compatibility
1938
1955
  // Angular's rendering seems to not apply :host styles correctly in some cases
1939
1956
  // Must be done in connectedCallback when element is attached to DOM
@@ -1952,6 +1969,8 @@ class EnhancedSelect extends HTMLElement {
1952
1969
  }
1953
1970
  }
1954
1971
  disconnectedCallback() {
1972
+ // unregister instance
1973
+ EnhancedSelect._instances.delete(this);
1955
1974
  // Cleanup observers
1956
1975
  this._resizeObserver?.disconnect();
1957
1976
  this._intersectionObserver?.disconnect();
@@ -2237,6 +2256,8 @@ class EnhancedSelect extends HTMLElement {
2237
2256
  right: 0;
2238
2257
  bottom: 0;
2239
2258
  width: var(--select-arrow-width, 40px);
2259
+ /* allow explicit height override even though container normally stretches */
2260
+ height: var(--select-arrow-height, auto);
2240
2261
  display: flex;
2241
2262
  align-items: center;
2242
2263
  justify-content: center;
@@ -2302,9 +2323,14 @@ class EnhancedSelect extends HTMLElement {
2302
2323
  background-color: var(--select-arrow-hover-bg, rgba(102, 126, 234, 0.08));
2303
2324
  }
2304
2325
 
2326
+ .dropdown-arrow:hover {
2327
+ /* legacy alias --select-arrow-hover for icon color */
2328
+ color: var(--select-arrow-hover, var(--select-arrow-hover-color, #667eea));
2329
+ }
2330
+
2305
2331
  .dropdown-arrow {
2306
- width: var(--select-arrow-size, 16px);
2307
- height: var(--select-arrow-size, 16px);
2332
+ width: var(--select-arrow-width, var(--select-arrow-size, 16px));
2333
+ height: var(--select-arrow-height, var(--select-arrow-size, 16px));
2308
2334
  color: var(--select-arrow-color, #667eea);
2309
2335
  transition: transform 0.2s ease, color 0.2s ease;
2310
2336
  transform: translateY(0);
@@ -2413,6 +2439,20 @@ class EnhancedSelect extends HTMLElement {
2413
2439
  background: var(--select-options-bg, var(--select-dropdown-bg, var(--select-bg, white)));
2414
2440
  }
2415
2441
 
2442
+ .group-header {
2443
+ padding: var(--select-group-header-padding, 8px 12px);
2444
+ font-weight: var(--select-group-header-weight, 600);
2445
+ color: var(--select-group-header-color, #6b7280);
2446
+ background-color: var(--select-group-header-bg, #f3f4f6);
2447
+ font-size: var(--select-group-header-font-size, 12px);
2448
+ text-transform: var(--select-group-header-text-transform, uppercase);
2449
+ letter-spacing: var(--select-group-header-letter-spacing, 0.05em);
2450
+ position: sticky;
2451
+ top: 0;
2452
+ z-index: 1;
2453
+ border-bottom: var(--select-group-header-border-bottom, 1px solid #e5e7eb);
2454
+ }
2455
+
2416
2456
  .option {
2417
2457
  padding: var(--select-option-padding, 8px 12px);
2418
2458
  cursor: pointer;
@@ -2586,9 +2626,12 @@ class EnhancedSelect extends HTMLElement {
2586
2626
  }
2587
2627
  }
2588
2628
 
2589
- /* Dark mode - Opt-in via class or data attribute */
2629
+ /* Dark mode - Opt-in via class, data attribute, or ancestor context */
2590
2630
  :host(.dark-mode),
2591
- :host([data-theme="dark"]) {
2631
+ :host([data-theme="dark"]),
2632
+ :host-context(.dark-mode),
2633
+ :host-context(.dark),
2634
+ :host-context([data-theme="dark"]) {
2592
2635
  .input-container {
2593
2636
  background: var(--select-dark-bg, #1f2937);
2594
2637
  border-color: var(--select-dark-border, #4b5563);
@@ -2641,6 +2684,13 @@ class EnhancedSelect extends HTMLElement {
2641
2684
 
2642
2685
  .option.active:not(.selected) {
2643
2686
  background-color: var(--select-dark-option-active-bg, #374151);
2687
+ }
2688
+
2689
+ /* Group header in dark mode */
2690
+ .group-header {
2691
+ color: var(--select-dark-group-header-color, var(--select-group-header-color, #6b7280));
2692
+ background-color: var(--select-dark-group-header-bg, var(--select-group-header-bg, #374151));
2693
+ }
2644
2694
  color: var(--select-dark-option-active-color, #f9fafb);
2645
2695
  outline: var(--select-dark-option-active-outline, 2px solid rgba(129, 140, 248, 0.55));
2646
2696
  }
@@ -2723,19 +2773,14 @@ class EnhancedSelect extends HTMLElement {
2723
2773
  this._boundArrowClick = (e) => {
2724
2774
  e.stopPropagation();
2725
2775
  e.preventDefault();
2726
- const wasOpen = this._state.isOpen;
2727
- this._state.isOpen = !this._state.isOpen;
2728
- this._updateDropdownVisibility();
2729
- this._updateArrowRotation();
2730
- if (this._state.isOpen && this._config.callbacks.onOpen) {
2731
- this._config.callbacks.onOpen();
2732
- }
2733
- else if (!this._state.isOpen && this._config.callbacks.onClose) {
2734
- this._config.callbacks.onClose();
2776
+ // delegate to the existing open/close helpers so we don't accidentally
2777
+ // drift out of sync with the logic in those methods (focus, events,
2778
+ // scroll-to-selected, etc.)
2779
+ if (this._state.isOpen) {
2780
+ this._handleClose();
2735
2781
  }
2736
- // Scroll to selected when opening
2737
- if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
2738
- setTimeout(() => this._scrollToSelected(), 50);
2782
+ else {
2783
+ this._handleOpen();
2739
2784
  }
2740
2785
  };
2741
2786
  this._arrowContainer.addEventListener('click', this._boundArrowClick);
@@ -2752,7 +2797,12 @@ class EnhancedSelect extends HTMLElement {
2752
2797
  });
2753
2798
  }
2754
2799
  // Input container click - focus input and open dropdown
2800
+ // Prevent the original pointer event from bubbling/causing default focus behavior
2801
+ // which can interfere with option click handling when opening the dropdown
2755
2802
  this._inputContainer.addEventListener('pointerdown', (e) => {
2803
+ // Prevent propagation to document click listener but do NOT preventDefault.
2804
+ // Allow default so browser events (click) on newly opened options still fire.
2805
+ e.stopPropagation();
2756
2806
  const target = e.target;
2757
2807
  if (!this._config.enabled)
2758
2808
  return;
@@ -2760,10 +2810,27 @@ class EnhancedSelect extends HTMLElement {
2760
2810
  return;
2761
2811
  if (target && target.closest('.clear-control-button'))
2762
2812
  return;
2763
- if (!this._state.isOpen) {
2813
+ const wasClosed = !this._state.isOpen;
2814
+ if (wasClosed) {
2764
2815
  this._handleOpen();
2765
2816
  }
2817
+ else {
2818
+ // clicking the input while open should close the dropdown too
2819
+ this._handleClose();
2820
+ }
2821
+ // Focus the input (do not prevent default behavior)
2766
2822
  this._input.focus();
2823
+ // If we just opened the dropdown, transfer pointer capture to the
2824
+ // options container so the subsequent pointerup lands there instead of
2825
+ // staying with the input container (which would swallow the event).
2826
+ if (wasClosed && this._optionsContainer && typeof e.pointerId === 'number') {
2827
+ try {
2828
+ this._optionsContainer.setPointerCapture(e.pointerId);
2829
+ }
2830
+ catch (_err) {
2831
+ // Some browsers may throw if element is not yet "connected"; ignore
2832
+ }
2833
+ }
2767
2834
  });
2768
2835
  // Input container click - prevent event from reaching document listener
2769
2836
  this._container.addEventListener('click', (e) => {
@@ -2771,6 +2838,12 @@ class EnhancedSelect extends HTMLElement {
2771
2838
  });
2772
2839
  // Input focus/blur
2773
2840
  this._input.addEventListener('focus', () => this._handleOpen());
2841
+ // When the input loses focus we normally close the dropdown, but
2842
+ // clicking an option will blur the input before the option's click
2843
+ // handler executes. To avoid the blur timer closing the dropdown
2844
+ // prematurely we use a short-lived flag that is set whenever we start
2845
+ // interacting with the options container. The close callback checks this
2846
+ // flag and skips closing if the user is about to click an option.
2774
2847
  this._input.addEventListener('blur', (e) => {
2775
2848
  const related = e.relatedTarget;
2776
2849
  if (related && (this._shadow.contains(related) || this._container.contains(related))) {
@@ -2778,6 +2851,10 @@ class EnhancedSelect extends HTMLElement {
2778
2851
  }
2779
2852
  // Delay to allow option click/focus transitions
2780
2853
  setTimeout(() => {
2854
+ if (this._suppressBlurClose) {
2855
+ // another pointerdown inside options is in progress; keep open
2856
+ return;
2857
+ }
2781
2858
  const active = document.activeElement;
2782
2859
  if (active && (this._shadow.contains(active) || this._container.contains(active))) {
2783
2860
  return;
@@ -2792,11 +2869,33 @@ class EnhancedSelect extends HTMLElement {
2792
2869
  const query = e.target.value;
2793
2870
  this._handleSearch(query);
2794
2871
  });
2795
- // Delegated click listener for improved event handling (smart fallback)
2796
- this._optionsContainer.addEventListener('click', (e) => {
2797
- const target = e.target;
2798
- // Handle option clicks
2799
- const option = target.closest('[data-sm-selectable], [data-selectable], [data-sm-state]');
2872
+ // If the user presses down inside the options container we should
2873
+ // temporarily suppress blur-based closing until after the click has
2874
+ // been handled. Setting a flag that gets cleared in the next tick is
2875
+ // sufficient.
2876
+ this._optionsContainer.addEventListener('pointerdown', () => {
2877
+ this._suppressBlurClose = true;
2878
+ setTimeout(() => {
2879
+ this._suppressBlurClose = false;
2880
+ }, 0);
2881
+ });
2882
+ // Delegated click listener for improved event handling (robust across shadow DOM)
2883
+ const handleOptionEvent = (e) => {
2884
+ const path = (e.composedPath && e.composedPath()) || [e.target];
2885
+ let option = null;
2886
+ for (const node of path) {
2887
+ if (!(node instanceof Element))
2888
+ continue;
2889
+ try {
2890
+ if (node.matches('[data-sm-selectable], [data-selectable], [data-sm-state]')) {
2891
+ option = node;
2892
+ break;
2893
+ }
2894
+ }
2895
+ catch (err) {
2896
+ continue;
2897
+ }
2898
+ }
2800
2899
  if (option && !option.hasAttribute('aria-disabled')) {
2801
2900
  const indexStr = option.getAttribute('data-sm-index') ?? option.getAttribute('data-index');
2802
2901
  const index = Number(indexStr);
@@ -2807,13 +2906,45 @@ class EnhancedSelect extends HTMLElement {
2807
2906
  });
2808
2907
  }
2809
2908
  }
2810
- });
2909
+ };
2910
+ this._optionsContainer.addEventListener('click', handleOptionEvent);
2911
+ // also watch pointerup to catch cases where the pointerdown started outside
2912
+ // (e.g. on the input) and the click never fires
2913
+ this._optionsContainer.addEventListener('pointerup', handleOptionEvent);
2811
2914
  // Keyboard navigation
2812
2915
  this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
2813
- // Click outside to close
2916
+ // Click outside to close — robust detection across shadow DOM and custom renderers
2814
2917
  document.addEventListener('pointerdown', (e) => {
2815
2918
  const path = (e.composedPath && e.composedPath()) || [];
2816
- const clickedInside = path.includes(this) || path.includes(this._container) || this._shadow.contains(e.target);
2919
+ let clickedInside = false;
2920
+ for (const node of path) {
2921
+ if (node === this || node === this._container) {
2922
+ clickedInside = true;
2923
+ break;
2924
+ }
2925
+ if (node instanceof Node) {
2926
+ try {
2927
+ if (this._shadow && this._shadow.contains(node)) {
2928
+ clickedInside = true;
2929
+ break;
2930
+ }
2931
+ }
2932
+ catch (err) {
2933
+ // ignore
2934
+ }
2935
+ }
2936
+ if (node instanceof Element) {
2937
+ try {
2938
+ if (node.matches('[data-sm-selectable], [data-selectable], [data-sm-state], .input-container, .select-container, .dropdown-arrow-container, .clear-control-button')) {
2939
+ clickedInside = true;
2940
+ break;
2941
+ }
2942
+ }
2943
+ catch (err) {
2944
+ // ignore
2945
+ }
2946
+ }
2947
+ }
2817
2948
  if (!clickedInside) {
2818
2949
  this._handleClose();
2819
2950
  }
@@ -2862,6 +2993,16 @@ class EnhancedSelect extends HTMLElement {
2862
2993
  _handleOpen() {
2863
2994
  if (!this._config.enabled || this._state.isOpen)
2864
2995
  return;
2996
+ // close any other open selects before proceeding
2997
+ EnhancedSelect._instances.forEach(inst => {
2998
+ if (inst !== this)
2999
+ inst._handleClose();
3000
+ });
3001
+ // Always focus the input when opening so callers (arrow click,
3002
+ // programmatic `open()`, etc.) get the keyboard cursor. This was a
3003
+ // frequent source of confusion in #14 where people opened the dropdown
3004
+ // but the text field never received focus.
3005
+ this._input.focus();
2865
3006
  this._markOpenStart();
2866
3007
  this._state.isOpen = true;
2867
3008
  this._dropdown.style.display = 'block';
@@ -3277,13 +3418,14 @@ class EnhancedSelect extends HTMLElement {
3277
3418
  // FIX: Do not rely on this._optionsContainer.children[index] because filtering changes the children
3278
3419
  // Instead, use the index to update state directly
3279
3420
  const item = this._state.loadedItems[index];
3421
+ // Debug: log selection attempt
3280
3422
  if (!item)
3281
3423
  return;
3424
+ const isCurrentlySelected = this._state.selectedIndices.has(index);
3282
3425
  // Keep active/focus styling aligned with the most recently interacted option.
3283
3426
  // Without this, a previously selected item may retain active classes/styles
3284
3427
  // after selecting a different option.
3285
3428
  this._state.activeIndex = index;
3286
- const isCurrentlySelected = this._state.selectedIndices.has(index);
3287
3429
  if (this._config.selection.mode === 'single') {
3288
3430
  // Single select: clear previous and select new
3289
3431
  const wasSelected = this._state.selectedIndices.has(index);
@@ -3514,6 +3656,7 @@ class EnhancedSelect extends HTMLElement {
3514
3656
  const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3515
3657
  const selectedValues = selectedItems.map(getValue);
3516
3658
  const selectedIndices = Array.from(this._state.selectedIndices);
3659
+ // Debug: log change payload
3517
3660
  this._emit('change', { selectedItems, selectedValues, selectedIndices });
3518
3661
  this._config.callbacks.onChange?.(selectedItems, selectedValues);
3519
3662
  }
@@ -3833,23 +3976,25 @@ class EnhancedSelect extends HTMLElement {
3833
3976
  const query = this._state.searchQuery.toLowerCase();
3834
3977
  // Handle Grouped Items Rendering (when no search query)
3835
3978
  if (this._state.groupedItems.length > 0 && !query) {
3836
- this._state.groupedItems.forEach(group => {
3837
- const header = document.createElement('div');
3838
- header.className = 'group-header';
3839
- header.textContent = group.label;
3840
- Object.assign(header.style, {
3841
- padding: '8px 12px',
3842
- fontWeight: '600',
3843
- color: '#6b7280',
3844
- backgroundColor: '#f3f4f6',
3845
- fontSize: '12px',
3846
- textTransform: 'uppercase',
3847
- letterSpacing: '0.05em',
3848
- position: 'sticky',
3849
- top: '0',
3850
- zIndex: '1',
3851
- borderBottom: '1px solid #e5e7eb'
3852
- });
3979
+ this._state.groupedItems.forEach((group, groupIndex) => {
3980
+ let header;
3981
+ if (this.groupHeaderRenderer) {
3982
+ header = this.groupHeaderRenderer(group, groupIndex);
3983
+ // make sure the returned element has the correct semantics so
3984
+ // people can style it. we add the class/part even if the renderer
3985
+ // returned something else to ensure backward compatibility.
3986
+ if (!(header instanceof HTMLElement)) {
3987
+ // fall back to default if API is misused
3988
+ header = document.createElement('div');
3989
+ header.textContent = String(group.label);
3990
+ }
3991
+ }
3992
+ else {
3993
+ header = document.createElement('div');
3994
+ header.textContent = group.label;
3995
+ }
3996
+ header.classList.add('group-header');
3997
+ header.setAttribute('part', 'group-header');
3853
3998
  this._optionsContainer.appendChild(header);
3854
3999
  group.options.forEach(item => {
3855
4000
  // Find original index for correct ID generation and selection
@@ -4122,6 +4267,8 @@ class EnhancedSelect extends HTMLElement {
4122
4267
  }
4123
4268
  }
4124
4269
  }
4270
+ /** live set of all connected instances; used to auto-close siblings */
4271
+ EnhancedSelect._instances = new Set();
4125
4272
  // Register custom element
4126
4273
  if (!customElements.get('enhanced-select')) {
4127
4274
  customElements.define('enhanced-select', EnhancedSelect);