@api-client/ui 0.5.51 → 0.5.53

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.51",
3
+ "version": "0.5.53",
4
4
  "description": "Internal UI component library for the API Client ecosystem.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "build/src/index.js",
@@ -5,7 +5,7 @@ export default css`
5
5
  display: none;
6
6
  position-area: bottom span-right;
7
7
  position-try: --menu-fallback-bottom-left, --menu-fallback-top-right, --menu-fallback-top-left, flip-block;
8
- position: absolute;
8
+ position: fixed;
9
9
  margin: 0;
10
10
  padding: 0;
11
11
  border: none;
@@ -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
 
@@ -167,7 +168,7 @@ export default class UiSelect extends LitElement {
167
168
  @property({ type: Boolean, reflect: true }) accessor open = false
168
169
 
169
170
  @state() accessor selectedOption: UiOption | null = null
170
- @state() accessor ariaActiveDescendant: string | undefined
171
+
171
172
  @query('.menu') accessor menu!: UiMenuElement
172
173
 
173
174
  /**
@@ -400,27 +401,29 @@ export default class UiSelect extends LitElement {
400
401
  }
401
402
  case 'ArrowDown':
402
403
  e.preventDefault()
403
- this.menu.highlightNext()
404
+ this.focusNextMenuItem()
404
405
  return
405
406
  case 'ArrowUp':
406
407
  e.preventDefault()
407
- this.menu.highlightPrevious()
408
+ this.focusPreviousMenuItem()
408
409
  return
409
410
  case 'Home':
410
411
  e.preventDefault()
411
- this.menu.highlightFirst()
412
+ this.focusFirstMenuItem()
412
413
  return
413
414
  case 'End':
414
415
  e.preventDefault()
415
- this.menu.highlightLast()
416
+ this.focusLastMenuItem()
416
417
  return
417
418
  case 'Enter':
418
- case ' ':
419
- if (this.menu.highlightListItem) {
419
+ case ' ': {
420
+ const currentItem = this.querySelector<UiOption>(':focus')
421
+ if (currentItem && this.isOptionSelectable(currentItem)) {
420
422
  e.preventDefault()
421
- this.menu.notifySelect(this.menu.highlightListItem as UiOption)
423
+ this.menu.notifySelect(currentItem)
422
424
  }
423
425
  return
426
+ }
424
427
  }
425
428
  } else {
426
429
  switch (e.key) {
@@ -460,7 +463,7 @@ export default class UiSelect extends LitElement {
460
463
  this.open = false
461
464
  }
462
465
 
463
- protected handleClick(e: Event): void {
466
+ override handleClick(e: Event): void {
464
467
  if (this.disabled || e.defaultPrevented) return
465
468
  e.preventDefault()
466
469
  e.stopPropagation()
@@ -481,23 +484,161 @@ export default class UiSelect extends LitElement {
481
484
  this.setAttribute('aria-expanded', String(this.open))
482
485
  if (this.open) {
483
486
  menu.showPopover()
484
- // menu.focus()
485
- if (this.selectedOption) {
486
- this.menu.highlightItem(this.selectedOption)
487
+ // Focus on the selected option or first selectable option when menu opens
488
+ if (this.selectedOption && this.isOptionSelectable(this.selectedOption)) {
489
+ this.selectedOption.focus()
487
490
  } else {
488
- this.menu.highlightFirst()
491
+ const firstSelectableOption = this.getFirstSelectableOption()
492
+ if (firstSelectableOption) {
493
+ firstSelectableOption.focus()
494
+ }
489
495
  }
490
496
  this.dispatchEvent(new CustomEvent('open', { bubbles: false, composed: true }))
491
- this.focus()
492
497
  } else {
493
498
  menu.hidePopover()
494
499
  this.dispatchEvent(new CustomEvent('close', { bubbles: false, composed: true }))
500
+ // Return focus to the select element when menu closes
501
+ this.focus()
495
502
  }
496
503
  }
497
504
 
505
+ /**
506
+ * Focus the next menu item in the dropdown, skipping disabled options
507
+ */
508
+ focusNextMenuItem(): void {
509
+ const currentItem = this.querySelector<UiOption>(':focus')
510
+ const nextItem = currentItem ? this.getNextSelectableOption(currentItem) : this.getFirstSelectableOption()
511
+ if (nextItem) {
512
+ nextItem.focus()
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Focus the previous menu item in the dropdown, skipping disabled options
518
+ */
519
+ focusPreviousMenuItem(): void {
520
+ const currentItem = this.querySelector<UiOption>(':focus')
521
+ const previousItem = currentItem ? this.getPreviousSelectableOption(currentItem) : this.getLastSelectableOption()
522
+ if (previousItem) {
523
+ previousItem.focus()
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Focus the first menu item in the dropdown, skipping disabled options
529
+ */
530
+ focusFirstMenuItem(): void {
531
+ const firstItem = this.getFirstSelectableOption()
532
+ if (firstItem) {
533
+ firstItem.focus()
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Focus the last menu item in the dropdown, skipping disabled options
539
+ */
540
+ focusLastMenuItem(): void {
541
+ const lastItem = this.getLastSelectableOption()
542
+ if (lastItem) {
543
+ lastItem.focus()
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Gets the first selectable (non-disabled) option
549
+ */
550
+ getFirstSelectableOption(): UiOption | null {
551
+ const options = this.querySelectorAll<UiOption>('ui-option')
552
+ for (const option of options) {
553
+ if (this.isOptionSelectable(option)) {
554
+ return option
555
+ }
556
+ }
557
+ return null
558
+ }
559
+
560
+ /**
561
+ * Gets the last selectable (non-disabled) option
562
+ */
563
+ getLastSelectableOption(): UiOption | null {
564
+ const options = Array.from(this.querySelectorAll<UiOption>('ui-option')).reverse()
565
+ for (const option of options) {
566
+ if (this.isOptionSelectable(option)) {
567
+ return option
568
+ }
569
+ }
570
+ return null
571
+ }
572
+
573
+ /**
574
+ * Gets the next selectable option after the current one, wrapping around if needed
575
+ */
576
+ protected getNextSelectableOption(currentOption: UiOption): UiOption | null {
577
+ const options = Array.from(this.querySelectorAll<UiOption>('ui-option'))
578
+ const currentIndex = options.indexOf(currentOption)
579
+
580
+ if (currentIndex === -1) {
581
+ return this.getFirstSelectableOption()
582
+ }
583
+
584
+ // Start from the next option and wrap around
585
+ for (let i = 1; i < options.length; i++) {
586
+ const nextIndex = (currentIndex + i) % options.length
587
+ const option = options[nextIndex]
588
+ if (this.isOptionSelectable(option)) {
589
+ return option
590
+ }
591
+ }
592
+
593
+ return currentOption // Return current if no other selectable option found
594
+ }
595
+
596
+ /**
597
+ * Gets the previous selectable option before the current one, wrapping around if needed
598
+ */
599
+ protected getPreviousSelectableOption(currentOption: UiOption): UiOption | null {
600
+ const options = Array.from(this.querySelectorAll<UiOption>('ui-option'))
601
+ const currentIndex = options.indexOf(currentOption)
602
+
603
+ if (currentIndex === -1) {
604
+ return this.getLastSelectableOption()
605
+ }
606
+
607
+ // Start from the previous option and wrap around
608
+ for (let i = 1; i < options.length; i++) {
609
+ const prevIndex = (currentIndex - i + options.length) % options.length
610
+ const option = options[prevIndex]
611
+ if (this.isOptionSelectable(option)) {
612
+ return option
613
+ }
614
+ }
615
+
616
+ return currentOption // Return current if no other selectable option found
617
+ }
618
+
619
+ /**
620
+ * Checks if an option is selectable (not disabled and not hidden)
621
+ */
622
+ protected isOptionSelectable(option: UiOption): boolean {
623
+ if (option.disabled) {
624
+ return false
625
+ }
626
+ if (option.hasAttribute('disabled')) {
627
+ return false
628
+ }
629
+ if (option.hidden && option.hasAttribute('hidden')) {
630
+ return false
631
+ }
632
+ return true
633
+ }
634
+
498
635
  handleSelect(e: CustomEvent<{ item: UiOption }>): void {
499
636
  e.stopPropagation()
500
637
  const item = e.detail.item
638
+ if (this.selectedOption && this.selectedOption !== item) {
639
+ this.selectedOption.selected = false
640
+ }
641
+ item.selected = true
501
642
  this.selectedOption = item
502
643
  this.#value = item.value
503
644
  this.#internals.setFormValue(this.value ?? null)
@@ -510,16 +651,18 @@ export default class UiSelect extends LitElement {
510
651
  composed: true,
511
652
  })
512
653
  this.dispatchEvent(changeEvent)
513
- this.focus()
514
- }
515
-
516
- handleHighlightChange(e: CustomEvent<{ item: UiOption | null }>): void {
517
- this.ariaActiveDescendant = e.detail.item?.id
654
+ // Focus will be returned to select element by handleOpenChange when open=false
518
655
  }
519
656
 
520
657
  handleMenuClose(): void {
521
658
  this.open = false
522
- this.focus()
659
+ // Focus will be returned to select element by handleOpenChange when open=false
660
+ }
661
+
662
+ handleMenuToggle(e: ToggleEvent): void {
663
+ if (e.newState === 'closed') {
664
+ this.open = false
665
+ }
523
666
  }
524
667
 
525
668
  protected renderInput(): TemplateResult {
@@ -560,7 +703,7 @@ export default class UiSelect extends LitElement {
560
703
  selector="ui-option"
561
704
  @select="${this.handleSelect}"
562
705
  @close="${this.handleMenuClose}"
563
- @highlightchange="${this.handleHighlightChange}"
706
+ @toggle="${this.handleMenuToggle}"
564
707
  >
565
708
  <slot @slotchange="${this.handleSlotChange}"></slot>
566
709
  </ui-menu>`
@@ -586,8 +729,6 @@ export default class UiSelect extends LitElement {
586
729
  'disabled': this.disabled,
587
730
  })
588
731
  return html`${this.renderFocusRing()}
589
- <div class="${classes}" aria-activedescendant=${this.ariaActiveDescendant || ''}>
590
- ${this.renderInput()} ${this.renderMenu()}
591
- </div> `
732
+ <div class="${classes}">${this.renderInput()} ${this.renderMenu()}</div> `
592
733
  }
593
734
  }