@api-client/ui 0.5.52 → 0.5.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@api-client/ui",
3
- "version": "0.5.52",
3
+ "version": "0.5.54",
4
4
  "description": "Internal UI component library for the API Client ecosystem.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "build/src/index.js",
@@ -9,7 +9,6 @@ export default css`
9
9
  margin: 0;
10
10
  padding: 0;
11
11
  border: none;
12
- overflow: hidden;
13
12
  /* in most cases the max-height won't matter as this assumes the whole screen to be available, which is rarely the truth. */
14
13
  max-height: 90vh;
15
14
  overflow: auto;
@@ -27,6 +26,11 @@ export default css`
27
26
  position-area: top span-left;
28
27
  }
29
28
 
29
+ /* Special class set on the element to render the menu to take measurements */
30
+ :host(.measurements) {
31
+ display: block;
32
+ }
33
+
30
34
  :host(:popover-open) {
31
35
  display: block;
32
36
  background-color: var(--md-sys-color-surface-container);
@@ -74,12 +74,14 @@ export default class Menu extends UiList {
74
74
  }
75
75
 
76
76
  override togglePopover(force?: boolean): boolean {
77
+ if (!this.open && !this.disabled) {
78
+ this.positionMenu()
79
+ }
77
80
  this.open = !this.open
78
81
  this.ariaExpanded = String(this.open)
79
82
  this.tabIndex = this.open ? 0 : -1
80
83
  const result = super.togglePopover(force)
81
84
  if (this.open) {
82
- this.positionMenu()
83
85
  this.focus()
84
86
  }
85
87
  return result
@@ -106,9 +108,9 @@ export default class Menu extends UiList {
106
108
  override showPopover(): void {
107
109
  this.tabIndex = 0 // Make menu focusable
108
110
  this.ariaExpanded = 'true'
111
+ this.positionMenu()
109
112
  super.showPopover()
110
113
  this.open = true
111
- this.positionMenu()
112
114
  this.focus()
113
115
  this.dispatchEvent(new CustomEvent('open', { bubbles: false, composed: true }))
114
116
  }
@@ -126,15 +128,20 @@ export default class Menu extends UiList {
126
128
  }
127
129
 
128
130
  positionMenu(): void {
129
- // Let CSS anchor positioning handle the positioning automatically
130
- // Only intervene if we need to set max-height for overflow cases
131
- const box = this.getBoundingClientRect()
131
+ // for the frame, make the element visible (without animations)
132
+ // to take measurements correctly.
133
+ this.classList.add('measurements')
132
134
 
133
135
  // Reset any previous manual positioning to let CSS anchor positioning work
134
136
  this.style.removeProperty('position-area')
135
137
  this.style.removeProperty('max-height')
136
138
  this.style.removeProperty('max-width')
137
139
 
140
+ // Let CSS anchor positioning handle the positioning automatically
141
+ // Only intervene if we need to set max-height for overflow cases
142
+ const box = this.getBoundingClientRect()
143
+ this.classList.remove('measurements')
144
+
138
145
  // Check if the menu content is being clipped
139
146
  const isVerticallyClipped = this.scrollHeight > this.clientHeight
140
147
  const isHorizontallyClipped = this.scrollWidth > this.clientWidth
@@ -148,6 +155,11 @@ export default class Menu extends UiList {
148
155
  const viewportMiddle = innerHeight / 2
149
156
  const isMenuInUpperHalf = box.top < viewportMiddle
150
157
 
158
+ // console.log(`Menu positioned at: top=${box.top}, left=${box.left}, bottom=${menuBottom}, right=${menuRight}`)
159
+ // console.log(
160
+ // eslint-disable-next-line max-len
161
+ // `Menu is in upper half: ${isMenuInUpperHalf}, Vertically clipped: ${isVerticallyClipped}, Horizontally clipped: ${isHorizontallyClipped}`
162
+ // )
151
163
  // Add CSS class to control animation direction
152
164
  if (isMenuInUpperHalf) {
153
165
  this.classList.add('menu-positioned-above')
@@ -156,7 +168,6 @@ export default class Menu extends UiList {
156
168
  this.classList.add('menu-positioned-below')
157
169
  this.classList.remove('menu-positioned-above')
158
170
  }
159
-
160
171
  // Only set max-height if the menu would overflow the viewport OR is already clipped
161
172
  if (menuBottom > innerHeight || isVerticallyClipped) {
162
173
  let availableHeight: number
@@ -3,6 +3,27 @@ import { css } from 'lit'
3
3
  export default css`
4
4
  :host {
5
5
  position-area: span-bottom right;
6
- position-try-fallbacks: flip-block flip-inline;
6
+ position-try:
7
+ --submenu-fallback-left,
8
+ --submenu-fallback-top-right,
9
+ --submenu-fallback-top-left,
10
+ --submenu-fallback-bottom-left,
11
+ flip-block flip-inline;
12
+ }
13
+
14
+ @position-try --submenu-fallback-left {
15
+ position-area: span-bottom left;
16
+ }
17
+
18
+ @position-try --submenu-fallback-top-right {
19
+ position-area: span-top right;
20
+ }
21
+
22
+ @position-try --submenu-fallback-top-left {
23
+ position-area: span-top left;
24
+ }
25
+
26
+ @position-try --submenu-fallback-bottom-left {
27
+ position-area: span-bottom left;
7
28
  }
8
29
  `
@@ -1,8 +1,9 @@
1
- import { html, LitElement, PropertyValues, TemplateResult } from 'lit'
1
+ import { html, PropertyValues, TemplateResult } from 'lit'
2
2
  import { property, query, state } from 'lit/decorators.js'
3
3
  import { classMap } from 'lit/directives/class-map.js'
4
4
  import { styleMap } from 'lit/directives/style-map.js'
5
5
  import { setDisabled } from '../../../lib/disabled.js'
6
+ import { UiElement } from '../../UiElement.js'
6
7
  import type UiOption from './Option.js'
7
8
  import type { UiMenuElement } from '../../menu/ui-menu.js'
8
9
  import { nanoid } from 'nanoid'
@@ -25,7 +26,7 @@ export interface UiSelectChangeEvent {
25
26
  * @fires open - Dispatched when the dropdown opens
26
27
  * @fires close - Dispatched when the dropdown closes
27
28
  */
28
- export default class UiSelect extends LitElement {
29
+ export default class UiSelect extends UiElement {
29
30
  static readonly formAssociated = true
30
31
  #internals = this.attachInternals()
31
32
 
@@ -35,6 +36,21 @@ export default class UiSelect extends LitElement {
35
36
  */
36
37
  #value: string | undefined
37
38
 
39
+ /**
40
+ * Type-ahead search string accumulated from user typing
41
+ */
42
+ #typeAheadString = ''
43
+
44
+ /**
45
+ * Timer for resetting the type-ahead search string
46
+ */
47
+ #typeAheadTimer: number | null = null
48
+
49
+ /**
50
+ * Timeout duration for type-ahead reset (in milliseconds)
51
+ */
52
+ static readonly TYPE_AHEAD_TIMEOUT = 1000
53
+
38
54
  /**
39
55
  * The currently selected value. Corresponds to the `value` attribute of the selected `ui-option`.
40
56
  * When set programmatically, it will update the selected option if a matching option exists.
@@ -167,7 +183,7 @@ export default class UiSelect extends LitElement {
167
183
  @property({ type: Boolean, reflect: true }) accessor open = false
168
184
 
169
185
  @state() accessor selectedOption: UiOption | null = null
170
- @state() accessor ariaActiveDescendant: string | undefined
186
+
171
187
  @query('.menu') accessor menu!: UiMenuElement
172
188
 
173
189
  /**
@@ -281,6 +297,15 @@ export default class UiSelect extends LitElement {
281
297
  }
282
298
  }
283
299
 
300
+ override disconnectedCallback(): void {
301
+ super.disconnectedCallback()
302
+ // Clean up the type-ahead timer
303
+ if (this.#typeAheadTimer) {
304
+ clearTimeout(this.#typeAheadTimer)
305
+ this.#typeAheadTimer = null
306
+ }
307
+ }
308
+
284
309
  /**
285
310
  * Resets the select to its initial state. Called automatically when the parent
286
311
  * form is reset. Part of the form-associated custom element API.
@@ -381,6 +406,13 @@ export default class UiSelect extends LitElement {
381
406
 
382
407
  protected handleKeydown(e: KeyboardEvent): void {
383
408
  if (this.disabled || e.defaultPrevented) return
409
+
410
+ // Handle type-ahead for printable characters
411
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
412
+ this.handleTypeAhead(e.key.toLowerCase())
413
+ return
414
+ }
415
+
384
416
  if (this.open) {
385
417
  switch (e.key) {
386
418
  case 'Tab': {
@@ -400,27 +432,29 @@ export default class UiSelect extends LitElement {
400
432
  }
401
433
  case 'ArrowDown':
402
434
  e.preventDefault()
403
- this.menu.highlightNext()
435
+ this.focusNextMenuItem()
404
436
  return
405
437
  case 'ArrowUp':
406
438
  e.preventDefault()
407
- this.menu.highlightPrevious()
439
+ this.focusPreviousMenuItem()
408
440
  return
409
441
  case 'Home':
410
442
  e.preventDefault()
411
- this.menu.highlightFirst()
443
+ this.focusFirstMenuItem()
412
444
  return
413
445
  case 'End':
414
446
  e.preventDefault()
415
- this.menu.highlightLast()
447
+ this.focusLastMenuItem()
416
448
  return
417
449
  case 'Enter':
418
- case ' ':
419
- if (this.menu.highlightListItem) {
450
+ case ' ': {
451
+ const currentItem = this.querySelector<UiOption>(':focus')
452
+ if (currentItem && this.isOptionSelectable(currentItem)) {
420
453
  e.preventDefault()
421
- this.menu.notifySelect(this.menu.highlightListItem as UiOption)
454
+ this.menu.notifySelect(currentItem)
422
455
  }
423
456
  return
457
+ }
424
458
  }
425
459
  } else {
426
460
  switch (e.key) {
@@ -460,7 +494,7 @@ export default class UiSelect extends LitElement {
460
494
  this.open = false
461
495
  }
462
496
 
463
- protected handleClick(e: Event): void {
497
+ override handleClick(e: Event): void {
464
498
  if (this.disabled || e.defaultPrevented) return
465
499
  e.preventDefault()
466
500
  e.stopPropagation()
@@ -481,23 +515,246 @@ export default class UiSelect extends LitElement {
481
515
  this.setAttribute('aria-expanded', String(this.open))
482
516
  if (this.open) {
483
517
  menu.showPopover()
484
- // menu.focus()
485
- if (this.selectedOption) {
486
- this.menu.highlightItem(this.selectedOption)
518
+ // Focus on the selected option or first selectable option when menu opens
519
+ if (this.selectedOption && this.isOptionSelectable(this.selectedOption)) {
520
+ this.selectedOption.focus()
521
+ this.selectedOption.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' })
487
522
  } else {
488
- this.menu.highlightFirst()
523
+ const firstSelectableOption = this.getFirstSelectableOption()
524
+ if (firstSelectableOption) {
525
+ firstSelectableOption.focus()
526
+ firstSelectableOption.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' })
527
+ }
489
528
  }
490
529
  this.dispatchEvent(new CustomEvent('open', { bubbles: false, composed: true }))
491
- this.focus()
492
530
  } else {
493
531
  menu.hidePopover()
494
532
  this.dispatchEvent(new CustomEvent('close', { bubbles: false, composed: true }))
533
+ // Return focus to the select element when menu closes
534
+ this.focus()
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Focus the next menu item in the dropdown, skipping disabled options
540
+ */
541
+ focusNextMenuItem(): void {
542
+ const currentItem = this.querySelector<UiOption>(':focus')
543
+ const nextItem = currentItem ? this.getNextSelectableOption(currentItem) : this.getFirstSelectableOption()
544
+ if (nextItem) {
545
+ nextItem.focus()
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Focus the previous menu item in the dropdown, skipping disabled options
551
+ */
552
+ focusPreviousMenuItem(): void {
553
+ const currentItem = this.querySelector<UiOption>(':focus')
554
+ const previousItem = currentItem ? this.getPreviousSelectableOption(currentItem) : this.getLastSelectableOption()
555
+ if (previousItem) {
556
+ previousItem.focus()
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Focus the first menu item in the dropdown, skipping disabled options
562
+ */
563
+ focusFirstMenuItem(): void {
564
+ const firstItem = this.getFirstSelectableOption()
565
+ if (firstItem) {
566
+ firstItem.focus()
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Focus the last menu item in the dropdown, skipping disabled options
572
+ */
573
+ focusLastMenuItem(): void {
574
+ const lastItem = this.getLastSelectableOption()
575
+ if (lastItem) {
576
+ lastItem.focus()
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Gets the first selectable (non-disabled) option
582
+ */
583
+ getFirstSelectableOption(): UiOption | null {
584
+ const options = this.querySelectorAll<UiOption>('ui-option')
585
+ for (const option of options) {
586
+ if (this.isOptionSelectable(option)) {
587
+ return option
588
+ }
589
+ }
590
+ return null
591
+ }
592
+
593
+ /**
594
+ * Gets the last selectable (non-disabled) option
595
+ */
596
+ getLastSelectableOption(): UiOption | null {
597
+ const options = Array.from(this.querySelectorAll<UiOption>('ui-option')).reverse()
598
+ for (const option of options) {
599
+ if (this.isOptionSelectable(option)) {
600
+ return option
601
+ }
602
+ }
603
+ return null
604
+ }
605
+
606
+ /**
607
+ * Gets the next selectable option after the current one, wrapping around if needed
608
+ */
609
+ protected getNextSelectableOption(currentOption: UiOption): UiOption | null {
610
+ const options = Array.from(this.querySelectorAll<UiOption>('ui-option'))
611
+ const currentIndex = options.indexOf(currentOption)
612
+
613
+ if (currentIndex === -1) {
614
+ return this.getFirstSelectableOption()
615
+ }
616
+
617
+ // Start from the next option and wrap around
618
+ for (let i = 1; i < options.length; i++) {
619
+ const nextIndex = (currentIndex + i) % options.length
620
+ const option = options[nextIndex]
621
+ if (this.isOptionSelectable(option)) {
622
+ return option
623
+ }
624
+ }
625
+
626
+ return currentOption // Return current if no other selectable option found
627
+ }
628
+
629
+ /**
630
+ * Gets the previous selectable option before the current one, wrapping around if needed
631
+ */
632
+ protected getPreviousSelectableOption(currentOption: UiOption): UiOption | null {
633
+ const options = Array.from(this.querySelectorAll<UiOption>('ui-option'))
634
+ const currentIndex = options.indexOf(currentOption)
635
+
636
+ if (currentIndex === -1) {
637
+ return this.getLastSelectableOption()
638
+ }
639
+
640
+ // Start from the previous option and wrap around
641
+ for (let i = 1; i < options.length; i++) {
642
+ const prevIndex = (currentIndex - i + options.length) % options.length
643
+ const option = options[prevIndex]
644
+ if (this.isOptionSelectable(option)) {
645
+ return option
646
+ }
647
+ }
648
+
649
+ return currentOption // Return current if no other selectable option found
650
+ }
651
+
652
+ /**
653
+ * Checks if an option is selectable (not disabled and not hidden)
654
+ */
655
+ protected isOptionSelectable(option: UiOption): boolean {
656
+ if (option.disabled) {
657
+ return false
658
+ }
659
+ if (option.hasAttribute('disabled')) {
660
+ return false
661
+ }
662
+ if (option.hidden && option.hasAttribute('hidden')) {
663
+ return false
495
664
  }
665
+ return true
666
+ }
667
+
668
+ /**
669
+ * Handles type-ahead functionality for keyboard navigation
670
+ */
671
+ protected handleTypeAhead(char: string): void {
672
+ // Clear the existing timer
673
+ if (this.#typeAheadTimer) {
674
+ clearTimeout(this.#typeAheadTimer)
675
+ }
676
+
677
+ // Add the character to the search string
678
+ this.#typeAheadString += char
679
+
680
+ // Find the matching option
681
+ const matchingOption = this.findOptionByTypeAhead(this.#typeAheadString)
682
+ if (matchingOption) {
683
+ if (this.open) {
684
+ // If menu is open, focus the matching option
685
+ matchingOption.focus()
686
+ } else {
687
+ // If menu is closed, select the matching option
688
+ this.selectOption(matchingOption)
689
+ }
690
+ }
691
+
692
+ // Set a timer to reset the search string
693
+ this.#typeAheadTimer = window.setTimeout(() => {
694
+ this.#typeAheadString = ''
695
+ this.#typeAheadTimer = null
696
+ }, UiSelect.TYPE_AHEAD_TIMEOUT)
697
+ }
698
+
699
+ /**
700
+ * Finds an option that matches the type-ahead search string
701
+ */
702
+ protected findOptionByTypeAhead(searchString: string): UiOption | null {
703
+ const options = this.querySelectorAll<UiOption>('ui-option')
704
+
705
+ for (const option of options) {
706
+ if (!this.isOptionSelectable(option)) {
707
+ continue
708
+ }
709
+
710
+ // Get the option's text content for comparison
711
+ const optionText = this.getOptionDisplayText(option).toLowerCase()
712
+
713
+ if (optionText.startsWith(searchString)) {
714
+ return option
715
+ }
716
+ }
717
+
718
+ return null
719
+ }
720
+
721
+ /**
722
+ * Gets the display text for an option (either textContent or renderValue)
723
+ */
724
+ protected getOptionDisplayText(option: UiOption): string {
725
+ // Use renderValue if available, otherwise fall back to textContent
726
+ return option.renderValue || option.textContent?.trim() || ''
727
+ }
728
+
729
+ /**
730
+ * Selects an option and updates the component state
731
+ */
732
+ protected selectOption(option: UiOption): void {
733
+ if (this.selectedOption && this.selectedOption !== option) {
734
+ this.selectedOption.selected = false
735
+ }
736
+
737
+ option.selected = true
738
+ this.selectedOption = option
739
+ this.#value = option.value
740
+ this.#internals.setFormValue(this.value ?? null)
741
+
742
+ // Dispatch change event
743
+ const changeEvent = new CustomEvent<UiSelectChangeEvent>('change', {
744
+ detail: { value: this.value, item: option },
745
+ bubbles: false,
746
+ composed: true,
747
+ })
748
+ this.dispatchEvent(changeEvent)
496
749
  }
497
750
 
498
751
  handleSelect(e: CustomEvent<{ item: UiOption }>): void {
499
752
  e.stopPropagation()
500
753
  const item = e.detail.item
754
+ if (this.selectedOption && this.selectedOption !== item) {
755
+ this.selectedOption.selected = false
756
+ }
757
+ item.selected = true
501
758
  this.selectedOption = item
502
759
  this.#value = item.value
503
760
  this.#internals.setFormValue(this.value ?? null)
@@ -510,16 +767,18 @@ export default class UiSelect extends LitElement {
510
767
  composed: true,
511
768
  })
512
769
  this.dispatchEvent(changeEvent)
513
- this.focus()
514
- }
515
-
516
- handleHighlightChange(e: CustomEvent<{ item: UiOption | null }>): void {
517
- this.ariaActiveDescendant = e.detail.item?.id
770
+ // Focus will be returned to select element by handleOpenChange when open=false
518
771
  }
519
772
 
520
773
  handleMenuClose(): void {
521
774
  this.open = false
522
- this.focus()
775
+ // Focus will be returned to select element by handleOpenChange when open=false
776
+ }
777
+
778
+ handleMenuToggle(e: ToggleEvent): void {
779
+ if (e.newState === 'closed') {
780
+ this.open = false
781
+ }
523
782
  }
524
783
 
525
784
  protected renderInput(): TemplateResult {
@@ -560,7 +819,7 @@ export default class UiSelect extends LitElement {
560
819
  selector="ui-option"
561
820
  @select="${this.handleSelect}"
562
821
  @close="${this.handleMenuClose}"
563
- @highlightchange="${this.handleHighlightChange}"
822
+ @toggle="${this.handleMenuToggle}"
564
823
  >
565
824
  <slot @slotchange="${this.handleSlotChange}"></slot>
566
825
  </ui-menu>`
@@ -586,8 +845,6 @@ export default class UiSelect extends LitElement {
586
845
  'disabled': this.disabled,
587
846
  })
588
847
  return html`${this.renderFocusRing()}
589
- <div class="${classes}" aria-activedescendant=${this.ariaActiveDescendant || ''}>
590
- ${this.renderInput()} ${this.renderMenu()}
591
- </div> `
848
+ <div class="${classes}">${this.renderInput()} ${this.renderMenu()}</div> `
592
849
  }
593
850
  }