@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/dist/index.js CHANGED
@@ -1873,7 +1873,22 @@ class EnhancedSelect extends HTMLElement {
1873
1873
  }
1874
1874
  set classMap(map) {
1875
1875
  this._classMap = map;
1876
- this._setGlobalStylesMirroring(Boolean(this._optionRenderer || map));
1876
+ this._setGlobalStylesMirroring(Boolean(this._optionRenderer || map || this._groupHeaderRenderer));
1877
+ if (!this.isConnected)
1878
+ return;
1879
+ this._renderOptions();
1880
+ }
1881
+ /**
1882
+ * DOM-based renderer for group headers. When provided, the component will
1883
+ * call this function for each group during rendering. The returned element
1884
+ * will receive `.group-header` and `part="group-header"` automatically.
1885
+ */
1886
+ get groupHeaderRenderer() {
1887
+ return this._groupHeaderRenderer;
1888
+ }
1889
+ set groupHeaderRenderer(renderer) {
1890
+ this._groupHeaderRenderer = renderer;
1891
+ this._setGlobalStylesMirroring(Boolean(this._optionRenderer || this._classMap || renderer));
1877
1892
  if (!this.isConnected)
1878
1893
  return;
1879
1894
  this._renderOptions();
@@ -1932,6 +1947,8 @@ class EnhancedSelect extends HTMLElement {
1932
1947
  this._initializeObservers();
1933
1948
  }
1934
1949
  connectedCallback() {
1950
+ // register instance
1951
+ EnhancedSelect._instances.add(this);
1935
1952
  // WORKAROUND: Force display style on host element for Angular compatibility
1936
1953
  // Angular's rendering seems to not apply :host styles correctly in some cases
1937
1954
  // Must be done in connectedCallback when element is attached to DOM
@@ -1950,6 +1967,8 @@ class EnhancedSelect extends HTMLElement {
1950
1967
  }
1951
1968
  }
1952
1969
  disconnectedCallback() {
1970
+ // unregister instance
1971
+ EnhancedSelect._instances.delete(this);
1953
1972
  // Cleanup observers
1954
1973
  this._resizeObserver?.disconnect();
1955
1974
  this._intersectionObserver?.disconnect();
@@ -2235,6 +2254,8 @@ class EnhancedSelect extends HTMLElement {
2235
2254
  right: 0;
2236
2255
  bottom: 0;
2237
2256
  width: var(--select-arrow-width, 40px);
2257
+ /* allow explicit height override even though container normally stretches */
2258
+ height: var(--select-arrow-height, auto);
2238
2259
  display: flex;
2239
2260
  align-items: center;
2240
2261
  justify-content: center;
@@ -2300,9 +2321,14 @@ class EnhancedSelect extends HTMLElement {
2300
2321
  background-color: var(--select-arrow-hover-bg, rgba(102, 126, 234, 0.08));
2301
2322
  }
2302
2323
 
2324
+ .dropdown-arrow:hover {
2325
+ /* legacy alias --select-arrow-hover for icon color */
2326
+ color: var(--select-arrow-hover, var(--select-arrow-hover-color, #667eea));
2327
+ }
2328
+
2303
2329
  .dropdown-arrow {
2304
- width: var(--select-arrow-size, 16px);
2305
- height: var(--select-arrow-size, 16px);
2330
+ width: var(--select-arrow-width, var(--select-arrow-size, 16px));
2331
+ height: var(--select-arrow-height, var(--select-arrow-size, 16px));
2306
2332
  color: var(--select-arrow-color, #667eea);
2307
2333
  transition: transform 0.2s ease, color 0.2s ease;
2308
2334
  transform: translateY(0);
@@ -2411,6 +2437,20 @@ class EnhancedSelect extends HTMLElement {
2411
2437
  background: var(--select-options-bg, var(--select-dropdown-bg, var(--select-bg, white)));
2412
2438
  }
2413
2439
 
2440
+ .group-header {
2441
+ padding: var(--select-group-header-padding, 8px 12px);
2442
+ font-weight: var(--select-group-header-weight, 600);
2443
+ color: var(--select-group-header-color, #6b7280);
2444
+ background-color: var(--select-group-header-bg, #f3f4f6);
2445
+ font-size: var(--select-group-header-font-size, 12px);
2446
+ text-transform: var(--select-group-header-text-transform, uppercase);
2447
+ letter-spacing: var(--select-group-header-letter-spacing, 0.05em);
2448
+ position: sticky;
2449
+ top: 0;
2450
+ z-index: 1;
2451
+ border-bottom: var(--select-group-header-border-bottom, 1px solid #e5e7eb);
2452
+ }
2453
+
2414
2454
  .option {
2415
2455
  padding: var(--select-option-padding, 8px 12px);
2416
2456
  cursor: pointer;
@@ -2584,9 +2624,12 @@ class EnhancedSelect extends HTMLElement {
2584
2624
  }
2585
2625
  }
2586
2626
 
2587
- /* Dark mode - Opt-in via class or data attribute */
2627
+ /* Dark mode - Opt-in via class, data attribute, or ancestor context */
2588
2628
  :host(.dark-mode),
2589
- :host([data-theme="dark"]) {
2629
+ :host([data-theme="dark"]),
2630
+ :host-context(.dark-mode),
2631
+ :host-context(.dark),
2632
+ :host-context([data-theme="dark"]) {
2590
2633
  .input-container {
2591
2634
  background: var(--select-dark-bg, #1f2937);
2592
2635
  border-color: var(--select-dark-border, #4b5563);
@@ -2639,6 +2682,13 @@ class EnhancedSelect extends HTMLElement {
2639
2682
 
2640
2683
  .option.active:not(.selected) {
2641
2684
  background-color: var(--select-dark-option-active-bg, #374151);
2685
+ }
2686
+
2687
+ /* Group header in dark mode */
2688
+ .group-header {
2689
+ color: var(--select-dark-group-header-color, var(--select-group-header-color, #6b7280));
2690
+ background-color: var(--select-dark-group-header-bg, var(--select-group-header-bg, #374151));
2691
+ }
2642
2692
  color: var(--select-dark-option-active-color, #f9fafb);
2643
2693
  outline: var(--select-dark-option-active-outline, 2px solid rgba(129, 140, 248, 0.55));
2644
2694
  }
@@ -2721,19 +2771,14 @@ class EnhancedSelect extends HTMLElement {
2721
2771
  this._boundArrowClick = (e) => {
2722
2772
  e.stopPropagation();
2723
2773
  e.preventDefault();
2724
- const wasOpen = this._state.isOpen;
2725
- this._state.isOpen = !this._state.isOpen;
2726
- this._updateDropdownVisibility();
2727
- this._updateArrowRotation();
2728
- if (this._state.isOpen && this._config.callbacks.onOpen) {
2729
- this._config.callbacks.onOpen();
2730
- }
2731
- else if (!this._state.isOpen && this._config.callbacks.onClose) {
2732
- this._config.callbacks.onClose();
2774
+ // delegate to the existing open/close helpers so we don't accidentally
2775
+ // drift out of sync with the logic in those methods (focus, events,
2776
+ // scroll-to-selected, etc.)
2777
+ if (this._state.isOpen) {
2778
+ this._handleClose();
2733
2779
  }
2734
- // Scroll to selected when opening
2735
- if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
2736
- setTimeout(() => this._scrollToSelected(), 50);
2780
+ else {
2781
+ this._handleOpen();
2737
2782
  }
2738
2783
  };
2739
2784
  this._arrowContainer.addEventListener('click', this._boundArrowClick);
@@ -2750,7 +2795,12 @@ class EnhancedSelect extends HTMLElement {
2750
2795
  });
2751
2796
  }
2752
2797
  // Input container click - focus input and open dropdown
2798
+ // Prevent the original pointer event from bubbling/causing default focus behavior
2799
+ // which can interfere with option click handling when opening the dropdown
2753
2800
  this._inputContainer.addEventListener('pointerdown', (e) => {
2801
+ // Prevent propagation to document click listener but do NOT preventDefault.
2802
+ // Allow default so browser events (click) on newly opened options still fire.
2803
+ e.stopPropagation();
2754
2804
  const target = e.target;
2755
2805
  if (!this._config.enabled)
2756
2806
  return;
@@ -2758,10 +2808,27 @@ class EnhancedSelect extends HTMLElement {
2758
2808
  return;
2759
2809
  if (target && target.closest('.clear-control-button'))
2760
2810
  return;
2761
- if (!this._state.isOpen) {
2811
+ const wasClosed = !this._state.isOpen;
2812
+ if (wasClosed) {
2762
2813
  this._handleOpen();
2763
2814
  }
2815
+ else {
2816
+ // clicking the input while open should close the dropdown too
2817
+ this._handleClose();
2818
+ }
2819
+ // Focus the input (do not prevent default behavior)
2764
2820
  this._input.focus();
2821
+ // If we just opened the dropdown, transfer pointer capture to the
2822
+ // options container so the subsequent pointerup lands there instead of
2823
+ // staying with the input container (which would swallow the event).
2824
+ if (wasClosed && this._optionsContainer && typeof e.pointerId === 'number') {
2825
+ try {
2826
+ this._optionsContainer.setPointerCapture(e.pointerId);
2827
+ }
2828
+ catch (_err) {
2829
+ // Some browsers may throw if element is not yet "connected"; ignore
2830
+ }
2831
+ }
2765
2832
  });
2766
2833
  // Input container click - prevent event from reaching document listener
2767
2834
  this._container.addEventListener('click', (e) => {
@@ -2769,6 +2836,12 @@ class EnhancedSelect extends HTMLElement {
2769
2836
  });
2770
2837
  // Input focus/blur
2771
2838
  this._input.addEventListener('focus', () => this._handleOpen());
2839
+ // When the input loses focus we normally close the dropdown, but
2840
+ // clicking an option will blur the input before the option's click
2841
+ // handler executes. To avoid the blur timer closing the dropdown
2842
+ // prematurely we use a short-lived flag that is set whenever we start
2843
+ // interacting with the options container. The close callback checks this
2844
+ // flag and skips closing if the user is about to click an option.
2772
2845
  this._input.addEventListener('blur', (e) => {
2773
2846
  const related = e.relatedTarget;
2774
2847
  if (related && (this._shadow.contains(related) || this._container.contains(related))) {
@@ -2776,6 +2849,10 @@ class EnhancedSelect extends HTMLElement {
2776
2849
  }
2777
2850
  // Delay to allow option click/focus transitions
2778
2851
  setTimeout(() => {
2852
+ if (this._suppressBlurClose) {
2853
+ // another pointerdown inside options is in progress; keep open
2854
+ return;
2855
+ }
2779
2856
  const active = document.activeElement;
2780
2857
  if (active && (this._shadow.contains(active) || this._container.contains(active))) {
2781
2858
  return;
@@ -2790,11 +2867,33 @@ class EnhancedSelect extends HTMLElement {
2790
2867
  const query = e.target.value;
2791
2868
  this._handleSearch(query);
2792
2869
  });
2793
- // Delegated click listener for improved event handling (smart fallback)
2794
- this._optionsContainer.addEventListener('click', (e) => {
2795
- const target = e.target;
2796
- // Handle option clicks
2797
- const option = target.closest('[data-sm-selectable], [data-selectable], [data-sm-state]');
2870
+ // If the user presses down inside the options container we should
2871
+ // temporarily suppress blur-based closing until after the click has
2872
+ // been handled. Setting a flag that gets cleared in the next tick is
2873
+ // sufficient.
2874
+ this._optionsContainer.addEventListener('pointerdown', () => {
2875
+ this._suppressBlurClose = true;
2876
+ setTimeout(() => {
2877
+ this._suppressBlurClose = false;
2878
+ }, 0);
2879
+ });
2880
+ // Delegated click listener for improved event handling (robust across shadow DOM)
2881
+ const handleOptionEvent = (e) => {
2882
+ const path = (e.composedPath && e.composedPath()) || [e.target];
2883
+ let option = null;
2884
+ for (const node of path) {
2885
+ if (!(node instanceof Element))
2886
+ continue;
2887
+ try {
2888
+ if (node.matches('[data-sm-selectable], [data-selectable], [data-sm-state]')) {
2889
+ option = node;
2890
+ break;
2891
+ }
2892
+ }
2893
+ catch (err) {
2894
+ continue;
2895
+ }
2896
+ }
2798
2897
  if (option && !option.hasAttribute('aria-disabled')) {
2799
2898
  const indexStr = option.getAttribute('data-sm-index') ?? option.getAttribute('data-index');
2800
2899
  const index = Number(indexStr);
@@ -2805,13 +2904,45 @@ class EnhancedSelect extends HTMLElement {
2805
2904
  });
2806
2905
  }
2807
2906
  }
2808
- });
2907
+ };
2908
+ this._optionsContainer.addEventListener('click', handleOptionEvent);
2909
+ // also watch pointerup to catch cases where the pointerdown started outside
2910
+ // (e.g. on the input) and the click never fires
2911
+ this._optionsContainer.addEventListener('pointerup', handleOptionEvent);
2809
2912
  // Keyboard navigation
2810
2913
  this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
2811
- // Click outside to close
2914
+ // Click outside to close — robust detection across shadow DOM and custom renderers
2812
2915
  document.addEventListener('pointerdown', (e) => {
2813
2916
  const path = (e.composedPath && e.composedPath()) || [];
2814
- const clickedInside = path.includes(this) || path.includes(this._container) || this._shadow.contains(e.target);
2917
+ let clickedInside = false;
2918
+ for (const node of path) {
2919
+ if (node === this || node === this._container) {
2920
+ clickedInside = true;
2921
+ break;
2922
+ }
2923
+ if (node instanceof Node) {
2924
+ try {
2925
+ if (this._shadow && this._shadow.contains(node)) {
2926
+ clickedInside = true;
2927
+ break;
2928
+ }
2929
+ }
2930
+ catch (err) {
2931
+ // ignore
2932
+ }
2933
+ }
2934
+ if (node instanceof Element) {
2935
+ try {
2936
+ if (node.matches('[data-sm-selectable], [data-selectable], [data-sm-state], .input-container, .select-container, .dropdown-arrow-container, .clear-control-button')) {
2937
+ clickedInside = true;
2938
+ break;
2939
+ }
2940
+ }
2941
+ catch (err) {
2942
+ // ignore
2943
+ }
2944
+ }
2945
+ }
2815
2946
  if (!clickedInside) {
2816
2947
  this._handleClose();
2817
2948
  }
@@ -2860,6 +2991,16 @@ class EnhancedSelect extends HTMLElement {
2860
2991
  _handleOpen() {
2861
2992
  if (!this._config.enabled || this._state.isOpen)
2862
2993
  return;
2994
+ // close any other open selects before proceeding
2995
+ EnhancedSelect._instances.forEach(inst => {
2996
+ if (inst !== this)
2997
+ inst._handleClose();
2998
+ });
2999
+ // Always focus the input when opening so callers (arrow click,
3000
+ // programmatic `open()`, etc.) get the keyboard cursor. This was a
3001
+ // frequent source of confusion in #14 where people opened the dropdown
3002
+ // but the text field never received focus.
3003
+ this._input.focus();
2863
3004
  this._markOpenStart();
2864
3005
  this._state.isOpen = true;
2865
3006
  this._dropdown.style.display = 'block';
@@ -3275,13 +3416,14 @@ class EnhancedSelect extends HTMLElement {
3275
3416
  // FIX: Do not rely on this._optionsContainer.children[index] because filtering changes the children
3276
3417
  // Instead, use the index to update state directly
3277
3418
  const item = this._state.loadedItems[index];
3419
+ // Debug: log selection attempt
3278
3420
  if (!item)
3279
3421
  return;
3422
+ const isCurrentlySelected = this._state.selectedIndices.has(index);
3280
3423
  // Keep active/focus styling aligned with the most recently interacted option.
3281
3424
  // Without this, a previously selected item may retain active classes/styles
3282
3425
  // after selecting a different option.
3283
3426
  this._state.activeIndex = index;
3284
- const isCurrentlySelected = this._state.selectedIndices.has(index);
3285
3427
  if (this._config.selection.mode === 'single') {
3286
3428
  // Single select: clear previous and select new
3287
3429
  const wasSelected = this._state.selectedIndices.has(index);
@@ -3512,6 +3654,7 @@ class EnhancedSelect extends HTMLElement {
3512
3654
  const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3513
3655
  const selectedValues = selectedItems.map(getValue);
3514
3656
  const selectedIndices = Array.from(this._state.selectedIndices);
3657
+ // Debug: log change payload
3515
3658
  this._emit('change', { selectedItems, selectedValues, selectedIndices });
3516
3659
  this._config.callbacks.onChange?.(selectedItems, selectedValues);
3517
3660
  }
@@ -3831,23 +3974,25 @@ class EnhancedSelect extends HTMLElement {
3831
3974
  const query = this._state.searchQuery.toLowerCase();
3832
3975
  // Handle Grouped Items Rendering (when no search query)
3833
3976
  if (this._state.groupedItems.length > 0 && !query) {
3834
- this._state.groupedItems.forEach(group => {
3835
- const header = document.createElement('div');
3836
- header.className = 'group-header';
3837
- header.textContent = group.label;
3838
- Object.assign(header.style, {
3839
- padding: '8px 12px',
3840
- fontWeight: '600',
3841
- color: '#6b7280',
3842
- backgroundColor: '#f3f4f6',
3843
- fontSize: '12px',
3844
- textTransform: 'uppercase',
3845
- letterSpacing: '0.05em',
3846
- position: 'sticky',
3847
- top: '0',
3848
- zIndex: '1',
3849
- borderBottom: '1px solid #e5e7eb'
3850
- });
3977
+ this._state.groupedItems.forEach((group, groupIndex) => {
3978
+ let header;
3979
+ if (this.groupHeaderRenderer) {
3980
+ header = this.groupHeaderRenderer(group, groupIndex);
3981
+ // make sure the returned element has the correct semantics so
3982
+ // people can style it. we add the class/part even if the renderer
3983
+ // returned something else to ensure backward compatibility.
3984
+ if (!(header instanceof HTMLElement)) {
3985
+ // fall back to default if API is misused
3986
+ header = document.createElement('div');
3987
+ header.textContent = String(group.label);
3988
+ }
3989
+ }
3990
+ else {
3991
+ header = document.createElement('div');
3992
+ header.textContent = group.label;
3993
+ }
3994
+ header.classList.add('group-header');
3995
+ header.setAttribute('part', 'group-header');
3851
3996
  this._optionsContainer.appendChild(header);
3852
3997
  group.options.forEach(item => {
3853
3998
  // Find original index for correct ID generation and selection
@@ -4120,6 +4265,8 @@ class EnhancedSelect extends HTMLElement {
4120
4265
  }
4121
4266
  }
4122
4267
  }
4268
+ /** live set of all connected instances; used to auto-close siblings */
4269
+ EnhancedSelect._instances = new Set();
4123
4270
  // Register custom element
4124
4271
  if (!customElements.get('enhanced-select')) {
4125
4272
  customElements.define('enhanced-select', EnhancedSelect);