@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.
- package/build/src/core/ActivityManager.d.ts +112 -4
- package/build/src/core/ActivityManager.d.ts.map +1 -1
- package/build/src/core/ActivityManager.js +251 -62
- package/build/src/core/ActivityManager.js.map +1 -1
- package/build/src/elements/currency/internals/Picker.d.ts.map +1 -1
- package/build/src/elements/currency/internals/Picker.js +0 -1
- package/build/src/elements/currency/internals/Picker.js.map +1 -1
- package/build/src/md/menu/internal/Menu.d.ts.map +1 -1
- package/build/src/md/menu/internal/Menu.js +20 -8
- package/build/src/md/menu/internal/Menu.js.map +1 -1
- package/build/src/md/select/internals/Select.d.ts +2 -1
- package/build/src/md/select/internals/Select.d.ts.map +1 -1
- package/build/src/md/select/internals/Select.js +46 -16
- package/build/src/md/select/internals/Select.js.map +1 -1
- package/build/src/md/select/internals/Select.styles.d.ts.map +1 -1
- package/build/src/md/select/internals/Select.styles.js +0 -5
- package/build/src/md/select/internals/Select.styles.js.map +1 -1
- package/demo/elements/currency/index.ts +80 -0
- package/demo/md/select/index.ts +1 -1
- package/package.json +1 -1
- package/src/core/ActivityManager.ts +334 -61
- package/src/elements/currency/internals/Picker.ts +0 -1
- package/src/md/menu/internal/Menu.ts +23 -8
- package/src/md/select/internals/Select.styles.ts +0 -5
- package/src/md/select/internals/Select.ts +47 -17
- package/test/core/activity_manager.spec.ts +4 -4
- package/test/md/select/Select.test.ts +229 -0
|
@@ -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?:
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 () => {
|