mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
Move to libs
This commit is contained in:
5
libs/components/src/menu/index.ts
Normal file
5
libs/components/src/menu/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./menu.module";
|
||||
export * from "./menu.component";
|
||||
export * from "./menu-trigger-for.directive";
|
||||
export * from "./menu-item.directive";
|
||||
export * from "./menu-divider.component";
|
||||
4
libs/components/src/menu/menu-divider.component.html
Normal file
4
libs/components/src/menu/menu-divider.component.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div
|
||||
class="tw-border-solid tw-border-0 tw-border-t tw-border-t-secondary-500 tw-my-2"
|
||||
role="separator"
|
||||
></div>
|
||||
7
libs/components/src/menu/menu-divider.component.ts
Normal file
7
libs/components/src/menu/menu-divider.component.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-menu-divider",
|
||||
templateUrl: "./menu-divider.component.html",
|
||||
})
|
||||
export class MenuDividerComponent {}
|
||||
36
libs/components/src/menu/menu-item.directive.ts
Normal file
36
libs/components/src/menu/menu-item.directive.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FocusableOption } from "@angular/cdk/a11y";
|
||||
import { Directive, ElementRef, HostBinding } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitMenuItem]",
|
||||
})
|
||||
export class MenuItemDirective implements FocusableOption {
|
||||
@HostBinding("class") classList = [
|
||||
"tw-block",
|
||||
"tw-py-1",
|
||||
"tw-px-4",
|
||||
"!tw-text-main",
|
||||
"!tw-no-underline",
|
||||
"tw-cursor-pointer",
|
||||
"tw-border-none",
|
||||
"tw-bg-background",
|
||||
"tw-text-left",
|
||||
"hover:tw-bg-secondary-100",
|
||||
"focus:tw-bg-secondary-100",
|
||||
"focus:tw-z-50",
|
||||
"focus:tw-outline-none",
|
||||
"focus:tw-ring",
|
||||
"focus:tw-ring-offset-2",
|
||||
"focus:tw-ring-primary-700",
|
||||
"active:!tw-ring-0",
|
||||
"active:!tw-ring-offset-0",
|
||||
];
|
||||
@HostBinding("attr.role") role = "menuitem";
|
||||
@HostBinding("tabIndex") tabIndex = "-1";
|
||||
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
|
||||
focus() {
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
119
libs/components/src/menu/menu-trigger-for.directive.ts
Normal file
119
libs/components/src/menu/menu-trigger-for.directive.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
OnDestroy,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
import { filter, mergeWith } from "rxjs/operators";
|
||||
|
||||
import { MenuComponent } from "./menu.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitMenuTriggerFor]",
|
||||
})
|
||||
export class MenuTriggerForDirective implements OnDestroy {
|
||||
@HostBinding("attr.aria-expanded") isOpen = false;
|
||||
@HostBinding("attr.aria-haspopup") hasPopup = "menu";
|
||||
@HostBinding("attr.role") role = "button";
|
||||
|
||||
@Input("bitMenuTriggerFor") menu: MenuComponent;
|
||||
|
||||
private overlayRef: OverlayRef;
|
||||
private defaultMenuConfig: OverlayConfig = {
|
||||
panelClass: "bit-menu-panel",
|
||||
hasBackdrop: true,
|
||||
backdropClass: "cdk-overlay-transparent-backdrop",
|
||||
scrollStrategy: this.overlay.scrollStrategies.reposition(),
|
||||
positionStrategy: this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(this.elementRef)
|
||||
.withPositions([
|
||||
{
|
||||
originX: "start",
|
||||
originY: "bottom",
|
||||
overlayX: "start",
|
||||
overlayY: "top",
|
||||
},
|
||||
{
|
||||
originX: "end",
|
||||
originY: "bottom",
|
||||
overlayX: "end",
|
||||
overlayY: "top",
|
||||
},
|
||||
])
|
||||
.withLockedPosition(true)
|
||||
.withFlexibleDimensions(false)
|
||||
.withPush(false),
|
||||
};
|
||||
private closedEventsSub: Subscription;
|
||||
private keyDownEventsSub: Subscription;
|
||||
|
||||
constructor(
|
||||
private elementRef: ElementRef<HTMLElement>,
|
||||
private viewContainerRef: ViewContainerRef,
|
||||
private overlay: Overlay
|
||||
) {}
|
||||
|
||||
@HostListener("click") toggleMenu() {
|
||||
this.isOpen ? this.destroyMenu() : this.openMenu();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.disposeAll();
|
||||
}
|
||||
|
||||
private openMenu() {
|
||||
if (this.menu == null) {
|
||||
throw new Error("Cannot find bit-menu element");
|
||||
}
|
||||
|
||||
this.isOpen = true;
|
||||
this.overlayRef = this.overlay.create(this.defaultMenuConfig);
|
||||
|
||||
const templatePortal = new TemplatePortal(this.menu.templateRef, this.viewContainerRef);
|
||||
this.overlayRef.attach(templatePortal);
|
||||
|
||||
this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => {
|
||||
if (event?.key === "Tab") {
|
||||
// Required to ensure tab order resumes correctly
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
this.destroyMenu();
|
||||
});
|
||||
this.keyDownEventsSub = this.overlayRef
|
||||
.keydownEvents()
|
||||
.subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event));
|
||||
}
|
||||
|
||||
private destroyMenu() {
|
||||
if (this.overlayRef == null || !this.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this.disposeAll();
|
||||
}
|
||||
|
||||
private getClosedEvents(): Observable<any> {
|
||||
const detachments = this.overlayRef.detachments();
|
||||
const escKey = this.overlayRef
|
||||
.keydownEvents()
|
||||
.pipe(filter((event: KeyboardEvent) => event.key === "Escape" || event.key === "Tab"));
|
||||
const backdrop = this.overlayRef.backdropClick();
|
||||
const menuClosed = this.menu.closed;
|
||||
|
||||
return detachments.pipe(mergeWith(escKey, backdrop, menuClosed));
|
||||
}
|
||||
|
||||
private disposeAll() {
|
||||
this.closedEventsSub?.unsubscribe();
|
||||
this.overlayRef?.dispose();
|
||||
this.keyDownEventsSub?.unsubscribe();
|
||||
}
|
||||
}
|
||||
9
libs/components/src/menu/menu.component.html
Normal file
9
libs/components/src/menu/menu.component.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<ng-template>
|
||||
<div
|
||||
(click)="closed.emit()"
|
||||
class="tw-flex tw-flex-col tw-bg-background tw-border tw-border-solid tw-rounded tw-border-secondary-500 tw-bg-clip-padding tw-py-2 tw-shrink-0"
|
||||
role="menu"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</ng-template>
|
||||
77
libs/components/src/menu/menu.component.spec.ts
Normal file
77
libs/components/src/menu/menu.component.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
|
||||
|
||||
import { MenuModule } from "./index";
|
||||
|
||||
describe("Menu", () => {
|
||||
let fixture: ComponentFixture<TestApp>;
|
||||
const getMenuTriggerDirective = () => {
|
||||
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
|
||||
return buttonDebugElement.injector.get(MenuTriggerForDirective);
|
||||
};
|
||||
|
||||
// The overlay is created outside the root debugElement, so we need to query its parent
|
||||
const getBitMenuPanel = () => document.querySelector(".bit-menu-panel");
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MenuModule],
|
||||
declarations: [TestApp],
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestApp);
|
||||
fixture.detectChanges();
|
||||
})
|
||||
);
|
||||
|
||||
it("should open when the trigger is clicked", async () => {
|
||||
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
|
||||
(buttonDebugElement.nativeElement as HTMLButtonElement).click();
|
||||
|
||||
expect(getBitMenuPanel()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should close when the trigger is clicked", () => {
|
||||
getMenuTriggerDirective().toggleMenu();
|
||||
|
||||
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
|
||||
(buttonDebugElement.nativeElement as HTMLButtonElement).click();
|
||||
|
||||
expect(getBitMenuPanel()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should close when a menu item is clicked", () => {
|
||||
getMenuTriggerDirective().toggleMenu();
|
||||
|
||||
(document.querySelector("#item1") as HTMLAnchorElement).click();
|
||||
|
||||
expect(getBitMenuPanel()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should close when the backdrop is clicked", () => {
|
||||
getMenuTriggerDirective().toggleMenu();
|
||||
|
||||
(document.querySelector(".cdk-overlay-backdrop") as HTMLAnchorElement).click();
|
||||
|
||||
expect(getBitMenuPanel()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: "test-app",
|
||||
template: `
|
||||
<button type="button" [bitMenuTriggerFor]="testMenu" class="testclass">Open menu</button>
|
||||
|
||||
<bit-menu #testMenu>
|
||||
<a id="item1" bitMenuItem>Item 1</a>
|
||||
<a id="item2" bitMenuItem>Item 2</a>
|
||||
</bit-menu>
|
||||
`,
|
||||
})
|
||||
class TestApp {}
|
||||
30
libs/components/src/menu/menu.component.ts
Normal file
30
libs/components/src/menu/menu.component.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FocusKeyManager } from "@angular/cdk/a11y";
|
||||
import {
|
||||
Component,
|
||||
Output,
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
EventEmitter,
|
||||
ContentChildren,
|
||||
QueryList,
|
||||
AfterContentInit,
|
||||
} from "@angular/core";
|
||||
|
||||
import { MenuItemDirective } from "./menu-item.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-menu",
|
||||
templateUrl: "./menu.component.html",
|
||||
exportAs: "menuComponent",
|
||||
})
|
||||
export class MenuComponent implements AfterContentInit {
|
||||
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@ContentChildren(MenuItemDirective, { descendants: true })
|
||||
menuItems: QueryList<MenuItemDirective>;
|
||||
keyManager: FocusKeyManager<MenuItemDirective>;
|
||||
|
||||
ngAfterContentInit() {
|
||||
this.keyManager = new FocusKeyManager(this.menuItems).withWrap();
|
||||
}
|
||||
}
|
||||
15
libs/components/src/menu/menu.module.ts
Normal file
15
libs/components/src/menu/menu.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { MenuDividerComponent } from "./menu-divider.component";
|
||||
import { MenuItemDirective } from "./menu-item.directive";
|
||||
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
|
||||
import { MenuComponent } from "./menu.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, OverlayModule],
|
||||
declarations: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
||||
exports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
||||
})
|
||||
export class MenuModule {}
|
||||
69
libs/components/src/menu/menu.stories.ts
Normal file
69
libs/components/src/menu/menu.stories.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { ButtonModule } from "../button/button.module";
|
||||
|
||||
import { MenuDividerComponent } from "./menu-divider.component";
|
||||
import { MenuItemDirective } from "./menu-item.directive";
|
||||
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
|
||||
import { MenuComponent } from "./menu.component";
|
||||
|
||||
export default {
|
||||
title: "Jslib/Menu",
|
||||
component: MenuTriggerForDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
MenuTriggerForDirective,
|
||||
MenuComponent,
|
||||
MenuItemDirective,
|
||||
MenuDividerComponent,
|
||||
],
|
||||
imports: [OverlayModule, ButtonModule],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17952",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<MenuTriggerForDirective> = (args: MenuTriggerForDirective) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-menu #myMenu="menuComponent">
|
||||
<a href="#" bitMenuItem>Anchor link</a>
|
||||
<a href="#" bitMenuItem>Another link</a>
|
||||
<button type="button" bitMenuItem>Button</button>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
<button type="button" bitMenuItem>Button after divider</button>
|
||||
</bit-menu>
|
||||
|
||||
<div class="tw-h-40">
|
||||
<div class="cdk-overlay-pane bit-menu-panel">
|
||||
<ng-container *ngTemplateOutlet="myMenu.templateRef"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
const TemplateWithButton: Story<MenuTriggerForDirective> = (args: MenuTriggerForDirective) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-h-40">
|
||||
<button bitButton buttonType="secondary" [bitMenuTriggerFor]="myMenu">Open menu</button>
|
||||
</div>
|
||||
|
||||
<bit-menu #myMenu>
|
||||
<a href="#" bitMenuItem>Anchor link</a>
|
||||
<a href="#" bitMenuItem>Another link</a>
|
||||
<button type="button" bitMenuItem>Button</button>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
<button type="button" bitMenuItem>Button after divider</button>
|
||||
</bit-menu>`,
|
||||
});
|
||||
|
||||
export const OpenMenu = Template.bind({});
|
||||
export const ClosedMenu = TemplateWithButton.bind({});
|
||||
Reference in New Issue
Block a user