diff --git a/apps/cookbook/src/app/examples/examples.common.ts b/apps/cookbook/src/app/examples/examples.common.ts index 4387a1f9cc..9a56743cfd 100644 --- a/apps/cookbook/src/app/examples/examples.common.ts +++ b/apps/cookbook/src/app/examples/examples.common.ts @@ -51,6 +51,7 @@ import { PagePullToRefreshExampleComponent } from './page-example/pull-to-refres import { DropdownExampleComponent } from './dropdown-example/dropdown-example.component'; import { DataTableExampleComponent } from './data-table-example/data-table-example.component'; import { LoadingOverlayServiceExampleComponent } from './loading-overlay-example/service/loading-overlay-service-example.component'; +import { MenuExampleComponent } from './menu-example/menu-example.component'; export const COMPONENT_DECLARATIONS: any[] = [ ExamplesComponent, @@ -107,4 +108,5 @@ export const COMPONENT_DECLARATIONS: any[] = [ SectionHeaderExampleComponent, ListExperimentalExampleComponent, DataTableExampleComponent, + MenuExampleComponent, ]; diff --git a/apps/cookbook/src/app/examples/examples.module.ts b/apps/cookbook/src/app/examples/examples.module.ts index 1fe1e366f1..4c3e42a71e 100644 --- a/apps/cookbook/src/app/examples/examples.module.ts +++ b/apps/cookbook/src/app/examples/examples.module.ts @@ -41,6 +41,7 @@ import { SectionHeaderExampleModule } from './section-header-example/section-hea import { SegmentedControlExampleModule } from './segmented-control-example/segmented-control-example.module'; import { ToggleButtonExampleModule } from './toggle-button-example/toggle-button-example.module'; import { VirtualScrollExampleModule } from './virtual-scroll-example/virtual-scroll-example.module'; +import { MenuExampleModule } from './menu-example/menu-example.module'; const IMPORTS = [ CodeViewerModule, @@ -72,6 +73,7 @@ const IMPORTS = [ VirtualScrollExampleModule, ExperimentalExamplesModule, DataTableExampleModule, + MenuExampleModule, SlideModule, HeaderExampleModule, AlertExperimentalModule, diff --git a/apps/cookbook/src/app/examples/examples.routes.ts b/apps/cookbook/src/app/examples/examples.routes.ts index dd6c07c97d..0a91f6ba57 100644 --- a/apps/cookbook/src/app/examples/examples.routes.ts +++ b/apps/cookbook/src/app/examples/examples.routes.ts @@ -79,8 +79,8 @@ import { DropdownExampleComponent } from './dropdown-example/dropdown-example.co import { DataTableExampleComponent } from './data-table-example/data-table-example.component'; import { HeaderExampleComponent } from './header-example/header-example.component'; import { NestedModalsV2ExampleComponent } from './modal-v2-example/nested-modals/nested-modals-v2-example.component'; +import { MenuExampleComponent } from './menu-example/menu-example.component'; -VirtualScrollListExampleComponent; export const routes: Routes = [ { path: '', @@ -522,8 +522,16 @@ export const routes: Routes = [ path: 'data-table', component: DataTableExampleComponent, }, + { + path: 'menu', + component: MenuExampleComponent, + }, { path: 'header', component: HeaderExampleComponent, }, + { + path: 'menu', + component: MenuExampleComponent, + }, ]; diff --git a/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts b/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts new file mode 100644 index 0000000000..9079ec4c4d --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core'; + +const config = { + selector: 'cookbook-menu-advanced-example', + template: ` + + +

Title

+ +
+
`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuAdvancedExampleComponent { + template: string = config.template; + + public actionClicked(): void { + console.log('Action clicked'); + } + + public toggled(): void { + console.log('Toggle changed'); + } +} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts b/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts new file mode 100644 index 0000000000..838c2579e2 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts @@ -0,0 +1,33 @@ +import { Component } from '@angular/core'; + +const config = { + selector: 'cookbook-menu-custom-button-example', + template: ` + + +

Action 1

+
+
`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuCustomButtonExampleComponent { + template: string = config.template; + + public actionClicked(): void { + console.log('Action clicked'); + } + + public toggled(): void { + console.log('Toggle changed'); + } +} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts b/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts new file mode 100644 index 0000000000..34ee7b1239 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +const config = { + selector: 'cookbook-menu-custom-placement-example', + template: ` + +

Action 1

+
+ ... +
`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuCustomPlacementExampleComponent { + template: string = config.template; +} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/default.ts b/apps/cookbook/src/app/examples/menu-example/examples/default.ts new file mode 100644 index 0000000000..84a52d59ce --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/default.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +const config = { + selector: 'cookbook-menu-default-example', + template: ` + +

Action 1

+
+
+`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuDefaultExampleComponent { + template: string = config.template; +} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/portal-in-list-wrapper.ts b/apps/cookbook/src/app/examples/menu-example/examples/portal-in-list-wrapper.ts new file mode 100644 index 0000000000..3424f3d93e --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/portal-in-list-wrapper.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cookbook-menu-portal-in-list-wrapper-example', + template: ` + + + + + + + + + + + `, +}) +export class PortalInListWrapperComponent {} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/portal.ts b/apps/cookbook/src/app/examples/menu-example/examples/portal.ts new file mode 100644 index 0000000000..59412ed248 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/portal.ts @@ -0,0 +1,54 @@ +import { ChangeDetectorRef, Component, Input } from '@angular/core'; + +const config = { + selector: 'cookbook-menu-portal-example', + template: ` + +

Action 1

+
+ +

Action 2

+
+
+`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuPortalExampleComponent { + template: string = config.template; + public _outlet: HTMLElement; + + /** + * + */ + constructor(private cd: ChangeDetectorRef) {} + + @Input() set isOutletElementSet(isSet: boolean) { + this._outlet = isSet ? this.outletElement : null; + } + private outletTag: string = 'cookbook-root'; + + public outletElement: HTMLElement = this.getOutletElement(); + + private getOutletElement(): HTMLElement { + const elements: HTMLCollectionOf = document.getElementsByTagName(this.outletTag); + + if (!elements || elements.length === 0) { + throw Error(`Could not locate HTMLElement for ${this.outletTag}. Did you misspell it?`); + } + + if (elements.length > 1) { + throw Error( + `Multiple HTMLElements found for ${this.outletTag}. + This can lead to unintended behaviours. Provide an unique outlet` + ); + } + + return elements[0] as HTMLElement; + } +} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts b/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts new file mode 100644 index 0000000000..c65bd8f8f2 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts @@ -0,0 +1,31 @@ +import { Component } from '@angular/core'; +import { OutletSelector, PortalOutletConfig } from '@kirbydesign/designsystem/shared/floating'; + +const config = { + selector: 'cookbook-menu-portal-config-example', + template: ` + +

Action 1

+
+ +

Action 2

+
+
`, + codeSnippet: `public outletConfig: PortalOutletConfig = { + selector: OutletSelector.tag, + value: 'cookbook-root', + };`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuPortalConfigExampleComponent { + template: string = config.template; + + public outletConfig: PortalOutletConfig = { + selector: OutletSelector.tag, + value: 'cookbook-root', + }; +} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/portalOutletConfig.ts b/apps/cookbook/src/app/examples/menu-example/examples/portalOutletConfig.ts new file mode 100644 index 0000000000..3c5d3f2f0a --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/portalOutletConfig.ts @@ -0,0 +1,9 @@ +export const portalOutletConfigExampleHTML = ` +
+`; + +export const portalOutletConfigExampleTS = `const outletConfig: PortalOutletConfig = { + selector: 'id', + value: 'target-div' +} +`; diff --git a/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts b/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts new file mode 100644 index 0000000000..47210e7284 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +const config = { + selector: 'cookbook-menu-selectable-example', + template: ` + +

Action 1

+
+ ... +
`, +}; + +@Component({ + selector: config.selector, + template: config.template, +}) +export class MenuSelectableExampleComponent { + template: string = config.template; +} diff --git a/apps/cookbook/src/app/examples/menu-example/menu-example.component.html b/apps/cookbook/src/app/examples/menu-example/menu-example.component.html new file mode 100644 index 0000000000..cc37540ad4 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/menu-example.component.html @@ -0,0 +1,13 @@ +

Menu

+

Simple

+ +

Selectable items

+ +

Advanced item

+ +

Custom button

+ +

Portal

+ +

Portal config

+ diff --git a/apps/cookbook/src/app/examples/menu-example/menu-example.component.ts b/apps/cookbook/src/app/examples/menu-example/menu-example.component.ts new file mode 100644 index 0000000000..def32abb63 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/menu-example.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cookbook-action-list-example', + templateUrl: './menu-example.component.html', +}) +export class MenuExampleComponent {} diff --git a/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts b/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts new file mode 100644 index 0000000000..c441897955 --- /dev/null +++ b/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { KirbyModule, ListHeaderDirective } from '@kirbydesign/designsystem'; +import { MenuDefaultExampleComponent } from './examples/default'; +import { MenuAdvancedExampleComponent } from './examples/advanced'; +import { MenuSelectableExampleComponent } from './examples/selectable'; +import { PortalInListWrapperComponent as MenuPortalInListWrapperComponent } from './examples/portal-in-list-wrapper'; +import { MenuCustomButtonExampleComponent } from '~/app/examples/menu-example/examples/customButton'; +import { MenuPortalExampleComponent } from '~/app/examples/menu-example/examples/portal'; +import { MenuCustomPlacementExampleComponent } from '~/app/examples/menu-example/examples/customPlacement'; +import { MenuPortalConfigExampleComponent } from '~/app/examples/menu-example/examples/portalConfig'; + +const COMPONENT_DECLARATIONS = [ + MenuPortalInListWrapperComponent, + MenuDefaultExampleComponent, + MenuAdvancedExampleComponent, + MenuSelectableExampleComponent, + MenuCustomButtonExampleComponent, + MenuPortalExampleComponent, + MenuPortalConfigExampleComponent, + MenuCustomPlacementExampleComponent, +]; + +@NgModule({ + imports: [CommonModule, KirbyModule], + declarations: COMPONENT_DECLARATIONS, + exports: COMPONENT_DECLARATIONS, +}) +export class MenuExampleModule {} diff --git a/apps/cookbook/src/app/shared/example-viewer/example-viewer.component.html b/apps/cookbook/src/app/shared/example-viewer/example-viewer.component.html index 35e67c6ab8..b34294eee5 100644 --- a/apps/cookbook/src/app/shared/example-viewer/example-viewer.component.html +++ b/apps/cookbook/src/app/shared/example-viewer/example-viewer.component.html @@ -1,5 +1,11 @@ - diff --git a/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html new file mode 100644 index 0000000000..546c3ceba4 --- /dev/null +++ b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html @@ -0,0 +1,273 @@ + + +
+

+ A menu is a list of actions that displays in a popup. Menu replaces action-sheet both on mobile + and web. +

+ +

+ The menu's default slot accepts one or multiple + kirby-item + . Additionally, the menu renders a default button that opens the menu. This button can be + replaced by a custom button by providing a + kirby-button + as a content child of the menu as shown + here + . +

+ +

+ The menu supports the use of portals, which enables the user to render the menu in a different + part of the DOM. This can be useful in case of issues with the stacking context. This is further + described in the + Portal + section. +

+
+

Examples

+

Basic

+
+
+ + + +
+
+ +

Selectable items

+

This example demonstrates how to set custom actions on the elements in the menu.

+
+
+ + + +
+
+ +

Advanced items

+

+ Since the Menu accepts Items, it is possible to customize these. The example below demonstrates + how to add a toggle to an Item in the list. +

+
+
+ + + +
+
+ +

Menu placement

+

+ By default the menu will open underneath the button and towards the right side of the screen. The + placement of the menu can be configured through the + placement + input as shown below. +

+ +
+
+ + + +
+
+ +

Custom button

+

+ By default a button is displayed and its functionality handled by the menu component. Should you + need to modify the button, for example adding a directive or an attribute to it, you can provide + your own button as shown in the example code. +

+ +

+ + The custom button won't receive the default values provided by the menu component. + +

+ +
+
+ + + +
+
+ +

Portal

+

+ The menu supports the use of portals to place its content in another part of the DOM. This can be + done by providing the menu with a + PortalOutletConfig + , or a reference to a + HTMLElement + that is passed to the + DOMPortalOutlet + input. +

+ +

+ Most use cases should be easily handled by using the + PortalOutletConfig + input. +

+ +

+ + + The use of portal should only be used when necessary. It might break styling and/or angular + functionality, so use with care. + + +

+ +

+ DOMPortalOutlet + - Portal with outlet +

+ + +

+ The + DOMPortalOutlet + input must be a reference to a unique DOM element, such as the + body + . +

+ + + + +
+
+

How it looks

+

+ One of the cases where it might be necessary to use portal, is when the menu is placed inside + a + kirby-list + . The example below exemplifies how it looks when the menu is not placed correctly in the + stack. +

+ + + + +

+ A menu in a + kirby-list +   + without +   + DOMPortalOutlet +

+
+ + <-- The menu is cut off by the list. +
+
+
+
+ + +

+ A menu in a + kirby-list +   + with + a + DOMPortalOutlet +

+

+ The + outletElement + is {{ isOutletElementSet }} +

+
+ + <-- The menu renders outside the menu. +
+
+
+
+
+ +

+ PortalOutletConfig + - Automatically get portal outlet +

+ +

+ +

+ This example will describe and show how to use + PortalOutletConfig + input. +

+

+ Providing a + PortalOutletConfig + to the + PortalOutletConfig + input, will let the menu component automatically handle getting and setting of the desired + HTMLElement + for which the menu content should portal to. +

+

+ PortalOutletConfig + consists of a selector and a value. Selector is the various ways you would fetch an element from + the document object, i.e id, class, tag and name. Value is the corresponding value associated with + the selector +

+

Example:

+ + + + +
+
+ + + +
+
+ +

Properties:

+ diff --git a/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.scss b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.scss new file mode 100644 index 0000000000..836052a76a --- /dev/null +++ b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.scss @@ -0,0 +1,18 @@ +@use '@kirbydesign/core/src/scss/utils'; + +h2:not(:first-child) { + margin-top: utils.size('l'); +} + +cookbook-example-viewer > *:first-child { + display: block; + margin-bottom: utils.size('s'); + max-width: 550px; +} + +.page-example { + display: flex; + justify-content: space-between; + margin-top: utils.size('xl'); + margin-bottom: utils.size('xl'); +} diff --git a/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.ts b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.ts new file mode 100644 index 0000000000..e3cef38dd6 --- /dev/null +++ b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.ts @@ -0,0 +1,106 @@ +import { Component, Input } from '@angular/core'; +import { + portalOutletConfigExampleHTML, + portalOutletConfigExampleTS, +} from '../../examples/menu-example/examples/portalOutletConfig'; +import { ApiDescriptionProperty } from './../../shared/api-description/api-description-properties/api-description-properties.component'; + +@Component({ + selector: 'cookbook-menu-showcase', + templateUrl: './menu-showcase.component.html', + styleUrls: ['./menu-showcase.component.scss'], +}) +export class MenuShowcaseComponent { + properties: ApiDescriptionProperty[] = [ + { + name: 'isDisabled', + description: + 'Disable the default button from being clickable and prevents content from changing display state', + defaultValue: 'false', + type: ['boolean'], + }, + { + name: 'buttonSize', + description: 'Size of the default button', + defaultValue: 'md', + type: ['ButtonSize'], + }, + { + name: 'placement', + description: 'Placement of the content when displayed', + defaultValue: 'bottom-start', + type: ['Placement'], + }, + { + name: 'triggers', + description: `Defines how the button should interact with the content. A value of 'click' will make the content appear/hide on click of the button`, + defaultValue: 'click', + type: ['Array'], + }, + { + name: 'autoPlacement', + description: + 'If content should be auto placed where it best fits on the screen. Will override value of input placement', + defaultValue: 'false', + type: ['boolean'], + }, + { + name: 'attentionLevel', + description: 'AttentionLevel for the menu button', + defaultValue: '3', + type: ['AttentionLevel'], + }, + { + name: 'DOMPortalOutlet', + description: + 'HTMLElement for which the menu content should be placed under as a child. If no element is provided, the content will appear at its normal place in the DOM', + defaultValue: 'N/A', + type: ['HTMLElement'], + }, + { + name: 'portalOutletConfig', + description: + 'Defines how to automatically find and assign DOMPortalOutlet if none is provided in DOMPortalOutlet. If nothing is provided here and in DOMPortalOutlet, the provided strategy is used', + defaultValue: 'N/A', + type: ['PortalOutletConfig'], + }, + { + name: 'closeOnSelect', + description: '"Toggle whether the menu should hide the content after selecting the content"', + defaultValue: 'false', + type: ['boolean'], + }, + { + name: 'closeOnEscapeKey', + description: 'If the menu should hide content after pressing escape', + defaultValue: 'true', + type: ['boolean'], + }, + { + name: 'closeOnBackdrop', + description: 'If the menu should hide content after clicking outside of content', + defaultValue: 'true', + type: ['boolean'], + }, + { + name: 'minWidth', + description: 'The minimum width of the menu. If not set, the default width is 240px', + defaultValue: 'N/A', + type: ['number'], + }, + ]; + + public isOutletElementSet: boolean = true; + + portalOutletConfigExampleHTML: string = portalOutletConfigExampleHTML; + portalOutletConfigExampleTS: string = portalOutletConfigExampleTS; + + public onCheckedChange(checked: boolean) { + this.isOutletElementSet = checked; + } + + scrollTo(target: Element) { + target.scrollIntoView({ behavior: 'smooth' }); + return false; + } +} diff --git a/apps/cookbook/src/app/showcase/showcase.common.ts b/apps/cookbook/src/app/showcase/showcase.common.ts index d35ec22377..2595137793 100644 --- a/apps/cookbook/src/app/showcase/showcase.common.ts +++ b/apps/cookbook/src/app/showcase/showcase.common.ts @@ -58,6 +58,7 @@ import { DataTableShowcaseComponent } from './data-table-showcase/data-table-sho import { CookbookChartStockConfigShowcaseComponent } from './chart-config-showcase/stock/chart-config-stock-showcase.component'; import { CookbookChartBarConfigShowcaseComponent } from './chart-config-showcase/bar/chart-config-bar-showcase.component'; import { HeaderShowcaseComponent } from './header-showcase/header-showcase.component'; +import { MenuShowcaseComponent } from './menu-showcase/menu-showcase.component'; export const COMPONENT_IMPORTS: any[] = [ExamplesModule, ShowcaseRoutingModule]; @@ -117,7 +118,9 @@ export const COMPONENT_EXPORTS: any[] = [ ExampleViewerComponent, ChartExampleConfigBaseBarComponent, CookbookChartBarConfigShowcaseComponent, + MenuShowcaseComponent, HeaderShowcaseComponent, + MenuShowcaseComponent, ]; export const COMPONENT_DECLARATIONS: any[] = [...COMPONENT_EXPORTS, ShowcaseComponent]; diff --git a/apps/cookbook/src/app/showcase/showcase.routes.ts b/apps/cookbook/src/app/showcase/showcase.routes.ts index aba411a997..1a45065f4c 100644 --- a/apps/cookbook/src/app/showcase/showcase.routes.ts +++ b/apps/cookbook/src/app/showcase/showcase.routes.ts @@ -61,6 +61,7 @@ import { RadioShowcaseComponent } from './radio-showcase/radio-showcase.componen import { CookbookChartStockConfigShowcaseComponent } from './chart-config-showcase/stock/chart-config-stock-showcase.component'; import { CookbookChartBarConfigShowcaseComponent } from './chart-config-showcase/bar/chart-config-bar-showcase.component'; import { HeaderShowcaseComponent } from './header-showcase/header-showcase.component'; +import { MenuShowcaseComponent } from './menu-showcase/menu-showcase.component'; export const routes: Routes = [ { @@ -301,6 +302,10 @@ export const routes: Routes = [ path: 'accordion', component: AccordionShowcaseComponent, }, + { + path: 'menu', + component: MenuShowcaseComponent, + }, { path: 'radio', component: RadioShowcaseComponent, diff --git a/apps/cookbook/tsconfig.json b/apps/cookbook/tsconfig.json index 72b785ab5d..73e9f6fa65 100644 --- a/apps/cookbook/tsconfig.json +++ b/apps/cookbook/tsconfig.json @@ -61,7 +61,8 @@ "@kirbydesign/designsystem/grid": ["libs/designsystem/grid/index.ts"], "@kirbydesign/designsystem/alert-experimental": [ "libs/designsystem/alert-experimental/index.ts" - ] + ], + "@kirbydesign/designsystem/menu": ["libs/designsystem/menu/index.ts"] }, "target": "es2020" }, diff --git a/apps/flows/tsconfig.json b/apps/flows/tsconfig.json index 94da5a4665..dbaf43bdaa 100644 --- a/apps/flows/tsconfig.json +++ b/apps/flows/tsconfig.json @@ -79,7 +79,8 @@ "@kirbydesign/designsystem/grid": ["libs/designsystem/grid/index.ts"], "@kirbydesign/designsystem/alert-experimental": [ "libs/designsystem/alert-experimental/index.ts" - ] + ], + "@kirbydesign/designsystem/menu": ["libs/designsystem/menu/index.ts"] }, "target": "es2020" }, diff --git a/libs/designsystem/button/src/button.component.ts b/libs/designsystem/button/src/button.component.ts index c35647ae7c..64a74b0897 100644 --- a/libs/designsystem/button/src/button.component.ts +++ b/libs/designsystem/button/src/button.component.ts @@ -21,6 +21,8 @@ export enum ButtonSize { LG = 'lg', } +export type AttentionLevel = '1' | '2' | '3' | '4'; + const ATTENTION_LEVEL_4_DEPRECATION_WARNING = 'Deprecation warning: The "kirby-button" support for using input property "attentionLevel" with the value "4" will be removed in a future release of Kirby designsystem. While deprecated, all attention-level 4 buttons will be rendered as attention-level 3.'; @@ -40,7 +42,7 @@ export class ButtonComponent implements AfterContentInit { isAttentionLevel2: boolean; @HostBinding('class.attention-level3') isAttentionLevel3: boolean; - @Input() set attentionLevel(level: '1' | '2' | '3' | '4') { + @Input() set attentionLevel(level: AttentionLevel) { this.isAttentionLevel1 = level === '1'; this.isAttentionLevel2 = level === '2'; this.isAttentionLevel3 = level === '3' || level === '4'; diff --git a/libs/designsystem/button/src/public_api.ts b/libs/designsystem/button/src/public_api.ts index 643ee819f0..f57e4d3a7a 100644 --- a/libs/designsystem/button/src/public_api.ts +++ b/libs/designsystem/button/src/public_api.ts @@ -1 +1 @@ -export * from './button.component'; +export { ButtonComponent, ButtonSize, AttentionLevel } from './button.component'; diff --git a/libs/designsystem/dropdown/src/dropdown.component.spec.ts b/libs/designsystem/dropdown/src/dropdown.component.spec.ts index eead201042..655ff62b94 100644 --- a/libs/designsystem/dropdown/src/dropdown.component.spec.ts +++ b/libs/designsystem/dropdown/src/dropdown.component.spec.ts @@ -10,8 +10,8 @@ import { IconComponent } from '@kirbydesign/designsystem/icon'; import { ItemComponent } from '@kirbydesign/designsystem/item'; import { HorizontalDirection, PopoverComponent } from '@kirbydesign/designsystem/popover'; import { ListItemTemplateDirective } from '@kirbydesign/designsystem/list'; - import { ButtonComponent } from '@kirbydesign/designsystem/button'; + import { DropdownComponent } from './dropdown.component'; import { OpenState } from './dropdown.types'; diff --git a/libs/designsystem/dropdown/src/dropdown.component.ts b/libs/designsystem/dropdown/src/dropdown.component.ts index 944504c522..af895b353c 100644 --- a/libs/designsystem/dropdown/src/dropdown.component.ts +++ b/libs/designsystem/dropdown/src/dropdown.component.ts @@ -20,13 +20,12 @@ import { ViewChildren, } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { ButtonComponent } from '@kirbydesign/designsystem/button'; import { CardComponent } from '@kirbydesign/designsystem/card'; import { DesignTokenHelper } from '@kirbydesign/designsystem/helpers'; -import { IconModule } from '@kirbydesign/designsystem/icon'; import { ItemComponent } from '@kirbydesign/designsystem/item'; import { ListItemTemplateDirective } from '@kirbydesign/designsystem/list'; import { HorizontalDirection, PopoverComponent } from '@kirbydesign/designsystem/popover'; +import { ButtonComponent } from '@kirbydesign/designsystem/button'; import { OpenState, VerticalDirection } from './dropdown.types'; import { KeyboardHandlerService } from './keyboard-handler.service'; diff --git a/libs/designsystem/dropdown/src/dropdown.module.ts b/libs/designsystem/dropdown/src/dropdown.module.ts index fd1d567b47..465d6bf398 100644 --- a/libs/designsystem/dropdown/src/dropdown.module.ts +++ b/libs/designsystem/dropdown/src/dropdown.module.ts @@ -1,11 +1,13 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; + import { ButtonComponent } from '@kirbydesign/designsystem/button'; import { CardModule } from '@kirbydesign/designsystem/card'; import { FormFieldModule } from '@kirbydesign/designsystem/form-field'; import { IconModule } from '@kirbydesign/designsystem/icon'; import { ItemModule } from '@kirbydesign/designsystem/item'; import { PopoverComponent } from '@kirbydesign/designsystem/popover'; + import { DropdownComponent } from './dropdown.component'; import { KeyboardHandlerService } from './keyboard-handler.service'; diff --git a/libs/designsystem/grid/ng-package.json b/libs/designsystem/grid/ng-package.json index 9e26dfeeb6..0967ef424b 100644 --- a/libs/designsystem/grid/ng-package.json +++ b/libs/designsystem/grid/ng-package.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/libs/designsystem/item/item-in-menu.integration.spec.ts b/libs/designsystem/item/item-in-menu.integration.spec.ts new file mode 100644 index 0000000000..4ce606ee3e --- /dev/null +++ b/libs/designsystem/item/item-in-menu.integration.spec.ts @@ -0,0 +1,71 @@ +import { IonicModule } from '@ionic/angular'; +import { createHostFactory, SpectatorHost } from '@ngneat/spectator'; +import { MockModule } from 'ng-mocks'; +import { IconModule } from '@kirbydesign/designsystem/icon'; +import { MenuComponent } from '../menu'; +import { ItemModule } from './src'; + +describe('ItemComponent in a MenuComponent', () => { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + imports: [IonicModule, MockModule(IconModule), ItemModule], + component: MenuComponent, + }); + + describe('when default', () => { + beforeEach(() => { + spectator = createHost(` + + Value + + `); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should have size sm as default', () => { + spectator.detectChanges(); + const ionItem = spectator.query('ion-item'); + + expect(ionItem).toBeTruthy(); + expect(ionItem).toHaveComputedStyle({ + '--min-height': '44px', + }); + }); + }); + + describe(`when item size is md`, () => { + beforeEach(() => { + spectator = createHost(`Value`); + }); + + it(`should have ion-item '--min-height 56px'`, () => { + spectator.detectChanges(); + const ionItem = spectator.query('ion-item'); + + expect(ionItem).toBeTruthy(); + expect(spectator.query('ion-item')).toHaveComputedStyle({ + '--min-height': '56px', + }); + }); + }); + + describe(`when item size is xs`, () => { + beforeEach(() => { + spectator = createHost(`Value`); + }); + + it(`should have ion-item '--min-height 32px'`, () => { + spectator.detectChanges(); + const ionItem = spectator.query('ion-item'); + + expect(ionItem).toBeTruthy(); + expect(spectator.query('ion-item')).toHaveComputedStyle({ + '--min-height': '32px', + }); + }); + }); +}); diff --git a/libs/designsystem/item/src/item.component.scss b/libs/designsystem/item/src/item.component.scss index d23b0421c1..bd9bf17669 100644 --- a/libs/designsystem/item/src/item.component.scss +++ b/libs/designsystem/item/src/item.component.scss @@ -69,8 +69,12 @@ --transition: #{interaction-state.transition('background-color')}; } - &.sm ion-item { - --min-height: #{map.get(utils.$item-heights, 's')}; + &.sm, + :host-context(kirby-menu) :host-context(kirby-item):not([size]) + { + ion-item { + --min-height: #{map.get(utils.$item-heights, 's')}; + } } &.xs ion-item { @@ -153,4 +157,4 @@ border-top-left-radius: utils.$border-radius; border-top-right-radius: utils.$border-radius; } -} +} \ No newline at end of file diff --git a/libs/designsystem/menu/index.ts b/libs/designsystem/menu/index.ts new file mode 100644 index 0000000000..cba1843545 --- /dev/null +++ b/libs/designsystem/menu/index.ts @@ -0,0 +1 @@ +export * from './src/index'; diff --git a/libs/designsystem/menu/ng-package.json b/libs/designsystem/menu/ng-package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/libs/designsystem/menu/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/libs/designsystem/menu/src/index.ts b/libs/designsystem/menu/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/designsystem/menu/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/designsystem/menu/src/menu.component.html b/libs/designsystem/menu/src/menu.component.html new file mode 100644 index 0000000000..8c319960bb --- /dev/null +++ b/libs/designsystem/menu/src/menu.component.html @@ -0,0 +1,34 @@ +
+ + + + +
+ + + diff --git a/libs/designsystem/menu/src/menu.component.scss b/libs/designsystem/menu/src/menu.component.scss new file mode 100644 index 0000000000..db92692d40 --- /dev/null +++ b/libs/designsystem/menu/src/menu.component.scss @@ -0,0 +1,22 @@ +@use '@kirbydesign/core/src/scss/utils'; +@use '@kirbydesign/core/src/scss/interaction-state/index'; + +$dropdown-max-height: 8 * utils.$dropdown-item-height; +$min-screen-width: 240px; +$max-screen-width-small: 460px; + +:host { + position: relative; +} + +.button-container { + display: inline-block; +} + +kirby-card { + max-height: $dropdown-max-height; + overflow-y: auto; + box-shadow: utils.get-elevation(8); + min-width: $min-screen-width; + max-width: $max-screen-width-small; +} diff --git a/libs/designsystem/menu/src/menu.component.spec.ts b/libs/designsystem/menu/src/menu.component.spec.ts new file mode 100644 index 0000000000..1aad630e3d --- /dev/null +++ b/libs/designsystem/menu/src/menu.component.spec.ts @@ -0,0 +1,300 @@ +import { createHostFactory, Spectator } from '@ngneat/spectator'; +import { MockComponent } from 'ng-mocks'; +import { IconComponent, IconModule } from '@kirbydesign/designsystem/icon'; +import { ButtonComponent } from '@kirbydesign/designsystem/button'; +import { FloatingDirective } from '@kirbydesign/designsystem/shared/floating'; +import { CardModule } from '@kirbydesign/designsystem/card'; +import { ToggleComponent } from '@kirbydesign/designsystem/toggle'; +import { ItemModule } from '@kirbydesign/designsystem/item'; +import { MenuComponent } from './menu.component'; + +describe('MenuListComponent', () => { + let spectator: Spectator; + let buttonElement: HTMLButtonElement; + let card: Element; + let buttonIcon: IconComponent; + + const createHost = createHostFactory({ + component: MenuComponent, + imports: [IconModule, CardModule, ItemModule], + declarations: [ + FloatingDirective, + MockComponent(ButtonComponent), + MockComponent(ToggleComponent), + ], + }); + + describe('by default', () => { + beforeEach(() => { + spectator = createHost(``, {}); + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); + buttonIcon = spectator.query(IconComponent); + }); + + describe('component', () => { + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should not be disabled by default', () => { + expect(spectator.component.isDisabled).toBeFalsy(); + }); + + it('should have size button size md by default', () => { + expect(spectator.component.buttonSize).toEqual('md'); + }); + + it('should have placement bottom-start as default', () => { + expect(spectator.component.placement).toEqual('bottom-start'); + }); + + it('should have autoPlacement disabled by default', () => { + expect(spectator.component.autoPlacement).toBeFalsy(); + }); + + it('should have close on select be true as default', () => { + expect(spectator.component.closeOnSelect).toBeFalsy(); + }); + + it('should have close on escape key be true as default', () => { + expect(spectator.component.closeOnSelect).toBeFalsy(); + }); + + it('should have close on backdrop be true as default', () => { + expect(spectator.component.closeOnSelect).toBeFalsy(); + }); + }); + + describe('button', () => { + it('should render', () => { + expect(buttonElement).toBeTruthy(); + }); + + it('should not render button as disabled ', () => { + expect(buttonElement.disabled).toBeFalsy(); + }); + + it('should not render disabled attribute on button', () => { + expect(buttonElement.attributes['disabled']).toBeUndefined(); + }); + + it('should have type="button" attribute', () => { + expect(buttonElement).toHaveAttribute('type', 'button'); + }); + }); + + describe('button-icon', () => { + it('should render', () => { + expect(buttonIcon).toBeTruthy(); + }); + + it('should have icon more', () => { + expect(buttonIcon).toHaveAttribute('name', 'more'); + }); + }); + + describe('content', () => { + it('should exist', () => { + expect(card).toBeTruthy(); + }); + + it('should have floatingDirective', () => { + expect(card.attributes['kirbyFloating']).toBeDefined(); + }); + }); + + describe('when', () => { + describe('component configured with isDisabled set to true', () => { + beforeEach(() => { + spectator.setInput('isDisabled', true); + }); + + it('should render button as disabled ', () => { + expect(buttonElement.disabled).toBeTruthy(); + }); + + it('should render disabled attribute on button', () => { + expect(buttonElement.attributes['disabled']).toBeDefined(); + }); + }); + }); + + describe('min-width', () => { + it('should have default min-width', () => { + expect(card).toHaveComputedStyle({ 'min-width': '240px' }); + }); + + it('should have min-width set to 300px', () => { + spectator.setInput('minWidth', 300); + expect(card).toHaveComputedStyle({ 'min-width': '300px' }); + }); + }); + }); + + describe('interaction', () => { + beforeEach(() => { + spectator = createHost( + ` + +

Action 1

+
+
`, + {} + ); + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); + buttonIcon = spectator.query(IconComponent); + }); + + it('should open menu when button is clicked', async () => { + expect(card).toHaveComputedStyle({ display: 'none' }); + + await spectator.click(buttonElement); + + expect(card).toHaveComputedStyle({ display: 'block' }); + }); + + it('should open and then close menu when button is clicked twice', async () => { + expect(card).toHaveComputedStyle({ display: 'none' }); + + await spectator.click(buttonElement); + expect(card).toHaveComputedStyle({ display: 'block' }); + + await spectator.click(buttonElement); + + expect(card).toHaveComputedStyle({ display: 'none' }); + }); + + it('should not open when the menu is disabled', async () => { + spectator.setInput('isDisabled', true); + + await spectator.click(buttonElement); + + expect(card).toHaveComputedStyle({ display: 'none' }); + }); + + it('should close the menu when pressing escape', async () => { + await spectator.click(buttonElement); + + expect(card).toHaveComputedStyle({ display: 'block' }); + + spectator.dispatchKeyboardEvent(buttonElement, 'keydown', 'Escape'); + + expect(card).toHaveComputedStyle({ display: 'none' }); + }); + + it('should not close the menu when pressing escape and closeOnEscapeKey is false', async () => { + spectator.setInput('closeOnEscapeKey', false); + + await spectator.click(buttonElement); + + expect(card).toHaveComputedStyle({ display: 'block' }); + + spectator.dispatchKeyboardEvent(buttonElement, 'keydown', 'Escape'); + + expect(card).toHaveComputedStyle({ display: 'block' }); + }); + + it('should not close when selecting an item and closeOnSelect is false', async () => { + spectator.setInput('closeOnSelect', false); + expect(card).toHaveComputedStyle({ display: 'none' }); + + await spectator.click(buttonElement); + expect(card).toHaveComputedStyle({ display: 'block' }); + + await spectator.click(spectator.query('kirby-item')); + expect(card).toHaveComputedStyle({ display: 'block' }); + }); + }); + + describe('custom button', () => { + beforeEach(() => { + spectator = createHost( + ` + + + `, + {} + ); + buttonIcon = spectator.query(IconComponent); + }); + + it('should render a custom button if provided', () => { + expect(buttonIcon).toHaveAttribute('name', 'menu-outline'); + }); + }); + + describe('advanced items', () => { + let toggle: ToggleComponent; + beforeEach(() => { + spectator = createHost( + ` + + +

Title

+ +
+
`, + {} + ); + buttonElement = spectator.query('button'); + toggle = spectator.query(ToggleComponent); + }); + + it('should render an advanced kirby item, with interactive elements inside', () => { + expect(toggle).toBeTruthy(); + }); + }); + + describe('trigger: default(click)', () => { + beforeEach(() => { + spectator = createHost( + ` + +

Action 1

+
+
`, + {} + ); + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); + buttonIcon = spectator.query(IconComponent); + }); + + it('should open menu when button is clicked', async () => { + expect(card).toHaveComputedStyle({ display: 'none' }); + + await spectator.click(buttonElement); + + expect(card).toHaveComputedStyle({ display: 'block' }); + }); + }); + + describe('trigger: hover', () => { + beforeEach(() => { + spectator = createHost( + ` + +

Action 1

+
+
`, + {} + ); + + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); + buttonIcon = spectator.query(IconComponent); + }); + + it('should open menu when button is hovered', () => { + expect(card).toHaveComputedStyle({ display: 'none' }); + + spectator.dispatchMouseEvent(buttonElement, 'mouseenter'); + + expect(card).toHaveComputedStyle({ display: 'block' }); + }); + }); +}); diff --git a/libs/designsystem/menu/src/menu.component.ts b/libs/designsystem/menu/src/menu.component.ts new file mode 100644 index 0000000000..e64ad5867b --- /dev/null +++ b/libs/designsystem/menu/src/menu.component.ts @@ -0,0 +1,78 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChild, + ElementRef, + Input, + ViewChild, +} from '@angular/core'; +import { Placement } from '@floating-ui/dom'; + +import { ItemModule } from '@kirbydesign/designsystem/item'; +import { CardModule } from '@kirbydesign/designsystem/card'; +import { IconModule } from '@kirbydesign/designsystem/icon'; +import { AttentionLevel, ButtonComponent, ButtonSize } from '@kirbydesign/designsystem/button'; +import { + FloatingDirective, + FloatingOffset, + PortalOutletConfig, + TriggerEvent, +} from '@kirbydesign/designsystem/shared/floating'; + +@Component({ + selector: 'kirby-menu', + standalone: true, + imports: [ButtonComponent, CommonModule, FloatingDirective, IconModule, CardModule, ItemModule], + templateUrl: './menu.component.html', + styleUrls: ['./menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuComponent implements AfterViewInit { + @Input() public isDisabled: boolean = false; + + @Input() public buttonSize: ButtonSize = ButtonSize.MD; + + @Input() public placement: Placement = 'bottom-start'; + + @Input() public attentionLevel: AttentionLevel = '3'; + + @Input() public triggers: Array = ['click']; + + @Input() public DOMPortalOutlet: HTMLElement | undefined; + + @Input() public portalOutletConfig: PortalOutletConfig | undefined; + + @Input() public autoPlacement: boolean = false; + + @Input() public closeOnSelect: boolean = false; + + @Input() public closeOnEscapeKey: boolean = true; + + @Input() public closeOnBackdrop: boolean = true; + + /** + * The minimum width of the menu. If not set, the default width is 240px + */ + @Input() public minWidth: number; + + @ViewChild('buttonContainer', { read: ElementRef }) + public buttonContainerElement: ElementRef | undefined; + + @ViewChild('defaultButton', { read: ElementRef }) + public defaultButtonElement: ElementRef | undefined; + + @ContentChild(ButtonComponent, { read: ElementRef }) public userProvidedButton: + | ElementRef + | undefined; + + public FloatingOffset: typeof FloatingOffset = FloatingOffset; + + constructor(private cdf: ChangeDetectorRef) {} + + public ngAfterViewInit(): void { + this.cdf.detectChanges(); // Sets the updated reference for kirby-floating + } +} diff --git a/libs/designsystem/menu/src/public_api.ts b/libs/designsystem/menu/src/public_api.ts new file mode 100644 index 0000000000..2c741a7037 --- /dev/null +++ b/libs/designsystem/menu/src/public_api.ts @@ -0,0 +1 @@ +export { MenuComponent } from './menu.component'; diff --git a/libs/designsystem/shared/floating/src/floating.directive.spec.ts b/libs/designsystem/shared/floating/src/floating.directive.spec.ts index 3a57773a16..a556bf464d 100644 --- a/libs/designsystem/shared/floating/src/floating.directive.spec.ts +++ b/libs/designsystem/shared/floating/src/floating.directive.spec.ts @@ -1,14 +1,14 @@ import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator'; import { PortalDirective } from '@kirbydesign/designsystem/shared/portal'; -import { TriggerEvent } from '@kirbydesign/designsystem/shared/floating'; -import { FloatingDirective } from './floating.directive'; +import { PortalOutletConfig, TriggerEvent } from '@kirbydesign/designsystem/shared/floating'; import * as floatingUi from '@floating-ui/dom'; -import { Placement } from '@floating-ui/core/src/types'; import { Strategy } from '@floating-ui/dom'; -import any = jasmine.any; +import { Placement } from '@floating-ui/core/src/types'; import { DesignTokenHelper } from '@kirbydesign/core'; +import { FloatingDirective, OutletSelector } from './floating.directive'; +import any = jasmine.any; @Component({ template: ` @@ -25,6 +25,10 @@ import { DesignTokenHelper } from '@kirbydesign/core'; [placement]="placement" >
+
+
+
+

   `,
   imports: [FloatingDirective],
   standalone: true,
@@ -33,6 +37,10 @@ class FloatingTestComponent {
   @ViewChild('floatingElement', { static: true }) public floatingElementRef: ElementRef;
   @ViewChild('hostElement', { static: true }) public hostElementRef: ElementRef;
   @ViewChild('clickTarget', { static: true }) public clickTargetRef: ElementRef;
+  @ViewChild('idTarget', { static: true }) public idTargetRef: ElementRef;
+  @ViewChild('classTarget', { static: true }) public classTargetRef: ElementRef;
+  @ViewChild('nameTarget', { static: true }) public nameTargetRef: ElementRef;
+  @ViewChild('tagTarget', { static: true }) public tagTargetRef: ElementRef;
   @ViewChild(FloatingDirective, { static: true }) public floatingDirective: FloatingDirective;
 
   /** Start values set to match directive */
@@ -75,7 +83,7 @@ describe('FloatingDirective', () => {
       it('should not add event listeners when only reference is set without triggers', () => {
         directive.triggers = null;
         directive.reference = component.floatingElementRef;
-        expect(directive['eventListeners']).toHaveLength(0);
+        expect(directive['referenceEventListenerDisposeFns']).toHaveLength(0);
       });
     });
 
@@ -105,25 +113,79 @@ describe('FloatingDirective', () => {
       it('should not add event listeners when only triggers is set without reference', () => {
         directive.reference = null;
         directive.triggers = ['hover'];
-        expect(directive['eventListenerDisposeFns']).toHaveLength(0);
+        expect(directive['referenceEventListenerDisposeFns']).toHaveLength(0);
       });
 
       it('should add event listeners for click event when reference and triggers is set', () => {
         directive.triggers = ['click'];
         directive.reference = component.floatingElementRef;
-        expect(directive['eventListenerDisposeFns']).toHaveLength(1);
+        expect(directive['referenceEventListenerDisposeFns']).toHaveLength(1);
       });
 
       it('should add event listeners for hover event when reference and triggers is set', () => {
         directive.triggers = ['hover'];
         directive.reference = component.floatingElementRef;
-        expect(directive['eventListenerDisposeFns']).toHaveLength(2);
+        expect(directive['referenceEventListenerDisposeFns']).toHaveLength(2);
       });
 
       it('should add event listeners for click event when reference and triggers is set', () => {
         directive.triggers = ['focus'];
         directive.reference = component.floatingElementRef;
-        expect(directive['eventListenerDisposeFns']).toHaveLength(2);
+        expect(directive['referenceEventListenerDisposeFns']).toHaveLength(2);
+      });
+    });
+
+    describe('portalOutletConfig', () => {
+      it('should not change outlet, if outlet is set by providedPortalOutlet', () => {
+        const config: PortalOutletConfig = {
+          selector: OutletSelector.tag,
+          value: 'tagTarget',
+        };
+
+        const expectedPortalOutlet: HTMLElement = component.classTargetRef.nativeElement;
+        directive['_providedPortalOutlet'] = expectedPortalOutlet;
+        directive.portalOutletConfig = config;
+        expect(directive.DOMPortalOutlet).toEqual(expectedPortalOutlet);
+      });
+
+      it('should set outlet to tagTarget', () => {
+        const config: PortalOutletConfig = {
+          selector: OutletSelector.tag,
+          value: 'pre',
+        };
+        const expectedPortalOutlet: HTMLElement = component.tagTargetRef.nativeElement;
+        directive.portalOutletConfig = config;
+        expect(directive['portalDirective'].outlet).toEqual(expectedPortalOutlet);
+      });
+
+      it('should set outlet to classTarget', () => {
+        const config: PortalOutletConfig = {
+          selector: OutletSelector.class,
+          value: 'classTarget',
+        };
+        const expectedPortalOutlet: HTMLElement = component.classTargetRef.nativeElement;
+        directive.portalOutletConfig = config;
+        expect(directive['portalDirective'].outlet).toEqual(expectedPortalOutlet);
+      });
+
+      it('should set outlet to idTarget', () => {
+        const config: PortalOutletConfig = {
+          selector: OutletSelector.id,
+          value: 'idTarget',
+        };
+        const expectedPortalOutlet: HTMLElement = component.idTargetRef.nativeElement;
+        directive.portalOutletConfig = config;
+        expect(directive['portalDirective'].outlet).toEqual(expectedPortalOutlet);
+      });
+
+      it('should set outlet to nameTarget', () => {
+        const config: PortalOutletConfig = {
+          selector: OutletSelector.name,
+          value: 'nameTarget',
+        };
+        const expectedPortalOutlet: HTMLElement = component.nameTargetRef.nativeElement;
+        directive.portalOutletConfig = config;
+        expect(directive['portalDirective'].outlet).toEqual(expectedPortalOutlet);
       });
     });
   });
@@ -143,41 +205,42 @@ describe('FloatingDirective', () => {
         spyOnProperty(floatingUi, 'computePosition', 'get').and.returnValue(computePositionFuncSpy);
       });
 
-      it('should add style left to host element', () => {
-        directive.ngOnInit();
-        expect(component.hostElementRef.nativeElement).toHaveComputedStyle({ left: '0px' });
-      });
+      describe('add styling', () => {
+        it('should add style left to host element', () => {
+          directive.ngOnInit();
+          expect(component.hostElementRef.nativeElement).toHaveComputedStyle({ left: '0px' });
+        });
 
-      it('should add style top to host element', () => {
-        directive.ngOnInit();
-        expect(component.hostElementRef.nativeElement).toHaveComputedStyle({ top: '0px' });
-      });
+        it('should add style top to host element', () => {
+          directive.ngOnInit();
+          expect(component.hostElementRef.nativeElement).toHaveComputedStyle({ top: '0px' });
+        });
 
-      it('should add style position from strategy input to host element - strategy absolute', () => {
-        const strategy: Strategy = 'absolute';
-        spectator.setInput('strategy', strategy);
-        directive.ngOnInit();
-        expect(component.hostElementRef.nativeElement).toHaveComputedStyle({
-          position: strategy,
+        it('should add style position from strategy input to host element - strategy absolute', () => {
+          const strategy: Strategy = 'absolute';
+          spectator.setInput('strategy', strategy);
+          directive.ngOnInit();
+          expect(component.hostElementRef.nativeElement).toHaveComputedStyle({
+            position: strategy,
+          });
         });
-      });
 
-      it('should add style position from strategy input to host element - strategy fixed', () => {
-        const strategy: Strategy = 'fixed';
-        spectator.setInput('strategy', strategy);
-        directive.ngOnInit();
-        expect(component.hostElementRef.nativeElement).toHaveComputedStyle({
-          position: strategy,
+        it('should add style position from strategy input to host element - strategy fixed', () => {
+          const strategy: Strategy = 'fixed';
+          spectator.setInput('strategy', strategy);
+          directive.ngOnInit();
+          expect(component.hostElementRef.nativeElement).toHaveComputedStyle({
+            position: strategy,
+          });
         });
-      });
 
-      it('should add style top to host element', () => {
-        directive.ngOnInit();
-        expect(component.hostElementRef.nativeElement).toHaveComputedStyle({
-          'z-index': DesignTokenHelper.zLayer('popover'),
+        it('should add style top to host element', () => {
+          directive.ngOnInit();
+          expect(component.hostElementRef.nativeElement).toHaveComputedStyle({
+            'z-index': DesignTokenHelper.zLayer('popover'),
+          });
         });
       });
-
       describe('autoUpdatePosition', () => {
         it('should call floating-ui autoUpdate', () => {
           spectator.detectChanges();
@@ -269,7 +332,7 @@ describe('FloatingDirective', () => {
           const isShown: boolean = true;
           directive['isShown'] = isShown;
           spectator.setInput('closeOnSelect', true);
-          directive.onMouseClick(event);
+          directive['onDocumentClickWhileHostShown'](event);
           expect(directive['isShown']).toBeFalse();
         });
 
@@ -277,7 +340,7 @@ describe('FloatingDirective', () => {
           const isShown: boolean = false;
           directive['isShown'] = isShown;
           spectator.setInput('closeOnSelect', false);
-          directive.onMouseClick(event);
+          directive['onDocumentClickWhileHostShown'](event);
           expect(directive['isShown']).toEqual(isShown);
         });
 
@@ -285,7 +348,7 @@ describe('FloatingDirective', () => {
           const isShown: boolean = true;
           directive['isShown'] = isShown;
           spectator.setInput('closeOnSelect', false);
-          directive.onMouseClick(event);
+          directive['onDocumentClickWhileHostShown'](event);
           expect(directive['isShown']).toEqual(isShown);
         });
       });
@@ -301,7 +364,7 @@ describe('FloatingDirective', () => {
           const isShown: boolean = true;
           directive['isShown'] = isShown;
           spectator.setInput('closeOnBackdrop', true);
-          directive.onMouseClick(event);
+          directive['onDocumentClickWhileHostShown'](event);
           expect(directive['isShown']).toBeFalse();
         });
 
@@ -309,7 +372,7 @@ describe('FloatingDirective', () => {
           const isShown: boolean = false;
           directive['isShown'] = isShown;
           spectator.setInput('closeOnBackdrop', false);
-          directive.onMouseClick(event);
+          directive['onDocumentClickWhileHostShown'](event);
           expect(directive['isShown']).toEqual(isShown);
         });
 
@@ -317,7 +380,7 @@ describe('FloatingDirective', () => {
           const isShown: boolean = true;
           directive['isShown'] = isShown;
           spectator.setInput('closeOnBackdrop', false);
-          directive.onMouseClick(event);
+          directive['onDocumentClickWhileHostShown'](event);
           expect(directive['isShown']).toEqual(isShown);
         });
       });
diff --git a/libs/designsystem/shared/floating/src/floating.directive.ts b/libs/designsystem/shared/floating/src/floating.directive.ts
index 905d412d66..36bc53c47e 100644
--- a/libs/designsystem/shared/floating/src/floating.directive.ts
+++ b/libs/designsystem/shared/floating/src/floating.directive.ts
@@ -30,6 +30,18 @@ export enum FloatingOffset {
   medium = 8,
 }
 
+export enum OutletSelector {
+  tag = 'tag',
+  id = 'id',
+  class = 'class',
+  name = 'name',
+}
+
+export interface PortalOutletConfig {
+  selector: OutletSelector;
+  value: string;
+}
+
 interface EventMethods {
   event: string;
   method: () => void;
@@ -56,7 +68,7 @@ export class FloatingDirective implements OnInit, OnDestroy {
    * Reference to the element for which the host should anchor to
    * */
   @Input() public set reference(ref: ElementRef) {
-    this.tearDownEventHandling();
+    this.tearDownReferenceElementEventHandling();
     this._reference = ref;
     this.setupEventHandling();
     this.autoUpdatePosition();
@@ -97,7 +109,7 @@ export class FloatingDirective implements OnInit, OnDestroy {
    * */
   @Input() public set triggers(eventTriggers: Array) {
     this._triggers = eventTriggers;
-    this.tearDownEventHandling();
+    this.tearDownReferenceElementEventHandling();
     this.setupEventHandling();
   }
 
@@ -111,7 +123,29 @@ export class FloatingDirective implements OnInit, OnDestroy {
    * This should be used when there's issues with the stacking context, and not as a default option.
    * */
   @Input() public set DOMPortalOutlet(outlet: HTMLElement | undefined) {
-    this.portalDirective.outlet = outlet;
+    this._providedPortalOutlet = outlet;
+    this.portalDirective.outlet =
+      this.DOMPortalOutlet ?? this.getOutletElement(this.portalOutletConfig);
+  }
+
+  public get DOMPortalOutlet(): HTMLElement | undefined {
+    return this._providedPortalOutlet;
+  }
+
+  /**
+   * Defines how to automatically find and assign DOMPortalOutlet if none is provided in DOMPortalOutlet.
+   * If nothing is provided here and in DOMPortalOutlet, the provided strategy is used
+   * */
+  @Input() public set portalOutletConfig(config: PortalOutletConfig | undefined) {
+    this._portalOutletConfig = config;
+
+    if (!this.DOMPortalOutlet) {
+      this.portalDirective.outlet = this.getOutletElement(config);
+    }
+  }
+
+  public get portalOutletConfig(): PortalOutletConfig | undefined {
+    return this._portalOutletConfig;
   }
 
   /**
@@ -154,27 +188,22 @@ export class FloatingDirective implements OnInit, OnDestroy {
     }
   }
 
-  @HostListener('document:mousedown', ['$event'])
-  public onMouseClick(event: Event): void {
-    const clickedOnHost: boolean = this.elementRef.nativeElement.contains(event.target);
-    if (clickedOnHost) {
-      this.handleClickInsideHostElement();
-    } else {
-      this.handleClickOutsideHostElement(event);
-    }
-  }
-
   private _placement: Placement = 'bottom-start';
 
   private _strategy: Strategy = 'absolute';
 
+  private _providedPortalOutlet: HTMLElement | undefined;
+
+  private _portalOutletConfig: PortalOutletConfig | undefined;
+
   private _triggers: Array = ['click'];
 
   private _reference: ElementRef | undefined;
 
   private autoUpdaterRef: () => void;
   private isShown: boolean = false;
-  private eventListenerDisposeFns: EventListenerDisposeFn[] = [];
+  private referenceEventListenerDisposeFns: EventListenerDisposeFn[] = [];
+  private documentClickEventListenerDisposeFn: EventListenerDisposeFn;
   private triggerEventMap: Map = new Map([
     ['click', [{ event: 'click', method: this.toggleShow.bind(this) }]],
     [
@@ -193,6 +222,16 @@ export class FloatingDirective implements OnInit, OnDestroy {
     ],
   ]);
 
+  private HTMLElements: {
+    [key in OutletSelector | 'default']: (value?: string) => Array | null;
+  } = {
+    id: (value) => Array.of(document.getElementById(value)),
+    class: (value) => Array.from(document.getElementsByClassName(value)),
+    name: (value) => Array.from(document.getElementsByName(value)),
+    tag: (value) => Array.from(document.getElementsByTagName(value)),
+    default: () => null,
+  };
+
   public constructor(
     private elementRef: ElementRef,
     private renderer: Renderer2,
@@ -210,6 +249,7 @@ export class FloatingDirective implements OnInit, OnDestroy {
       return;
     }
 
+    this.attachDocumentClickEvent();
     this.renderer.setStyle(this.elementRef.nativeElement, 'display', 'block');
     this.isShown = true;
     this.displayChanged.emit(this.isShown);
@@ -221,6 +261,7 @@ export class FloatingDirective implements OnInit, OnDestroy {
       return;
     }
 
+    this.tearDownDocumentClickEventHandling();
     this.renderer.setStyle(this.elementRef.nativeElement, 'display', 'none');
     this.isShown = false;
     this.displayChanged.emit(this.isShown);
@@ -235,6 +276,15 @@ export class FloatingDirective implements OnInit, OnDestroy {
     }
   }
 
+  private onDocumentClickWhileHostShown(event: Event): void {
+    const clickedOnHost: boolean = this.elementRef.nativeElement.contains(event.target);
+    if (clickedOnHost) {
+      this.handleClickInsideHostElement();
+    } else {
+      this.handleClickOutsideHostElement(event);
+    }
+  }
+
   private addFloatStylingToHostElement(): void {
     this.renderer.setStyle(this.elementRef.nativeElement, 'left', `0px`);
     this.renderer.setStyle(this.elementRef.nativeElement, 'top', `0px`);
@@ -244,6 +294,15 @@ export class FloatingDirective implements OnInit, OnDestroy {
       'z-index',
       DesignTokenHelper.zLayer('popover')
     );
+    this.setDisplayStyling();
+  }
+
+  private setDisplayStyling(): void {
+    this.renderer.setStyle(
+      this.elementRef.nativeElement,
+      'display',
+      this.isShown ? `block` : `none`
+    );
   }
 
   private updateHostElementPosition(): void {
@@ -294,11 +353,7 @@ export class FloatingDirective implements OnInit, OnDestroy {
   private setPositionStylingOnHostElement(xPosition: number, yPosition: number): void {
     this.renderer.setStyle(this.elementRef.nativeElement, 'left', `${xPosition}px`);
     this.renderer.setStyle(this.elementRef.nativeElement, 'top', `${yPosition}px`);
-    this.renderer.setStyle(
-      this.elementRef.nativeElement,
-      'display',
-      this.isShown ? `block` : `none`
-    );
+    this.setDisplayStyling();
   }
 
   private setupEventHandling(): void {
@@ -311,6 +366,18 @@ export class FloatingDirective implements OnInit, OnDestroy {
     );
   }
 
+  private attachDocumentClickEvent(): void {
+    if (this.documentClickEventListenerDisposeFn) {
+      return;
+    }
+
+    this.documentClickEventListenerDisposeFn = this.renderer.listen(
+      'document',
+      'mousedown',
+      (event) => this.onDocumentClickWhileHostShown(event)
+    );
+  }
+
   private attachTriggerEventToReferenceElement(trigger: TriggerEvent): void {
     const events: EventMethods[] | undefined = this.triggerEventMap.get(trigger);
 
@@ -324,7 +391,7 @@ export class FloatingDirective implements OnInit, OnDestroy {
         event.event,
         event.method
       );
-      this.eventListenerDisposeFns.push(eventListenerDisposeFn);
+      this.referenceEventListenerDisposeFns.push(eventListenerDisposeFn);
     });
   }
 
@@ -343,13 +410,49 @@ export class FloatingDirective implements OnInit, OnDestroy {
     }
   }
 
-  private tearDownEventHandling(): void {
-    this.eventListenerDisposeFns.forEach((eventListenerDisposeFunction: EventListenerDisposeFn) => {
-      if (eventListenerDisposeFunction != null) {
-        eventListenerDisposeFunction();
+  private getOutletElement(config: PortalOutletConfig | undefined): HTMLElement | null {
+    if (!config || !config.selector || !config.value) {
+      return null;
+    }
+
+    const elements: Array | null = this.getHTMLElements(config);
+
+    if (!elements || elements.length === 0) {
+      throw Error(`Could not locate HTMLElement for ${config.selector}. Did you misspell it?`);
+    }
+
+    if (elements.length > 1) {
+      throw Error(
+        `Multiple HTMLElements found for ${config.selector}.
+         This can lead to unintended behaviours. Provide an unique outlet`
+      );
+    }
+
+    return elements[0] as HTMLElement;
+  }
+
+  private getHTMLElements(config: PortalOutletConfig | undefined): Array | null {
+    return (
+      this.HTMLElements[config.selector](config.value) || this.HTMLElements['default'](config.value)
+    );
+  }
+
+  private tearDownReferenceElementEventHandling(): void {
+    this.referenceEventListenerDisposeFns.forEach(
+      (eventListenerDisposeFunction: EventListenerDisposeFn) => {
+        if (eventListenerDisposeFunction != null) {
+          eventListenerDisposeFunction();
+        }
       }
-    });
-    this.eventListenerDisposeFns = [];
+    );
+    this.referenceEventListenerDisposeFns = [];
+  }
+
+  private tearDownDocumentClickEventHandling(): void {
+    if (this.documentClickEventListenerDisposeFn) {
+      this.documentClickEventListenerDisposeFn();
+      this.documentClickEventListenerDisposeFn = null;
+    }
   }
 
   private removeAutoUpdaterRef(): void {
@@ -359,7 +462,8 @@ export class FloatingDirective implements OnInit, OnDestroy {
   }
 
   public ngOnDestroy() {
-    this.tearDownEventHandling();
+    this.tearDownDocumentClickEventHandling();
+    this.tearDownReferenceElementEventHandling();
     this.removeAutoUpdaterRef();
   }
 }
diff --git a/libs/designsystem/shared/floating/src/public_api.ts b/libs/designsystem/shared/floating/src/public_api.ts
index cc2aabf0ca..4021a24f50 100644
--- a/libs/designsystem/shared/floating/src/public_api.ts
+++ b/libs/designsystem/shared/floating/src/public_api.ts
@@ -1 +1,7 @@
-export { FloatingDirective, TriggerEvent } from './floating.directive';
+export {
+  FloatingDirective,
+  TriggerEvent,
+  FloatingOffset,
+  PortalOutletConfig,
+  OutletSelector,
+} from './floating.directive';
diff --git a/libs/designsystem/src/lib/kirby.module.ts b/libs/designsystem/src/lib/kirby.module.ts
index b84d8b4895..bc30d5ec9e 100644
--- a/libs/designsystem/src/lib/kirby.module.ts
+++ b/libs/designsystem/src/lib/kirby.module.ts
@@ -72,6 +72,8 @@ import { ReorderListComponent } from '@kirbydesign/designsystem/reorder-list';
 
 import { ToastController, ToastHelper } from '@kirbydesign/designsystem/toast';
 import { BreakpointHelperService, GridComponent } from '@kirbydesign/designsystem/grid';
+
+import { MenuComponent } from '@kirbydesign/designsystem/menu';
 import { TabNavigationModule } from '@kirbydesign/designsystem/tab-navigation';
 import { SegmentedControlComponent } from './components/segmented-control/segmented-control.component';
 import { customElementsInitializer } from './custom-elements-initializer';
@@ -110,6 +112,7 @@ const standaloneComponents = [
   SlideButtonComponent,
   SegmentedControlComponent,
   CheckboxComponent,
+  MenuComponent,
   ActionSheetComponent,
   ModalFooterComponent,
   AvatarComponent,
@@ -162,7 +165,7 @@ const providers = [
   customElementsInitializer(),
 ];
 
-const ConfigToken = new InjectionToken('USERCONFIG');
+const ConfigToken = new InjectionToken('USERCONFIG');
 export interface KirbyConfig {
   moduleRootRoutePath?: string;
 }
diff --git a/libs/designsystem/toast/ng-package.json b/libs/designsystem/toast/ng-package.json
index 9e26dfeeb6..0967ef424b 100644
--- a/libs/designsystem/toast/ng-package.json
+++ b/libs/designsystem/toast/ng-package.json
@@ -1 +1 @@
-{}
\ No newline at end of file
+{}
diff --git a/libs/designsystem/tsconfig.json b/libs/designsystem/tsconfig.json
index 5d897c3963..0ad2c26b9f 100644
--- a/libs/designsystem/tsconfig.json
+++ b/libs/designsystem/tsconfig.json
@@ -52,7 +52,8 @@
       "@kirbydesign/designsystem/data-table": ["data-table/index.ts"],
       "@kirbydesign/designsystem/reorder-list": ["reorder-list/index.ts"],
       "@kirbydesign/designsystem/toast": ["toast/index.ts"],
-      "@kirbydesign/designsystem/alert-experimental": ["alert-experimental/index.ts"]
+      "@kirbydesign/designsystem/alert-experimental": ["alert-experimental/index.ts"],
+      "@kirbydesign/designsystem/menu": ["menu/index.ts"]
     },
     "esModuleInterop": true,
     "target": "es2020"
diff --git a/libs/designsystem/tsconfig.spec.json b/libs/designsystem/tsconfig.spec.json
index b8424fdaa5..57ad74415a 100644
--- a/libs/designsystem/tsconfig.spec.json
+++ b/libs/designsystem/tsconfig.spec.json
@@ -56,7 +56,8 @@
       "@kirbydesign/designsystem/data-table": ["data-table/index.ts"],
       "@kirbydesign/designsystem/reorder-list": ["reorder-list/index.ts"],
       "@kirbydesign/designsystem/toast": ["toast/index.ts"],
-      "@kirbydesign/designsystem/alert-experimental": ["alert-experimental/index.ts"]
+      "@kirbydesign/designsystem/alert-experimental": ["alert-experimental/index.ts"],
+      "@kirbydesign/designsystem/menu": ["menu/index.ts"]
     }
   },
   "files": ["test.ts"],
diff --git a/package-lock.json b/package-lock.json
index d3036f2606..9e8e852dbe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -370,8 +370,7 @@
     },
     "node_modules/@angular-devkit/build-angular/node_modules/@types/estree": {
       "version": "0.0.51",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
-      "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ=="
+      "license": "MIT"
     },
     "node_modules/@angular-devkit/build-angular/node_modules/ajv": {
       "version": "8.11.0",
@@ -389,8 +388,7 @@
     },
     "node_modules/@angular-devkit/build-angular/node_modules/eslint-scope": {
       "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
-      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "license": "BSD-2-Clause",
       "dependencies": {
         "esrecurse": "^4.3.0",
         "estraverse": "^4.1.1"
@@ -401,8 +399,7 @@
     },
     "node_modules/@angular-devkit/build-angular/node_modules/estraverse": {
       "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
-      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "license": "BSD-2-Clause",
       "engines": {
         "node": ">=4.0"
       }
@@ -464,8 +461,7 @@
     },
     "node_modules/@angular-devkit/build-angular/node_modules/schema-utils": {
       "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
-      "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+      "license": "MIT",
       "dependencies": {
         "@types/json-schema": "^7.0.8",
         "ajv": "^6.12.5",
@@ -481,8 +477,7 @@
     },
     "node_modules/@angular-devkit/build-angular/node_modules/schema-utils/node_modules/ajv": {
       "version": "6.12.6",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "license": "MIT",
       "dependencies": {
         "fast-deep-equal": "^3.1.1",
         "fast-json-stable-stringify": "^2.0.0",
@@ -496,21 +491,18 @@
     },
     "node_modules/@angular-devkit/build-angular/node_modules/schema-utils/node_modules/ajv-keywords": {
       "version": "3.5.2",
-      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
-      "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+      "license": "MIT",
       "peerDependencies": {
         "ajv": "^6.9.1"
       }
     },
     "node_modules/@angular-devkit/build-angular/node_modules/schema-utils/node_modules/json-schema-traverse": {
       "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+      "license": "MIT"
     },
     "node_modules/@angular-devkit/build-angular/node_modules/webpack": {
       "version": "5.75.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz",
-      "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==",
+      "license": "MIT",
       "dependencies": {
         "@types/eslint-scope": "^3.7.3",
         "@types/estree": "^0.0.51",
@@ -3056,13 +3048,11 @@
     },
     "node_modules/@floating-ui/core": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz",
-      "integrity": "sha512-zbsLwtnHo84w1Kc8rScAo5GMk1GdecSlrflIbfnEBJwvTSj1SL6kkOYV+nHraMCPEy+RNZZUaZyL8JosDGCtGQ=="
+      "license": "MIT"
     },
     "node_modules/@floating-ui/dom": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.0.tgz",
-      "integrity": "sha512-TSogMPVxbRe77QCj1dt8NmRiJasPvuc+eT5jnJ6YpLqgOD2zXc5UA3S1qwybN+GVCDNdKfpKy1oj8RpzLJvh6A==",
+      "license": "MIT",
       "dependencies": {
         "@floating-ui/core": "^1.0.5"
       }
@@ -29180,8 +29170,7 @@
     },
     "node_modules/webpack": {
       "version": "5.76.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz",
-      "integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==",
+      "license": "MIT",
       "dependencies": {
         "@types/eslint-scope": "^3.7.3",
         "@types/estree": "^0.0.51",
@@ -30110,9 +30099,7 @@
           }
         },
         "@types/estree": {
-          "version": "0.0.51",
-          "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
-          "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ=="
+          "version": "0.0.51"
         },
         "ajv": {
           "version": "8.11.0",
@@ -30125,17 +30112,13 @@
         },
         "eslint-scope": {
           "version": "5.1.1",
-          "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
-          "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
           "requires": {
             "esrecurse": "^4.3.0",
             "estraverse": "^4.1.1"
           }
         },
         "estraverse": {
-          "version": "4.3.0",
-          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
-          "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="
+          "version": "4.3.0"
         },
         "immutable": {
           "version": "4.2.2"
@@ -30169,8 +30152,6 @@
         },
         "schema-utils": {
           "version": "3.1.1",
-          "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
-          "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
           "requires": {
             "@types/json-schema": "^7.0.8",
             "ajv": "^6.12.5",
@@ -30179,8 +30160,6 @@
           "dependencies": {
             "ajv": {
               "version": "6.12.6",
-              "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-              "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
               "requires": {
                 "fast-deep-equal": "^3.1.1",
                 "fast-json-stable-stringify": "^2.0.0",
@@ -30190,21 +30169,15 @@
             },
             "ajv-keywords": {
               "version": "3.5.2",
-              "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
-              "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
               "requires": {}
             },
             "json-schema-traverse": {
-              "version": "0.4.1",
-              "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-              "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+              "version": "0.4.1"
             }
           }
         },
         "webpack": {
           "version": "5.75.0",
-          "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz",
-          "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==",
           "requires": {
             "@types/eslint-scope": "^3.7.3",
             "@types/estree": "^0.0.51",
@@ -31667,14 +31640,10 @@
       }
     },
     "@floating-ui/core": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.0.tgz",
-      "integrity": "sha512-zbsLwtnHo84w1Kc8rScAo5GMk1GdecSlrflIbfnEBJwvTSj1SL6kkOYV+nHraMCPEy+RNZZUaZyL8JosDGCtGQ=="
+      "version": "1.1.0"
     },
     "@floating-ui/dom": {
       "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.0.tgz",
-      "integrity": "sha512-TSogMPVxbRe77QCj1dt8NmRiJasPvuc+eT5jnJ6YpLqgOD2zXc5UA3S1qwybN+GVCDNdKfpKy1oj8RpzLJvh6A==",
       "requires": {
         "@floating-ui/core": "^1.0.5"
       }
@@ -48185,8 +48154,6 @@
     },
     "webpack": {
       "version": "5.76.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz",
-      "integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==",
       "requires": {
         "@types/eslint-scope": "^3.7.3",
         "@types/estree": "^0.0.51",