@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.umd.js CHANGED
@@ -1879,7 +1879,22 @@
1879
1879
  }
1880
1880
  set classMap(map) {
1881
1881
  this._classMap = map;
1882
- this._setGlobalStylesMirroring(Boolean(this._optionRenderer || map));
1882
+ this._setGlobalStylesMirroring(Boolean(this._optionRenderer || map || this._groupHeaderRenderer));
1883
+ if (!this.isConnected)
1884
+ return;
1885
+ this._renderOptions();
1886
+ }
1887
+ /**
1888
+ * DOM-based renderer for group headers. When provided, the component will
1889
+ * call this function for each group during rendering. The returned element
1890
+ * will receive `.group-header` and `part="group-header"` automatically.
1891
+ */
1892
+ get groupHeaderRenderer() {
1893
+ return this._groupHeaderRenderer;
1894
+ }
1895
+ set groupHeaderRenderer(renderer) {
1896
+ this._groupHeaderRenderer = renderer;
1897
+ this._setGlobalStylesMirroring(Boolean(this._optionRenderer || this._classMap || renderer));
1883
1898
  if (!this.isConnected)
1884
1899
  return;
1885
1900
  this._renderOptions();
@@ -1938,6 +1953,8 @@
1938
1953
  this._initializeObservers();
1939
1954
  }
1940
1955
  connectedCallback() {
1956
+ // register instance
1957
+ EnhancedSelect._instances.add(this);
1941
1958
  // WORKAROUND: Force display style on host element for Angular compatibility
1942
1959
  // Angular's rendering seems to not apply :host styles correctly in some cases
1943
1960
  // Must be done in connectedCallback when element is attached to DOM
@@ -1956,6 +1973,8 @@
1956
1973
  }
1957
1974
  }
1958
1975
  disconnectedCallback() {
1976
+ // unregister instance
1977
+ EnhancedSelect._instances.delete(this);
1959
1978
  // Cleanup observers
1960
1979
  this._resizeObserver?.disconnect();
1961
1980
  this._intersectionObserver?.disconnect();
@@ -2241,6 +2260,8 @@
2241
2260
  right: 0;
2242
2261
  bottom: 0;
2243
2262
  width: var(--select-arrow-width, 40px);
2263
+ /* allow explicit height override even though container normally stretches */
2264
+ height: var(--select-arrow-height, auto);
2244
2265
  display: flex;
2245
2266
  align-items: center;
2246
2267
  justify-content: center;
@@ -2306,9 +2327,14 @@
2306
2327
  background-color: var(--select-arrow-hover-bg, rgba(102, 126, 234, 0.08));
2307
2328
  }
2308
2329
 
2330
+ .dropdown-arrow:hover {
2331
+ /* legacy alias --select-arrow-hover for icon color */
2332
+ color: var(--select-arrow-hover, var(--select-arrow-hover-color, #667eea));
2333
+ }
2334
+
2309
2335
  .dropdown-arrow {
2310
- width: var(--select-arrow-size, 16px);
2311
- height: var(--select-arrow-size, 16px);
2336
+ width: var(--select-arrow-width, var(--select-arrow-size, 16px));
2337
+ height: var(--select-arrow-height, var(--select-arrow-size, 16px));
2312
2338
  color: var(--select-arrow-color, #667eea);
2313
2339
  transition: transform 0.2s ease, color 0.2s ease;
2314
2340
  transform: translateY(0);
@@ -2417,6 +2443,20 @@
2417
2443
  background: var(--select-options-bg, var(--select-dropdown-bg, var(--select-bg, white)));
2418
2444
  }
2419
2445
 
2446
+ .group-header {
2447
+ padding: var(--select-group-header-padding, 8px 12px);
2448
+ font-weight: var(--select-group-header-weight, 600);
2449
+ color: var(--select-group-header-color, #6b7280);
2450
+ background-color: var(--select-group-header-bg, #f3f4f6);
2451
+ font-size: var(--select-group-header-font-size, 12px);
2452
+ text-transform: var(--select-group-header-text-transform, uppercase);
2453
+ letter-spacing: var(--select-group-header-letter-spacing, 0.05em);
2454
+ position: sticky;
2455
+ top: 0;
2456
+ z-index: 1;
2457
+ border-bottom: var(--select-group-header-border-bottom, 1px solid #e5e7eb);
2458
+ }
2459
+
2420
2460
  .option {
2421
2461
  padding: var(--select-option-padding, 8px 12px);
2422
2462
  cursor: pointer;
@@ -2590,9 +2630,12 @@
2590
2630
  }
2591
2631
  }
2592
2632
 
2593
- /* Dark mode - Opt-in via class or data attribute */
2633
+ /* Dark mode - Opt-in via class, data attribute, or ancestor context */
2594
2634
  :host(.dark-mode),
2595
- :host([data-theme="dark"]) {
2635
+ :host([data-theme="dark"]),
2636
+ :host-context(.dark-mode),
2637
+ :host-context(.dark),
2638
+ :host-context([data-theme="dark"]) {
2596
2639
  .input-container {
2597
2640
  background: var(--select-dark-bg, #1f2937);
2598
2641
  border-color: var(--select-dark-border, #4b5563);
@@ -2645,6 +2688,13 @@
2645
2688
 
2646
2689
  .option.active:not(.selected) {
2647
2690
  background-color: var(--select-dark-option-active-bg, #374151);
2691
+ }
2692
+
2693
+ /* Group header in dark mode */
2694
+ .group-header {
2695
+ color: var(--select-dark-group-header-color, var(--select-group-header-color, #6b7280));
2696
+ background-color: var(--select-dark-group-header-bg, var(--select-group-header-bg, #374151));
2697
+ }
2648
2698
  color: var(--select-dark-option-active-color, #f9fafb);
2649
2699
  outline: var(--select-dark-option-active-outline, 2px solid rgba(129, 140, 248, 0.55));
2650
2700
  }
@@ -2727,19 +2777,14 @@
2727
2777
  this._boundArrowClick = (e) => {
2728
2778
  e.stopPropagation();
2729
2779
  e.preventDefault();
2730
- const wasOpen = this._state.isOpen;
2731
- this._state.isOpen = !this._state.isOpen;
2732
- this._updateDropdownVisibility();
2733
- this._updateArrowRotation();
2734
- if (this._state.isOpen && this._config.callbacks.onOpen) {
2735
- this._config.callbacks.onOpen();
2736
- }
2737
- else if (!this._state.isOpen && this._config.callbacks.onClose) {
2738
- this._config.callbacks.onClose();
2780
+ // delegate to the existing open/close helpers so we don't accidentally
2781
+ // drift out of sync with the logic in those methods (focus, events,
2782
+ // scroll-to-selected, etc.)
2783
+ if (this._state.isOpen) {
2784
+ this._handleClose();
2739
2785
  }
2740
- // Scroll to selected when opening
2741
- if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
2742
- setTimeout(() => this._scrollToSelected(), 50);
2786
+ else {
2787
+ this._handleOpen();
2743
2788
  }
2744
2789
  };
2745
2790
  this._arrowContainer.addEventListener('click', this._boundArrowClick);
@@ -2756,7 +2801,12 @@
2756
2801
  });
2757
2802
  }
2758
2803
  // Input container click - focus input and open dropdown
2804
+ // Prevent the original pointer event from bubbling/causing default focus behavior
2805
+ // which can interfere with option click handling when opening the dropdown
2759
2806
  this._inputContainer.addEventListener('pointerdown', (e) => {
2807
+ // Prevent propagation to document click listener but do NOT preventDefault.
2808
+ // Allow default so browser events (click) on newly opened options still fire.
2809
+ e.stopPropagation();
2760
2810
  const target = e.target;
2761
2811
  if (!this._config.enabled)
2762
2812
  return;
@@ -2764,10 +2814,27 @@
2764
2814
  return;
2765
2815
  if (target && target.closest('.clear-control-button'))
2766
2816
  return;
2767
- if (!this._state.isOpen) {
2817
+ const wasClosed = !this._state.isOpen;
2818
+ if (wasClosed) {
2768
2819
  this._handleOpen();
2769
2820
  }
2821
+ else {
2822
+ // clicking the input while open should close the dropdown too
2823
+ this._handleClose();
2824
+ }
2825
+ // Focus the input (do not prevent default behavior)
2770
2826
  this._input.focus();
2827
+ // If we just opened the dropdown, transfer pointer capture to the
2828
+ // options container so the subsequent pointerup lands there instead of
2829
+ // staying with the input container (which would swallow the event).
2830
+ if (wasClosed && this._optionsContainer && typeof e.pointerId === 'number') {
2831
+ try {
2832
+ this._optionsContainer.setPointerCapture(e.pointerId);
2833
+ }
2834
+ catch (_err) {
2835
+ // Some browsers may throw if element is not yet "connected"; ignore
2836
+ }
2837
+ }
2771
2838
  });
2772
2839
  // Input container click - prevent event from reaching document listener
2773
2840
  this._container.addEventListener('click', (e) => {
@@ -2775,6 +2842,12 @@
2775
2842
  });
2776
2843
  // Input focus/blur
2777
2844
  this._input.addEventListener('focus', () => this._handleOpen());
2845
+ // When the input loses focus we normally close the dropdown, but
2846
+ // clicking an option will blur the input before the option's click
2847
+ // handler executes. To avoid the blur timer closing the dropdown
2848
+ // prematurely we use a short-lived flag that is set whenever we start
2849
+ // interacting with the options container. The close callback checks this
2850
+ // flag and skips closing if the user is about to click an option.
2778
2851
  this._input.addEventListener('blur', (e) => {
2779
2852
  const related = e.relatedTarget;
2780
2853
  if (related && (this._shadow.contains(related) || this._container.contains(related))) {
@@ -2782,6 +2855,10 @@
2782
2855
  }
2783
2856
  // Delay to allow option click/focus transitions
2784
2857
  setTimeout(() => {
2858
+ if (this._suppressBlurClose) {
2859
+ // another pointerdown inside options is in progress; keep open
2860
+ return;
2861
+ }
2785
2862
  const active = document.activeElement;
2786
2863
  if (active && (this._shadow.contains(active) || this._container.contains(active))) {
2787
2864
  return;
@@ -2796,11 +2873,33 @@
2796
2873
  const query = e.target.value;
2797
2874
  this._handleSearch(query);
2798
2875
  });
2799
- // Delegated click listener for improved event handling (smart fallback)
2800
- this._optionsContainer.addEventListener('click', (e) => {
2801
- const target = e.target;
2802
- // Handle option clicks
2803
- const option = target.closest('[data-sm-selectable], [data-selectable], [data-sm-state]');
2876
+ // If the user presses down inside the options container we should
2877
+ // temporarily suppress blur-based closing until after the click has
2878
+ // been handled. Setting a flag that gets cleared in the next tick is
2879
+ // sufficient.
2880
+ this._optionsContainer.addEventListener('pointerdown', () => {
2881
+ this._suppressBlurClose = true;
2882
+ setTimeout(() => {
2883
+ this._suppressBlurClose = false;
2884
+ }, 0);
2885
+ });
2886
+ // Delegated click listener for improved event handling (robust across shadow DOM)
2887
+ const handleOptionEvent = (e) => {
2888
+ const path = (e.composedPath && e.composedPath()) || [e.target];
2889
+ let option = null;
2890
+ for (const node of path) {
2891
+ if (!(node instanceof Element))
2892
+ continue;
2893
+ try {
2894
+ if (node.matches('[data-sm-selectable], [data-selectable], [data-sm-state]')) {
2895
+ option = node;
2896
+ break;
2897
+ }
2898
+ }
2899
+ catch (err) {
2900
+ continue;
2901
+ }
2902
+ }
2804
2903
  if (option && !option.hasAttribute('aria-disabled')) {
2805
2904
  const indexStr = option.getAttribute('data-sm-index') ?? option.getAttribute('data-index');
2806
2905
  const index = Number(indexStr);
@@ -2811,13 +2910,45 @@
2811
2910
  });
2812
2911
  }
2813
2912
  }
2814
- });
2913
+ };
2914
+ this._optionsContainer.addEventListener('click', handleOptionEvent);
2915
+ // also watch pointerup to catch cases where the pointerdown started outside
2916
+ // (e.g. on the input) and the click never fires
2917
+ this._optionsContainer.addEventListener('pointerup', handleOptionEvent);
2815
2918
  // Keyboard navigation
2816
2919
  this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
2817
- // Click outside to close
2920
+ // Click outside to close — robust detection across shadow DOM and custom renderers
2818
2921
  document.addEventListener('pointerdown', (e) => {
2819
2922
  const path = (e.composedPath && e.composedPath()) || [];
2820
- const clickedInside = path.includes(this) || path.includes(this._container) || this._shadow.contains(e.target);
2923
+ let clickedInside = false;
2924
+ for (const node of path) {
2925
+ if (node === this || node === this._container) {
2926
+ clickedInside = true;
2927
+ break;
2928
+ }
2929
+ if (node instanceof Node) {
2930
+ try {
2931
+ if (this._shadow && this._shadow.contains(node)) {
2932
+ clickedInside = true;
2933
+ break;
2934
+ }
2935
+ }
2936
+ catch (err) {
2937
+ // ignore
2938
+ }
2939
+ }
2940
+ if (node instanceof Element) {
2941
+ try {
2942
+ if (node.matches('[data-sm-selectable], [data-selectable], [data-sm-state], .input-container, .select-container, .dropdown-arrow-container, .clear-control-button')) {
2943
+ clickedInside = true;
2944
+ break;
2945
+ }
2946
+ }
2947
+ catch (err) {
2948
+ // ignore
2949
+ }
2950
+ }
2951
+ }
2821
2952
  if (!clickedInside) {
2822
2953
  this._handleClose();
2823
2954
  }
@@ -2866,6 +2997,16 @@
2866
2997
  _handleOpen() {
2867
2998
  if (!this._config.enabled || this._state.isOpen)
2868
2999
  return;
3000
+ // close any other open selects before proceeding
3001
+ EnhancedSelect._instances.forEach(inst => {
3002
+ if (inst !== this)
3003
+ inst._handleClose();
3004
+ });
3005
+ // Always focus the input when opening so callers (arrow click,
3006
+ // programmatic `open()`, etc.) get the keyboard cursor. This was a
3007
+ // frequent source of confusion in #14 where people opened the dropdown
3008
+ // but the text field never received focus.
3009
+ this._input.focus();
2869
3010
  this._markOpenStart();
2870
3011
  this._state.isOpen = true;
2871
3012
  this._dropdown.style.display = 'block';
@@ -3281,13 +3422,14 @@
3281
3422
  // FIX: Do not rely on this._optionsContainer.children[index] because filtering changes the children
3282
3423
  // Instead, use the index to update state directly
3283
3424
  const item = this._state.loadedItems[index];
3425
+ // Debug: log selection attempt
3284
3426
  if (!item)
3285
3427
  return;
3428
+ const isCurrentlySelected = this._state.selectedIndices.has(index);
3286
3429
  // Keep active/focus styling aligned with the most recently interacted option.
3287
3430
  // Without this, a previously selected item may retain active classes/styles
3288
3431
  // after selecting a different option.
3289
3432
  this._state.activeIndex = index;
3290
- const isCurrentlySelected = this._state.selectedIndices.has(index);
3291
3433
  if (this._config.selection.mode === 'single') {
3292
3434
  // Single select: clear previous and select new
3293
3435
  const wasSelected = this._state.selectedIndices.has(index);
@@ -3518,6 +3660,7 @@
3518
3660
  const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
3519
3661
  const selectedValues = selectedItems.map(getValue);
3520
3662
  const selectedIndices = Array.from(this._state.selectedIndices);
3663
+ // Debug: log change payload
3521
3664
  this._emit('change', { selectedItems, selectedValues, selectedIndices });
3522
3665
  this._config.callbacks.onChange?.(selectedItems, selectedValues);
3523
3666
  }
@@ -3837,23 +3980,25 @@
3837
3980
  const query = this._state.searchQuery.toLowerCase();
3838
3981
  // Handle Grouped Items Rendering (when no search query)
3839
3982
  if (this._state.groupedItems.length > 0 && !query) {
3840
- this._state.groupedItems.forEach(group => {
3841
- const header = document.createElement('div');
3842
- header.className = 'group-header';
3843
- header.textContent = group.label;
3844
- Object.assign(header.style, {
3845
- padding: '8px 12px',
3846
- fontWeight: '600',
3847
- color: '#6b7280',
3848
- backgroundColor: '#f3f4f6',
3849
- fontSize: '12px',
3850
- textTransform: 'uppercase',
3851
- letterSpacing: '0.05em',
3852
- position: 'sticky',
3853
- top: '0',
3854
- zIndex: '1',
3855
- borderBottom: '1px solid #e5e7eb'
3856
- });
3983
+ this._state.groupedItems.forEach((group, groupIndex) => {
3984
+ let header;
3985
+ if (this.groupHeaderRenderer) {
3986
+ header = this.groupHeaderRenderer(group, groupIndex);
3987
+ // make sure the returned element has the correct semantics so
3988
+ // people can style it. we add the class/part even if the renderer
3989
+ // returned something else to ensure backward compatibility.
3990
+ if (!(header instanceof HTMLElement)) {
3991
+ // fall back to default if API is misused
3992
+ header = document.createElement('div');
3993
+ header.textContent = String(group.label);
3994
+ }
3995
+ }
3996
+ else {
3997
+ header = document.createElement('div');
3998
+ header.textContent = group.label;
3999
+ }
4000
+ header.classList.add('group-header');
4001
+ header.setAttribute('part', 'group-header');
3857
4002
  this._optionsContainer.appendChild(header);
3858
4003
  group.options.forEach(item => {
3859
4004
  // Find original index for correct ID generation and selection
@@ -4126,6 +4271,8 @@
4126
4271
  }
4127
4272
  }
4128
4273
  }
4274
+ /** live set of all connected instances; used to auto-close siblings */
4275
+ EnhancedSelect._instances = new Set();
4129
4276
  // Register custom element
4130
4277
  if (!customElements.get('enhanced-select')) {
4131
4278
  customElements.define('enhanced-select', EnhancedSelect);