@api-client/ui 0.5.29 → 0.5.31

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.
@@ -58,9 +58,9 @@ export enum IntentFlags {
58
58
  ByReference = 1 << 2,
59
59
  }
60
60
 
61
- export interface Intent {
61
+ export interface Intent<T = unknown> {
62
62
  action: string
63
- data?: unknown
63
+ data?: T
64
64
  category?: string[]
65
65
  flags?: number
66
66
  /**
@@ -98,6 +98,24 @@ interface ActivityStackEntry {
98
98
  action: string
99
99
  }
100
100
 
101
+ /**
102
+ * Type for activity constructor with static action property.
103
+ */
104
+ export interface ActivityConstructor {
105
+ new (parent: Application): Activity
106
+ action?: string
107
+ }
108
+
109
+ /**
110
+ * Enhanced activity registration options.
111
+ */
112
+ export interface ActivityRegistrationOptions {
113
+ /** Whether to override existing registrations */
114
+ override?: boolean
115
+ /** Optional validation function for intents */
116
+ validateIntent?: (intent: Intent) => boolean
117
+ }
118
+
101
119
  /**
102
120
  * Manages application activities.
103
121
  *
@@ -139,12 +157,31 @@ export class ActivityManager {
139
157
  // this.reflectEvent = this.reflectEvent.bind(this)
140
158
  }
141
159
 
142
- registerActivity(action: string, activityClass: typeof Activity): void {
143
- if (this.activityClasses.has(action)) {
160
+ /**
161
+ * Registers an activity class with enhanced options.
162
+ * @param action The action name to register the activity for.
163
+ * @param activityClass The activity class constructor.
164
+ * @param options Optional registration configuration.
165
+ */
166
+ registerActivity(
167
+ action: string,
168
+ activityClass: ActivityConstructor,
169
+ options: ActivityRegistrationOptions = {}
170
+ ): void {
171
+ const { override = false } = options
172
+
173
+ if (this.activityClasses.has(action) && !override) {
144
174
  // eslint-disable-next-line no-console
145
175
  console.warn(`Activity with action "${action}" already registered.`, new Error().stack)
176
+ return
146
177
  }
147
- this.activityClasses.set(action, activityClass)
178
+
179
+ // Validate that the class extends Activity
180
+ if (typeof activityClass !== 'function') {
181
+ throw new Error(`Invalid activity class for action "${action}": must be a constructor function`)
182
+ }
183
+
184
+ this.activityClasses.set(action, activityClass as typeof Activity)
148
185
  }
149
186
 
150
187
  // protected reflectEvent<T extends Event>(event: T): T {
@@ -302,6 +339,9 @@ export class ActivityManager {
302
339
  await stackEntry.activity.onDestroy()
303
340
  stackEntry.activity.lifecycle = ActivityLifecycle.Destroyed
304
341
 
342
+ // Clean up resources to prevent memory leaks
343
+ this.cleanupActivity(stackEntry.activity)
344
+
305
345
  await this.manageActivityResult(stackEntry.activity, stackEntry.intent)
306
346
 
307
347
  // Resume the previous activity
@@ -343,10 +383,10 @@ export class ActivityManager {
343
383
  private async bringLastActivityToFront(): Promise<void> {
344
384
  if (this.modalActivityStack.length > 0) {
345
385
  const previousEntry = this.modalActivityStack[this.activityStack.length - 1]
346
- await this.updateCurrentActivity(previousEntry.activity, false)
386
+ await this.updateCurrentActivity(previousEntry.activity, false, previousEntry.intent)
347
387
  } else if (this.activityStack.length > 0) {
348
388
  const previousEntry = this.activityStack[this.activityStack.length - 1]
349
- await this.updateCurrentActivity(previousEntry.activity, false)
389
+ await this.updateCurrentActivity(previousEntry.activity, false, previousEntry.intent)
350
390
  } else {
351
391
  this.currentActivity = undefined // No more activities
352
392
  }
@@ -404,7 +444,7 @@ export class ActivityManager {
404
444
  *
405
445
  * @param activity The activity to bring to the foreground.
406
446
  */
407
- private async updateCurrentActivity(activity: Activity, isModal: boolean): Promise<void> {
447
+ private async updateCurrentActivity(activity: Activity, isModal: boolean, intent?: Intent): Promise<void> {
408
448
  this.setupStyles(activity, this.currentActivity)
409
449
  if (
410
450
  this.currentActivity &&
@@ -416,56 +456,38 @@ export class ActivityManager {
416
456
  await this.currentActivity.onPause()
417
457
  this.currentActivity.lifecycle = ActivityLifecycle.Paused
418
458
  }
419
- if (activity.lifecycle === ActivityLifecycle.Paused) {
420
- await activity.onResume()
421
- if (this.isDestroyed(activity)) {
422
- return
423
- }
424
- activity.lifecycle = ActivityLifecycle.Resumed
425
- activity.requestUpdate()
426
- } else if (activity.lifecycle === ActivityLifecycle.Stopped) {
427
- await activity.onRestart()
428
- activity.lifecycle = ActivityLifecycle.Resumed
429
- } else if (activity.lifecycle === ActivityLifecycle.Destroyed) {
430
- throw new Error(`Invalid state. The activity is already destroyed.`)
431
- } else if (activity.lifecycle === ActivityLifecycle.Created) {
432
- await activity.onStart()
433
- if (this.isDestroyed(activity)) {
434
- return
435
- }
436
- activity.lifecycle = ActivityLifecycle.Started
437
- await activity.onResume()
438
- if (this.isDestroyed(activity)) {
439
- return
440
- }
441
- activity.lifecycle = ActivityLifecycle.Resumed
442
- activity.requestUpdate()
443
- } else if (activity.lifecycle === ActivityLifecycle.Started) {
444
- await activity.onResume()
445
- if (this.isDestroyed(activity)) {
446
- return
447
- }
448
- activity.lifecycle = ActivityLifecycle.Resumed
449
- activity.requestUpdate()
450
- } else if (activity.lifecycle === ActivityLifecycle.Initialized) {
451
- // TODO: Figure out intent passing here.
452
- await activity.onCreate()
453
- activity.lifecycle = ActivityLifecycle.Created
454
- if (this.isDestroyed(activity)) {
455
- // the activity finished and the manager already took care of this situation.
456
- return
457
- }
458
- await activity.onStart()
459
- if (this.isDestroyed(activity)) {
460
- return
461
- }
462
- activity.lifecycle = ActivityLifecycle.Started
463
- await activity.onResume()
464
- if (this.isDestroyed(activity)) {
465
- return
466
- }
467
- activity.lifecycle = ActivityLifecycle.Resumed
468
- activity.requestUpdate()
459
+ switch (activity.lifecycle) {
460
+ case ActivityLifecycle.Paused:
461
+ if (!(await this.transitionStartedToResumed(activity))) {
462
+ return
463
+ }
464
+ break
465
+
466
+ case ActivityLifecycle.Stopped:
467
+ await this.safeExecuteLifecycleMethod(() => activity.onRestart(), activity, 'onRestart')
468
+ activity.lifecycle = ActivityLifecycle.Resumed
469
+ break
470
+
471
+ case ActivityLifecycle.Destroyed:
472
+ throw new Error(`Invalid state. The activity is already destroyed.`)
473
+
474
+ case ActivityLifecycle.Created:
475
+ if (!(await this.transitionCreatedToResumed(activity))) {
476
+ return
477
+ }
478
+ break
479
+
480
+ case ActivityLifecycle.Started:
481
+ if (!(await this.transitionStartedToResumed(activity))) {
482
+ return
483
+ }
484
+ break
485
+
486
+ case ActivityLifecycle.Initialized:
487
+ if (!(await this.transitionInitializedToResumed(activity, intent))) {
488
+ return
489
+ }
490
+ break
469
491
  }
470
492
  if (!isModal) {
471
493
  // With a modal activity, it renders directly to the `<body>` and renders outside
@@ -474,17 +496,130 @@ export class ActivityManager {
474
496
  }
475
497
  }
476
498
 
499
+ /**
500
+ * Safely executes an async lifecycle method with error handling.
501
+ * @param lifecycleMethod The lifecycle method to execute.
502
+ * @param activity The activity instance.
503
+ * @param methodName The name of the method for error reporting.
504
+ * @returns Promise that resolves when the method completes or rejects with wrapped error.
505
+ */
506
+ private async safeExecuteLifecycleMethod(
507
+ lifecycleMethod: () => void | Promise<void>,
508
+ activity: Activity,
509
+ methodName: string
510
+ ): Promise<void> {
511
+ try {
512
+ await lifecycleMethod()
513
+ } catch (error) {
514
+ const activityName = activity.constructor.name
515
+ const wrappedError = new Error(
516
+ `Error in ${activityName}.${methodName}(): ${error instanceof Error ? error.message : String(error)}`
517
+ )
518
+ wrappedError.cause = error
519
+ // Allow the activity to handle its own errors, but still propagate
520
+ this.#parent.dispatchEvent(
521
+ new CustomEvent('activity-lifecycle-error', {
522
+ detail: { activity, methodName, error: wrappedError },
523
+ })
524
+ )
525
+ throw wrappedError
526
+ }
527
+ }
528
+
477
529
  /**
478
530
  * Allows navigating back through the activity stack.
479
531
  * Call `ActivityManager.navigateBack()` when the user performs a back action
480
532
  * (e.g., clicks a back button).
533
+ * @returns Promise that resolves to true if navigation was successful, false if at root.
481
534
  */
482
- async navigateBack(): Promise<void> {
535
+ async navigateBack(): Promise<boolean> {
536
+ // Handle modal activities first
537
+ if (this.modalActivityStack.length > 0) {
538
+ const topModal = this.modalActivityStack[this.modalActivityStack.length - 1]
539
+ await this.finishActivity(topModal.activity)
540
+ return true
541
+ }
542
+
543
+ // Handle regular activities
483
544
  if (this.activityStack.length > 1 && this.currentActivity) {
484
- // Finish current, which will resume previous
485
545
  await this.finishActivity(this.currentActivity)
546
+ return true
547
+ }
548
+
549
+ // At root, cannot navigate back
550
+ return false
551
+ }
552
+
553
+ /**
554
+ * Navigates to a specific activity in the stack, clearing activities above it.
555
+ * @param action The action of the activity to navigate to.
556
+ * @returns Promise that resolves to true if navigation was successful.
557
+ */
558
+ async navigateToActivity(action: string): Promise<boolean> {
559
+ const index = this.activityStack.findIndex((entry) => entry.action === action)
560
+ if (index === -1) {
561
+ return false
562
+ }
563
+
564
+ // Finish all activities above the target
565
+ const activitiesToFinish = this.activityStack.slice(index + 1)
566
+ for (const entry of activitiesToFinish) {
567
+ await this.finishActivity(entry.activity)
568
+ }
569
+
570
+ // Bring the target activity to the front
571
+ const targetEntry = this.activityStack[index]
572
+ await this.updateCurrentActivity(targetEntry.activity, false, targetEntry.intent)
573
+ return true
574
+ }
575
+
576
+ /**
577
+ * Clears the entire activity stack and starts a new root activity.
578
+ * @param intent The intent for the new root activity.
579
+ */
580
+ async clearStackAndStart(intent: Intent): Promise<void> {
581
+ // Finish all activities
582
+ const allActivities = [...this.activityStack, ...this.modalActivityStack]
583
+ for (const entry of allActivities) {
584
+ if (entry.activity.lifecycle !== ActivityLifecycle.Destroyed) {
585
+ await this.finishActivity(entry.activity)
586
+ }
587
+ }
588
+
589
+ // Clear stacks
590
+ this.activityStack.length = 0
591
+ this.modalActivityStack.length = 0
592
+ this.currentActivity = undefined
593
+
594
+ // Start new root activity
595
+ await this.startActivity(intent)
596
+ }
597
+
598
+ /**
599
+ * Creates an intent with better type safety and validation.
600
+ * @param action The action name.
601
+ * @param data Optional intent data.
602
+ * @param options Optional intent configuration.
603
+ * @returns A properly formed intent.
604
+ */
605
+ createIntent<T = unknown>(
606
+ action: string,
607
+ data?: T,
608
+ options: {
609
+ category?: string[]
610
+ flags?: number
611
+ requestCode?: number
612
+ } = {}
613
+ ): Intent<T> {
614
+ if (!this.isActionRegistered(action)) {
615
+ throw new Error(`Cannot create intent for unregistered action: ${action}`)
616
+ }
617
+
618
+ return {
619
+ action,
620
+ data,
621
+ ...options,
486
622
  }
487
- // Else do nothing (or display a message, or exit the app)
488
623
  }
489
624
 
490
625
  createRequestCode(): number {
@@ -496,4 +631,142 @@ export class ActivityManager {
496
631
  findActiveActivity(action: string): Activity | undefined {
497
632
  return this.activityStack.find((entry) => entry.action === action)?.activity
498
633
  }
634
+
635
+ /**
636
+ * Transitions an activity from Created to Resumed state.
637
+ * @param activity The activity to transition.
638
+ * @returns true if successful, false if activity was destroyed during transition.
639
+ */
640
+ private async transitionCreatedToResumed(activity: Activity): Promise<boolean> {
641
+ await this.safeExecuteLifecycleMethod(() => activity.onStart(), activity, 'onStart')
642
+ if (this.isDestroyed(activity)) {
643
+ return false
644
+ }
645
+ activity.lifecycle = ActivityLifecycle.Started
646
+
647
+ await this.safeExecuteLifecycleMethod(() => activity.onResume(), activity, 'onResume')
648
+ if (this.isDestroyed(activity)) {
649
+ return false
650
+ }
651
+ activity.lifecycle = ActivityLifecycle.Resumed
652
+ activity.requestUpdate()
653
+ return true
654
+ }
655
+
656
+ /**
657
+ * Transitions an activity from Started to Resumed state.
658
+ * @param activity The activity to transition.
659
+ * @returns true if successful, false if activity was destroyed during transition.
660
+ */
661
+ private async transitionStartedToResumed(activity: Activity): Promise<boolean> {
662
+ await this.safeExecuteLifecycleMethod(() => activity.onResume(), activity, 'onResume')
663
+ if (this.isDestroyed(activity)) {
664
+ return false
665
+ }
666
+ activity.lifecycle = ActivityLifecycle.Resumed
667
+ activity.requestUpdate()
668
+ return true
669
+ }
670
+
671
+ /**
672
+ * Transitions an activity from Initialized to Resumed state.
673
+ * @param activity The activity to transition.
674
+ * @param intent The intent used to create the activity.
675
+ * @returns true if successful, false if activity was destroyed during transition.
676
+ */
677
+ private async transitionInitializedToResumed(activity: Activity, intent?: Intent): Promise<boolean> {
678
+ await this.safeExecuteLifecycleMethod(() => activity.onCreate(intent), activity, 'onCreate')
679
+ activity.lifecycle = ActivityLifecycle.Created
680
+ if (this.isDestroyed(activity)) {
681
+ return false
682
+ }
683
+
684
+ return await this.transitionCreatedToResumed(activity)
685
+ }
686
+
687
+ /**
688
+ * Cleans up resources for destroyed activities to prevent memory leaks.
689
+ * @param activity The activity to clean up.
690
+ */
691
+ private cleanupActivity(activity: Activity): void {
692
+ // Remove any event listeners that might create memory leaks
693
+ // This changes the entire prototype chain of the activity,
694
+ // effectively making it a new object. We can't do that as the activity will loose some
695
+ // of its properties, so we don't do it for now.
696
+ // if (activity instanceof EventTarget) {
697
+ // // Clear all event listeners by replacing the activity with a new EventTarget
698
+ // // This is a defensive approach since we can't enumerate listeners
699
+ // Object.setPrototypeOf(activity, EventTarget.prototype)
700
+ // }
701
+
702
+ // Clear any references that might prevent garbage collection
703
+ activity.renderRoot = undefined
704
+ }
705
+
706
+ /**
707
+ * Validates that an activity stack is in a consistent state.
708
+ * @param stack The activity stack to validate.
709
+ * @returns Array of validation errors, empty if valid.
710
+ */
711
+ private validateStackConsistency(stack: ActivityStackEntry[]): string[] {
712
+ const errors: string[] = []
713
+ const seenIds = new Set<string>()
714
+
715
+ for (const entry of stack) {
716
+ if (seenIds.has(entry.id)) {
717
+ errors.push(`Duplicate activity ID found: ${entry.id}`)
718
+ }
719
+ seenIds.add(entry.id)
720
+
721
+ if (!entry.activity || !entry.id || !entry.action) {
722
+ errors.push(`Invalid stack entry: missing required properties`)
723
+ }
724
+
725
+ if (entry.activity.lifecycle === ActivityLifecycle.Destroyed) {
726
+ errors.push(`Destroyed activity found in stack: ${entry.id}`)
727
+ }
728
+ }
729
+
730
+ return errors
731
+ }
732
+
733
+ /**
734
+ * Gets diagnostic information about the current state of the activity manager.
735
+ * Useful for debugging and monitoring.
736
+ * @returns Object containing current state information.
737
+ */
738
+ getDiagnostics(): {
739
+ totalActivities: number
740
+ modalActivities: number
741
+ currentActivity?: string
742
+ topActivity?: string
743
+ stackValidation: string[]
744
+ modalStackValidation: string[]
745
+ } {
746
+ return {
747
+ totalActivities: this.activityStack.length,
748
+ modalActivities: this.modalActivityStack.length,
749
+ currentActivity: this.currentActivity?.constructor.name,
750
+ topActivity: this.getTopActivity()?.constructor.name,
751
+ stackValidation: this.validateStackConsistency(this.activityStack),
752
+ modalStackValidation: this.validateStackConsistency(this.modalActivityStack),
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Gets all registered activity actions.
758
+ * @returns Array of registered action names.
759
+ */
760
+ getRegisteredActions(): string[] {
761
+ return Array.from(this.activityClasses.keys())
762
+ }
763
+
764
+ /**
765
+ * Checks if an action is registered.
766
+ * @param action The action to check.
767
+ * @returns true if the action is registered, false otherwise.
768
+ */
769
+ isActionRegistered(action: string): boolean {
770
+ return this.activityClasses.has(action)
771
+ }
499
772
  }
@@ -764,7 +764,6 @@ export default class CurrencyPicker extends LitElement {
764
764
  <ui-select
765
765
  label="${this.label}"
766
766
  @change="${this.handleCurrencySelect}"
767
- menuPositioning="popover"
768
767
  ?disabled="${this.disabled}"
769
768
  ?required="${this.required}"
770
769
  .supportingText="${this.supportingText || ''}"
@@ -118,12 +118,19 @@ export default class Menu extends UiList {
118
118
  // Let CSS anchor positioning handle the positioning automatically
119
119
  // Only intervene if we need to set max-height for overflow cases
120
120
  const box = this.getBoundingClientRect()
121
- const menuBottom = box.top + box.height
122
- const menuRight = box.left + box.width
123
121
 
124
122
  // Reset any previous manual positioning to let CSS anchor positioning work
125
123
  this.style.removeProperty('position-area')
126
124
  this.style.removeProperty('max-height')
125
+ this.style.removeProperty('max-width')
126
+
127
+ // Check if the menu content is being clipped
128
+ const isVerticallyClipped = this.scrollHeight > this.clientHeight
129
+ const isHorizontallyClipped = this.scrollWidth > this.clientWidth
130
+
131
+ // Get the actual bottom and right edges of the menu
132
+ const menuBottom = box.top + box.height
133
+ const menuRight = box.left + box.width
127
134
 
128
135
  // Detect if menu is positioned above or below the anchor
129
136
  // by checking if the menu is in the upper or lower half of the viewport
@@ -139,16 +146,24 @@ export default class Menu extends UiList {
139
146
  this.classList.remove('menu-positioned-above')
140
147
  }
141
148
 
142
- // Only set max-height if the menu would overflow
143
- if (menuBottom > innerHeight) {
144
- const availableHeight = innerHeight - box.top
149
+ // Only set max-height if the menu would overflow the viewport OR is already clipped
150
+ if (menuBottom > innerHeight || isVerticallyClipped) {
151
+ let availableHeight: number
152
+
153
+ if (isMenuInUpperHalf) {
154
+ // Menu is positioned below the anchor - available space is from top to bottom of viewport
155
+ availableHeight = innerHeight - box.top
156
+ } else {
157
+ // Menu is positioned above the anchor - available space is from top of viewport to bottom of menu
158
+ availableHeight = box.top + box.height
159
+ }
160
+
145
161
  this.style.maxHeight = `${Math.max(200, availableHeight - 20)}px`
146
162
  }
147
163
 
148
- if (menuRight > innerWidth) {
164
+ // Only set max-width if the menu would overflow the viewport OR is already clipped
165
+ if (menuRight > innerWidth || isHorizontallyClipped) {
149
166
  const availableWidth = innerWidth - box.left
150
- // Let CSS anchor positioning handle horizontal flipping
151
- // We could set max-width if needed
152
167
  if (availableWidth < 200) {
153
168
  this.style.maxWidth = `${Math.max(180, availableWidth - 20)}px`
154
169
  }
@@ -12,11 +12,6 @@ export default css`
12
12
  }
13
13
 
14
14
  .input {
15
- anchor-name: --input;
16
15
  cursor: default;
17
16
  }
18
-
19
- .menu {
20
- position-anchor: --input;
21
- }
22
17
  `
@@ -1,9 +1,11 @@
1
1
  import { html, LitElement, 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
+ import { styleMap } from 'lit/directives/style-map.js'
4
5
  import { setDisabled } from '../../../lib/disabled.js'
5
6
  import type UiOption from './Option.js'
6
7
  import type { UiMenuElement } from '../../menu/ui-menu.js'
8
+ import { nanoid } from 'nanoid'
7
9
 
8
10
  import '../../text-field/ui-outlined-text-field.js'
9
11
  import '../../menu/ui-menu.js'
@@ -274,6 +276,9 @@ export default class UiSelect extends LitElement {
274
276
  if (!this.disabled) {
275
277
  this.setAttribute('tabindex', '0')
276
278
  }
279
+ if (!this.id) {
280
+ this.id = `select-${nanoid(6)}`
281
+ }
277
282
  }
278
283
 
279
284
  /**
@@ -355,11 +360,17 @@ export default class UiSelect extends LitElement {
355
360
 
356
361
  protected async setCurrentOption(): Promise<void> {
357
362
  await this.updateComplete
363
+ const options = this.querySelectorAll<UiOption>('ui-option')
358
364
  if (this.value) {
359
- const options = this.querySelectorAll<UiOption>('ui-option')
360
365
  this.selectedOption = Array.from(options).find((option) => option.value === this.value) || null
361
366
  } else {
362
- this.selectedOption = null
367
+ const selected = Array.from(options).find((option) => option.selected)
368
+ if (selected) {
369
+ this.selectedOption = selected
370
+ this.#value = selected.value
371
+ } else {
372
+ this.selectedOption = null
373
+ }
363
374
  }
364
375
  }
365
376
 
@@ -500,19 +511,10 @@ export default class UiSelect extends LitElement {
500
511
  this.focus()
501
512
  }
502
513
 
503
- override render(): TemplateResult {
504
- const classes = classMap({
505
- 'ui-select': true,
506
- 'open': this.open,
507
- 'disabled': this.disabled,
508
- })
509
- return html`${this.renderFocusRing()}
510
- <div class="${classes}" aria-activedescendant=${this.ariaActiveDescendant || ''}>
511
- ${this.renderInput()} ${this.renderMenu()}
512
- </div> `
513
- }
514
-
515
514
  protected renderInput(): TemplateResult {
515
+ const styles = {
516
+ 'anchor-name': `--${this.id}`,
517
+ }
516
518
  return html`<ui-outlined-text-field
517
519
  .name=${this.name}
518
520
  .label=${this.label}
@@ -521,32 +523,60 @@ export default class UiSelect extends LitElement {
521
523
  .required=${this.required}
522
524
  readonly
523
525
  tabindex="-1"
524
- inert
526
+ .inert=${true}
525
527
  aria-hidden="true"
526
528
  .invalid=${this.invalid}
527
529
  .invalidText=${this.invalidText || ''}
528
530
  .supportingText=${this.supportingText || ''}
529
531
  class="input"
532
+ style=${styleMap(styles)}
533
+ part="input"
530
534
  >
531
- <ui-icon slot="suffix">arrow_drop_down</ui-icon>
535
+ <ui-icon part="icon" slot="suffix">arrow_drop_down</ui-icon>
532
536
  </ui-outlined-text-field>`
533
537
  }
534
538
 
535
539
  protected renderMenu(): TemplateResult {
540
+ const styles = {
541
+ 'position-anchor': `--${this.id}`,
542
+ }
536
543
  return html`<ui-menu
537
544
  id="menu"
538
545
  class="menu"
546
+ part="menu"
547
+ style=${styleMap(styles)}
539
548
  popover="auto"
540
549
  selector="ui-option"
541
550
  @select="${this.handleSelect}"
542
551
  @close="${this.handleMenuClose}"
543
552
  @highlightchange="${this.handleHighlightChange}"
544
553
  >
545
- <slot></slot>
554
+ <slot @slotchange="${this.handleSlotChange}"></slot>
546
555
  </ui-menu>`
547
556
  }
548
557
 
558
+ protected async handleSlotChange(): Promise<void> {
559
+ // When options change, re-evaluate the current selection
560
+ // only if we don't have an explicit value set
561
+ if (!this.value) {
562
+ await this.setCurrentOption()
563
+ this.requestUpdate()
564
+ }
565
+ }
566
+
549
567
  protected renderFocusRing(): TemplateResult {
550
568
  return html`<md-focus-ring part="focus-ring" class="focus-ring" .control="${this as HTMLElement}"></md-focus-ring>`
551
569
  }
570
+
571
+ override render(): TemplateResult {
572
+ const classes = classMap({
573
+ 'ui-select': true,
574
+ 'open': this.open,
575
+ 'disabled': this.disabled,
576
+ })
577
+ return html`${this.renderFocusRing()}
578
+ <div class="${classes}" aria-activedescendant=${this.ariaActiveDescendant || ''}>
579
+ ${this.renderInput()} ${this.renderMenu()}
580
+ </div> `
581
+ }
552
582
  }
@@ -354,10 +354,10 @@ describe('ActivityManager', () => {
354
354
 
355
355
  assert.isTrue(activity1.onActivityResult.calledOnce)
356
356
  const [calledRequestCode, calledResultCode, calledIntent] = activity1.onActivityResult.firstCall.args
357
- assert.equal(calledRequestCode, requestCode)
358
- assert.equal(calledResultCode, IntentResult.RESULT_OK)
359
- assert.deepEqual(calledIntent.data, resultData)
360
- assert.equal(calledIntent.action, TestActivity2.action)
357
+ assert.equal(calledRequestCode, requestCode, 'has the request code from activity1')
358
+ assert.equal(calledResultCode, IntentResult.RESULT_OK, 'has the OK result code')
359
+ assert.deepEqual(calledIntent.data, resultData, 'has the result data from activity2')
360
+ assert.equal(calledIntent.action, TestActivity2.action, 'has the action of activity2')
361
361
  })
362
362
 
363
363
  it('handles finishing an activity that was only created (not fully started)', async () => {