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 @@ - + {{ expanded ? 'Hide Code' : 'View Code' }} 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",
+ 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 + . +
kirby-item
kirby-button
+ 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. +
This example demonstrates how to set custom actions on the elements in the menu.
+ 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. +
+ 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. +
placement
+ 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. + +
+ 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. +
PortalOutletConfig
HTMLElement
DOMPortalOutlet
+ 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. + + +
+ The + DOMPortalOutlet + input must be a reference to a unique DOM element, such as the + body + . +
body
+ 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. +
kirby-list
+ A menu in a + kirby-list + + without + + DOMPortalOutlet +
+ A menu in a + kirby-list + + with + a + DOMPortalOutlet +
+ The + outletElement + is {{ isOutletElementSet }} +
outletElement
+ 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: