@api-client/ui 0.5.53 → 0.5.55

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.53",
3
+ "version": "0.5.55",
4
4
  "description": "Internal UI component library for the API Client ecosystem.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "build/src/index.js",
@@ -373,7 +373,9 @@ export class ActivityManager {
373
373
  await target.activity.onPause()
374
374
  target.activity.lifecycle = ActivityLifecycle.Paused
375
375
  }
376
- const intentCopy = structuredClone(intent)
376
+ // Let's create a shallow copy of the intent. We can disregard the ByReference flag here,
377
+ // as we override the data with the result data.
378
+ const intentCopy = { ...intent }
377
379
  intentCopy.data = activity.getResult()
378
380
  await target.activity.onActivityResult(requestCode, activity.resultCode, intentCopy)
379
381
  }
@@ -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
@@ -36,6 +36,21 @@ export default class UiSelect extends UiElement {
36
36
  */
37
37
  #value: string | undefined
38
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
+
39
54
  /**
40
55
  * The currently selected value. Corresponds to the `value` attribute of the selected `ui-option`.
41
56
  * When set programmatically, it will update the selected option if a matching option exists.
@@ -282,6 +297,15 @@ export default class UiSelect extends UiElement {
282
297
  }
283
298
  }
284
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
+
285
309
  /**
286
310
  * Resets the select to its initial state. Called automatically when the parent
287
311
  * form is reset. Part of the form-associated custom element API.
@@ -382,6 +406,13 @@ export default class UiSelect extends UiElement {
382
406
 
383
407
  protected handleKeydown(e: KeyboardEvent): void {
384
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
+
385
416
  if (this.open) {
386
417
  switch (e.key) {
387
418
  case 'Tab': {
@@ -487,10 +518,12 @@ export default class UiSelect extends UiElement {
487
518
  // Focus on the selected option or first selectable option when menu opens
488
519
  if (this.selectedOption && this.isOptionSelectable(this.selectedOption)) {
489
520
  this.selectedOption.focus()
521
+ this.selectedOption.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' })
490
522
  } else {
491
523
  const firstSelectableOption = this.getFirstSelectableOption()
492
524
  if (firstSelectableOption) {
493
525
  firstSelectableOption.focus()
526
+ firstSelectableOption.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' })
494
527
  }
495
528
  }
496
529
  this.dispatchEvent(new CustomEvent('open', { bubbles: false, composed: true }))
@@ -632,6 +665,89 @@ export default class UiSelect extends UiElement {
632
665
  return true
633
666
  }
634
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)
749
+ }
750
+
635
751
  handleSelect(e: CustomEvent<{ item: UiOption }>): void {
636
752
  e.stopPropagation()
637
753
  const item = e.detail.item