From a4303fac590fdf4daaee66bf0ad7a88cd9161943 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 23 Oct 2023 09:22:54 -0700 Subject: [PATCH] [PM-2452] Popover Component (#5889) * setup popover component template and basic story * add a11y features * add multiple positions for the popover * add stories for open right and left * prevent panel from hugging edges of screen * fix typo * add popover arrow depending on position * add buttons to stories * add figma preview * move toward directive approach * add all positions * add header input * add close functionality * make standalone component * add a11y import * add all stories * add story controls/args * add module of standalone components * gracefully handle text wrap and align close button to top for longer headings * update semantic html * add story for open state * use bitIconButton * adjust styles * add public close method * setup walkthrough mode * add walkthrough mode * revert to before walkthrough service added * add triggerRef to stories * change property name * add Escape key to close events * add initially open state * add docs * minor reformatting --------- Co-authored-by: William Martin --- libs/components/src/index.ts | 1 + .../src/popover/default-positions.ts | 150 +++++++ libs/components/src/popover/index.ts | 1 + .../popover/popover-trigger-for.directive.ts | 131 ++++++ .../src/popover/popover.component.css | 49 +++ .../src/popover/popover.component.html | 26 ++ .../src/popover/popover.component.ts | 18 + libs/components/src/popover/popover.mdx | 88 ++++ libs/components/src/popover/popover.module.ts | 10 + .../components/src/popover/popover.stories.ts | 408 ++++++++++++++++++ libs/components/src/tw-theme.css | 1 + 11 files changed, 883 insertions(+) create mode 100644 libs/components/src/popover/default-positions.ts create mode 100644 libs/components/src/popover/index.ts create mode 100644 libs/components/src/popover/popover-trigger-for.directive.ts create mode 100644 libs/components/src/popover/popover.component.css create mode 100644 libs/components/src/popover/popover.component.html create mode 100644 libs/components/src/popover/popover.component.ts create mode 100644 libs/components/src/popover/popover.mdx create mode 100644 libs/components/src/popover/popover.module.ts create mode 100644 libs/components/src/popover/popover.stories.ts diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d4fdda08a2a..790fea828f7 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -19,6 +19,7 @@ export * from "./menu"; export * from "./multi-select"; export * from "./navigation"; export * from "./no-items"; +export * from "./popover"; export * from "./progress"; export * from "./radio-button"; export * from "./search"; diff --git a/libs/components/src/popover/default-positions.ts b/libs/components/src/popover/default-positions.ts new file mode 100644 index 00000000000..ae08c38a288 --- /dev/null +++ b/libs/components/src/popover/default-positions.ts @@ -0,0 +1,150 @@ +import { ConnectedPosition } from "@angular/cdk/overlay"; + +const ORIGIN_OFFSET_PX = 6; +const OVERLAY_OFFSET_PX = 24; + +export type PositionIdentifier = + | "right-start" + | "right-center" + | "right-end" + | "left-start" + | "left-center" + | "left-end" + | "below-start" + | "below-center" + | "below-end" + | "above-start" + | "above-center" + | "above-end"; + +export interface DefaultPosition extends ConnectedPosition { + id: PositionIdentifier; +} + +export const defaultPositions: DefaultPosition[] = [ + /** + * The order of these positions matters. The Popover component will use + * the first position that fits within the viewport. + */ + + // Popover opens to right of trigger + { + id: "right-start", + offsetX: ORIGIN_OFFSET_PX, + offsetY: -OVERLAY_OFFSET_PX, + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "top", + panelClass: ["bit-popover-right", "bit-popover-right-start"], + }, + { + id: "right-center", + offsetX: ORIGIN_OFFSET_PX, + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "center", + panelClass: ["bit-popover-right", "bit-popover-right-center"], + }, + { + id: "right-end", + offsetX: ORIGIN_OFFSET_PX, + offsetY: OVERLAY_OFFSET_PX, + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "bottom", + panelClass: ["bit-popover-right", "bit-popover-right-end"], + }, + // ... to left of trigger + { + id: "left-start", + offsetX: -ORIGIN_OFFSET_PX, + offsetY: -OVERLAY_OFFSET_PX, + originX: "start", + originY: "center", + overlayX: "end", + overlayY: "top", + panelClass: ["bit-popover-left", "bit-popover-left-start"], + }, + { + id: "left-center", + offsetX: -ORIGIN_OFFSET_PX, + originX: "start", + originY: "center", + overlayX: "end", + overlayY: "center", + panelClass: ["bit-popover-left", "bit-popover-left-center"], + }, + { + id: "left-end", + offsetX: -ORIGIN_OFFSET_PX, + offsetY: OVERLAY_OFFSET_PX, + originX: "start", + originY: "center", + overlayX: "end", + overlayY: "bottom", + panelClass: ["bit-popover-left", "bit-popover-left-end"], + }, + // ... below trigger + { + id: "below-center", + offsetY: ORIGIN_OFFSET_PX, + originX: "center", + originY: "bottom", + overlayX: "center", + overlayY: "top", + panelClass: ["bit-popover-below", "bit-popover-below-center"], + }, + { + id: "below-start", + offsetX: -OVERLAY_OFFSET_PX, + offsetY: ORIGIN_OFFSET_PX, + originX: "center", + originY: "bottom", + overlayX: "start", + overlayY: "top", + panelClass: ["bit-popover-below", "bit-popover-below-start"], + }, + { + id: "below-end", + offsetX: OVERLAY_OFFSET_PX, + offsetY: ORIGIN_OFFSET_PX, + originX: "center", + originY: "bottom", + overlayX: "end", + overlayY: "top", + panelClass: ["bit-popover-below", "bit-popover-below-end"], + }, + // ... above trigger + { + id: "above-center", + offsetY: -ORIGIN_OFFSET_PX, + originX: "center", + originY: "top", + overlayX: "center", + overlayY: "bottom", + panelClass: ["bit-popover-above", "bit-popover-above-center"], + }, + { + id: "above-start", + offsetX: -OVERLAY_OFFSET_PX, + offsetY: -ORIGIN_OFFSET_PX, + originX: "center", + originY: "top", + overlayX: "start", + overlayY: "bottom", + panelClass: ["bit-popover-above", "bit-popover-above-start"], + }, + { + id: "above-end", + offsetX: OVERLAY_OFFSET_PX, + offsetY: -ORIGIN_OFFSET_PX, + originX: "center", + originY: "top", + overlayX: "end", + overlayY: "bottom", + panelClass: ["bit-popover-above", "bit-popover-above-end"], + }, +]; diff --git a/libs/components/src/popover/index.ts b/libs/components/src/popover/index.ts new file mode 100644 index 00000000000..64953771679 --- /dev/null +++ b/libs/components/src/popover/index.ts @@ -0,0 +1 @@ +export * from "./popover.module"; diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts new file mode 100644 index 00000000000..6168f21bbb4 --- /dev/null +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -0,0 +1,131 @@ +import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; +import { TemplatePortal } from "@angular/cdk/portal"; +import { + AfterViewInit, + Directive, + ElementRef, + HostBinding, + HostListener, + Input, + OnDestroy, + ViewContainerRef, +} from "@angular/core"; +import { Observable, Subscription, filter, mergeWith } from "rxjs"; + +import { defaultPositions } from "./default-positions"; +import { PopoverComponent } from "./popover.component"; + +@Directive({ + selector: "[bitPopoverTriggerFor]", + standalone: true, + exportAs: "popoverTrigger", +}) +export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { + @Input() + @HostBinding("attr.aria-expanded") + popoverOpen = false; + + @Input("bitPopoverTriggerFor") + popover: PopoverComponent; + + @Input("position") + position: string; + + private overlayRef: OverlayRef; + private closedEventsSub: Subscription; + + get positions() { + if (!this.position) { + return defaultPositions; + } + + const preferredPosition = defaultPositions.find((position) => position.id === this.position); + + if (preferredPosition) { + return [preferredPosition, ...defaultPositions]; + } + + return defaultPositions; + } + + get defaultPopoverConfig(): OverlayConfig { + return { + hasBackdrop: true, + backdropClass: "cdk-overlay-transparent-backdrop", + scrollStrategy: this.overlay.scrollStrategies.reposition(), + positionStrategy: this.overlay + .position() + .flexibleConnectedTo(this.elementRef) + .withPositions(this.positions) + .withLockedPosition(true) + .withFlexibleDimensions(false) + .withPush(true), + }; + } + + constructor( + private elementRef: ElementRef, + private viewContainerRef: ViewContainerRef, + private overlay: Overlay + ) {} + + @HostListener("click") + togglePopover() { + if (this.popoverOpen) { + this.closePopover(); + } else { + this.openPopover(); + } + } + + private openPopover() { + this.popoverOpen = true; + this.overlayRef = this.overlay.create(this.defaultPopoverConfig); + + const templatePortal = new TemplatePortal(this.popover.templateRef, this.viewContainerRef); + + this.overlayRef.attach(templatePortal); + this.closedEventsSub = this.getClosedEvents().subscribe(() => { + this.destroyPopover(); + }); + } + + private getClosedEvents(): Observable { + const detachments = this.overlayRef.detachments(); + const escKey = this.overlayRef + .keydownEvents() + .pipe(filter((event: KeyboardEvent) => event.key === "Escape")); + const backdrop = this.overlayRef.backdropClick(); + const popoverClosed = this.popover.closed; + + return detachments.pipe(mergeWith(escKey, backdrop, popoverClosed)); + } + + private destroyPopover() { + if (this.overlayRef == null || !this.popoverOpen) { + return; + } + + this.popoverOpen = false; + this.disposeAll(); + } + + private disposeAll() { + this.closedEventsSub?.unsubscribe(); + this.overlayRef?.dispose(); + } + + ngAfterViewInit() { + if (this.popoverOpen) { + this.openPopover(); + } + } + + ngOnDestroy() { + this.disposeAll(); + } + + closePopover() { + this.destroyPopover(); + } +} diff --git a/libs/components/src/popover/popover.component.css b/libs/components/src/popover/popover.component.css new file mode 100644 index 00000000000..548d1bea368 --- /dev/null +++ b/libs/components/src/popover/popover.component.css @@ -0,0 +1,49 @@ +.bit-popover-arrow { + @apply tw-absolute tw-z-10 tw-h-4 tw-w-4 tw-rotate-45 tw-border-solid tw-bg-background; +} + +.bit-popover-right .bit-popover-arrow { + @apply tw-left-1 -tw-translate-x-1/2 tw-rounded-bl-sm tw-border-b tw-border-l tw-border-b-secondary-300 tw-border-l-secondary-300; +} + +.bit-popover-left .bit-popover-arrow { + @apply tw-right-1 tw-translate-x-1/2 tw-rounded-tr-sm tw-border-r tw-border-t tw-border-r-secondary-300 tw-border-t-secondary-300; +} + +.bit-popover-right-start .bit-popover-arrow, +.bit-popover-left-start .bit-popover-arrow { + @apply tw-top-6 -tw-translate-y-1/2; +} + +.bit-popover-right-center .bit-popover-arrow, +.bit-popover-left-center .bit-popover-arrow { + @apply tw-top-1/2 -tw-translate-y-1/2; +} + +.bit-popover-right-end .bit-popover-arrow, +.bit-popover-left-end .bit-popover-arrow { + @apply tw-bottom-6 tw-translate-y-1/2; +} + +.bit-popover-below .bit-popover-arrow { + @apply tw-top-1 -tw-translate-y-1/2 tw-rounded-tl-sm tw-border-l tw-border-t tw-border-l-secondary-300 tw-border-t-secondary-300; +} + +.bit-popover-above .bit-popover-arrow { + @apply tw-bottom-1 tw-translate-y-1/2 tw-rounded-br-sm tw-border-b tw-border-r tw-border-b-secondary-300 tw-border-r-secondary-300; +} + +.bit-popover-below-start .bit-popover-arrow, +.bit-popover-above-start .bit-popover-arrow { + @apply tw-left-6 -tw-translate-x-1/2; +} + +.bit-popover-below-center .bit-popover-arrow, +.bit-popover-above-center .bit-popover-arrow { + @apply tw-left-1/2 -tw-translate-x-1/2; +} + +.bit-popover-below-end .bit-popover-arrow, +.bit-popover-above-end .bit-popover-arrow { + @apply tw-right-6 tw-translate-x-1/2; +} diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html new file mode 100644 index 00000000000..8da3b002031 --- /dev/null +++ b/libs/components/src/popover/popover.component.html @@ -0,0 +1,26 @@ + + + diff --git a/libs/components/src/popover/popover.component.ts b/libs/components/src/popover/popover.component.ts new file mode 100644 index 00000000000..ee87368a66e --- /dev/null +++ b/libs/components/src/popover/popover.component.ts @@ -0,0 +1,18 @@ +import { A11yModule } from "@angular/cdk/a11y"; +import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core"; + +import { IconButtonModule } from "../icon-button/icon-button.module"; +import { SharedModule } from "../shared/shared.module"; + +@Component({ + standalone: true, + selector: "bit-popover", + imports: [A11yModule, IconButtonModule, SharedModule], + templateUrl: "./popover.component.html", + exportAs: "popoverComponent", +}) +export class PopoverComponent { + @ViewChild(TemplateRef) templateRef: TemplateRef; + @Input() title = ""; + @Output() closed = new EventEmitter(); +} diff --git a/libs/components/src/popover/popover.mdx b/libs/components/src/popover/popover.mdx new file mode 100644 index 00000000000..7b47b160ddc --- /dev/null +++ b/libs/components/src/popover/popover.mdx @@ -0,0 +1,88 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./popover.stories"; + + + +# Popover + +A popover is a page overlay that is triggered by a selecting a button. It displays interactive +content. + +Popovers remain actively open until a user dismisses it in one of the following ways: + +- Presses the Esc key +- Presses the close "x" button in the Popover +- Presses a button within the Popover triggering close +- Clicks outside of the Popover + +Popovers are used to provide the user with additional context about an interaction or page. We +primarily use popovers when a user clicks on an icon-button with a question icon. This launches a +popover that provides the user with in app help text. + +Note: Popovers are not tooltips. Use tooltips to show a short text to respondents when they hover +over a word or icon. Use popovers to show a longer text, or when you want to link to an external web +page. + + + +## Open on Page Load + +A Popover can be set to initially open on page load by setting `[popoverOpen]="true"` on the trigger +element, like so: + +```html + +``` + +## Positions + +The Popover component uses the following list of default "positions" to determine where to position +the Popover overlay. + +1. right-start ---> "Open the Popover to the RIGHT of the trigger and align the START of the Popover + with the trigger" +2. right-center +3. right-end +4. left-start +5. left-center +6. left-end +7. below-start +8. below-center +9. below-end +10. above-start +11. above-center +12. above-end + +The order here matters. If position 1 fits within the viewport, it will be used. If it does not, the +Popover component will try position 2, and so forth. This cascading behavior ensures that if the +user resizes the screen, the Popover component will find the best way to reposition itself. + +### Example + +Suppose you have a trigger element on the right side of the page. The `right-start` position will +not work because there is not enough space to open the Popover to the right. The same is true for +`right-center` and `right-end`. + +The first position that "fits" is `left-start`, and therefore that is where the Popover will open. + + + +### Manually Setting a Position + +You can manually set the initial position of the Popover by binding a `[position]` input on the +Popover's trigger element, such as: + +```html + +``` + + + +Note that if the user resizes the page and the Popover no longer fits in the viewport, the Popover +component will fall back to the list of default positions to find the best position. + +To test this out, open the Popopver in the example above and then slowly resize your browser window +horizontally to make it smaller. When the Popover no longer fits the `above-end` position, it will +jump down below the trigger, using `below-center`, because that is the first position that fits +based on the list of default positions. diff --git a/libs/components/src/popover/popover.module.ts b/libs/components/src/popover/popover.module.ts new file mode 100644 index 00000000000..8d7032c7aed --- /dev/null +++ b/libs/components/src/popover/popover.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from "@angular/core"; + +import { PopoverTriggerForDirective } from "./popover-trigger-for.directive"; +import { PopoverComponent } from "./popover.component"; + +@NgModule({ + imports: [PopoverComponent, PopoverTriggerForDirective], + exports: [PopoverComponent, PopoverTriggerForDirective], +}) +export class PopoverModule {} diff --git a/libs/components/src/popover/popover.stories.ts b/libs/components/src/popover/popover.stories.ts new file mode 100644 index 00000000000..2bb009d4988 --- /dev/null +++ b/libs/components/src/popover/popover.stories.ts @@ -0,0 +1,408 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { ButtonModule } from "../button"; +import { IconButtonModule } from "../icon-button"; +import { SharedModule } from "../shared/shared.module"; +import { I18nMockService } from "../utils/i18n-mock.service"; + +import { PopoverTriggerForDirective } from "./popover-trigger-for.directive"; +import { PopoverModule } from "./popover.module"; + +export default { + title: "Component Library/Popover", + decorators: [ + moduleMetadata({ + imports: [PopoverModule, ButtonModule, IconButtonModule, SharedModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + close: "Close", + }); + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1717-15868", + }, + }, + argTypes: { + position: { + options: [ + "right-start", + "right-center", + "right-end", + "left-start", + "left-center", + "left-end", + "below-start", + "below-center", + "below-end", + "above-start", + "above-center", + "above-end", + ], + control: { type: "select" }, + }, + }, + args: { + position: "right-start", + }, +} as Meta; + +type Story = StoryObj; + +const popoverContent = ` + +
Lorem ipsum dolor adipisicing elit.
+
    +
  • Dolor sit amet consectetur
  • +
  • Esse labore veniam tempora
  • +
  • Adipisicing elit ipsum iustolaborum
  • +
+ +
+`; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const Open: Story = { + render: (args) => ({ + props: args, + template: ` + +
Lorem ipsum dolor adipisicing elit.
+
    +
  • Dolor sit amet consectetur
  • +
  • Esse labore veniam tempora
  • +
  • Adipisicing elit ipsum iustolaborum
  • +
+
+ +
+
+ +
+
+ `, + }), +}; + +export const InitiallyOpen: Story = { + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const RightStart: Story = { + args: { + position: "right-start", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const RightCenter: Story = { + args: { + position: "right-center", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const RightEnd: Story = { + args: { + position: "right-end", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const LeftStart: Story = { + args: { + position: "left-start", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const LeftCenter: Story = { + args: { + position: "left-center", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; +export const LeftEnd: Story = { + args: { + position: "left-end", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const BelowStart: Story = { + args: { + position: "below-start", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const BelowCenter: Story = { + args: { + position: "below-center", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const BelowEnd: Story = { + args: { + position: "below-end", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const AboveStart: Story = { + args: { + position: "above-start", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const AboveCenter: Story = { + args: { + position: "above-center", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; + +export const AboveEnd: Story = { + args: { + position: "above-end", + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ ${popoverContent} + `, + }), +}; diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index b2fa2bddce5..6775a0fa582 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -160,6 +160,7 @@ --tw-ring-offset-color: #002b36; } +@import "./popover/popover.component.css"; @import "./search/search.component.css"; /**