1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[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 <contact@willmartian.com>
This commit is contained in:
rr-bw 2023-10-23 09:22:54 -07:00 committed by GitHub
parent c2e03d2cdc
commit a4303fac59
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 883 additions and 0 deletions

View File

@ -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";

View File

@ -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"],
},
];

View File

@ -0,0 +1 @@
export * from "./popover.module";

View File

@ -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<HTMLElement>,
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<any> {
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();
}
}

View File

@ -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;
}

View File

@ -0,0 +1,26 @@
<ng-template>
<section cdkTrapFocus cdkTrapFocusAutoCapture class="tw-relative" role="dialog" aria-modal="true">
<div class="tw-overflow-hidden tw-rounded-md tw-border tw-border-solid tw-border-secondary-300">
<div
class="tw-relative tw-z-20 tw-w-72 tw-break-words tw-bg-background tw-pb-4 tw-pt-2 tw-text-main"
>
<div class="tw-mb-1 tw-mr-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-pl-4">
<h2 class="tw-mb-0 tw-mt-1 tw-text-base tw-font-semibold">
{{ title }}
</h2>
<button
type="button"
bitIconButton="bwi-close"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
(click)="closed.emit()"
></button>
</div>
<div class="tw-px-4">
<ng-content></ng-content>
</div>
</div>
</div>
<div class="bit-popover-arrow"></div>
</section>
</ng-template>

View File

@ -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<any>;
@Input() title = "";
@Output() closed = new EventEmitter();
}

View File

@ -0,0 +1,88 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./popover.stories";
<Meta of={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.
<Primary />
## 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
<button [bitPopoverTriggerFor]="myPopover" [popoverOpen]="true">Open Popover</button>
```
## 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.
<Story of={stories.LeftStart} />
### 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
<button [bitPopoverTriggerFor]="myPopover" [position]="'above-end'">Open Popover</button>
```
<Story of={stories.AboveEnd} />
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.

View File

@ -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 {}

View File

@ -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<PopoverTriggerForDirective>;
const popoverContent = `
<bit-popover [title]="'Example Title'" #myPopover>
<div>Lorem ipsum dolor <a href="#">adipisicing elit</a>.</div>
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
<li>Dolor sit amet consectetur</li>
<li>Esse labore veniam tempora</li>
<li>Adipisicing elit ipsum <a href="#">iustolaborum</a></li>
</ul>
<button bitButton class="tw-mt-3" (click)="triggerRef.closePopover()">Close</button>
</bit-popover>
`;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const Open: Story = {
render: (args) => ({
props: args,
template: `
<bit-popover [title]="'Example Title'" #myPopover="popoverComponent">
<div>Lorem ipsum dolor <a href="#">adipisicing elit</a>.</div>
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
<li>Dolor sit amet consectetur</li>
<li>Esse labore veniam tempora</li>
<li>Adipisicing elit ipsum <a href="#">iustolaborum</a></li>
</ul>
</bit-popover>
<div class="tw-h-40">
<div class="cdk-overlay-pane bit-popover-right bit-popover-right-start">
<ng-container *ngTemplateOutlet="myPopover.templateRef"></ng-container>
</div>
</div>
`,
}),
};
export const InitiallyOpen: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
[popoverOpen]="true"
#triggerRef="popoverTrigger"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const RightStart: Story = {
args: {
position: "right-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const RightCenter: Story = {
args: {
position: "right-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const RightEnd: Story = {
args: {
position: "right-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const LeftStart: Story = {
args: {
position: "left-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const LeftCenter: Story = {
args: {
position: "left-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const LeftEnd: Story = {
args: {
position: "left-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-end">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const BelowStart: Story = {
args: {
position: "below-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const BelowCenter: Story = {
args: {
position: "below-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const BelowEnd: Story = {
args: {
position: "below-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const AboveStart: Story = {
args: {
position: "above-start",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const AboveCenter: Story = {
args: {
position: "above-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};
export const AboveEnd: Story = {
args: {
position: "above-end",
},
render: (args) => ({
props: args,
template: `
<div class="tw-mt-32 tw-flex tw-justify-center">
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-500"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
[position]="'${args.position}'"
>
<i class="bwi bwi-question-circle"></i>
</button>
</div>
${popoverContent}
`,
}),
};

View File

@ -160,6 +160,7 @@
--tw-ring-offset-color: #002b36;
}
@import "./popover/popover.component.css";
@import "./search/search.component.css";
/**