mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
[SM-43] create product-switcher (#4189)
* rebase to master * use bit-menu in product switcher; add focusStrategy to bit-menu * recommit locales after rebase * add light style to iconButton, use in product-switcher * move out of component library * add buttonType input * gate behind sm flag * update aria-label * add role input to bit-menu * style changes * simplify partition logic * split into two components for Storybook * update focus styles; update grid sizing to relative * fix underline on hover * update attribute binding * move to layouts dir * add bitLink; update grid gap * reorder loose components * move orgs mock * move a11y module * fix aria role bug; add aria label to menu * update colors * update ring color * simplify colors * remove duplicate link module
This commit is contained in:
parent
7d3063942e
commit
eeb407b8a4
@ -20,6 +20,12 @@ module.exports = {
|
|||||||
builder: "webpack5",
|
builder: "webpack5",
|
||||||
disableTelemetry: true,
|
disableTelemetry: true,
|
||||||
},
|
},
|
||||||
|
env: (config) => ({
|
||||||
|
...config,
|
||||||
|
FLAGS: JSON.stringify({
|
||||||
|
secretsManager: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
webpackFinal: async (config, { configType }) => {
|
webpackFinal: async (config, { configType }) => {
|
||||||
config.resolve.plugins = [new TsconfigPathsPlugin()];
|
config.resolve.plugins = [new TsconfigPathsPlugin()];
|
||||||
return config;
|
return config;
|
||||||
|
@ -38,6 +38,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<product-switcher buttonType="light"></product-switcher>
|
||||||
<ul class="navbar-nav flex-row ml-md-auto d-none d-md-flex">
|
<ul class="navbar-nav flex-row ml-md-auto d-none d-md-flex">
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
|
1
apps/web/src/app/layouts/product-switcher/index.ts
Normal file
1
apps/web/src/app/layouts/product-switcher/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./product-switcher.module";
|
@ -0,0 +1,39 @@
|
|||||||
|
<bit-menu #menu ariaRole="dialog" [ariaLabel]="'switchProducts' | i18n">
|
||||||
|
<div class="tw-px-4 tw-py-2" *ngIf="products$ | async as products">
|
||||||
|
<!-- Bento options -->
|
||||||
|
<!-- grid-template-columns is dynamic so we can collapse empty columns -->
|
||||||
|
<section
|
||||||
|
[ngStyle]="{
|
||||||
|
'--num-products': products.bento.length,
|
||||||
|
'grid-template-columns': 'repeat(min(var(--num-products,1),3),auto)'
|
||||||
|
}"
|
||||||
|
class="tw-grid tw-gap-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
*ngFor="let product of products.bento"
|
||||||
|
[routerLink]="product.appRoute"
|
||||||
|
class="tw-group tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-500 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700"
|
||||||
|
routerLinkActive="tw-font-bold tw-bg-primary-500 hover:tw-bg-primary-500 !tw-text-contrast tw-ring-offset-2"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
|
>
|
||||||
|
<i class="bwi {{ product.icon }} tw-text-4xl !tw-m-0 !tw-mb-1"></i>
|
||||||
|
<span class="tw-text-center tw-text-sm tw-leading-snug group-hover:tw-underline">{{
|
||||||
|
product.name
|
||||||
|
}}</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Other options -->
|
||||||
|
<section
|
||||||
|
*ngIf="products.other.length > 0"
|
||||||
|
class="tw-mt-4 tw-flex tw-w-full tw-flex-col tw-border-0 tw-border-t tw-border-solid tw-border-t-text-muted tw-p-2 tw-pb-0"
|
||||||
|
>
|
||||||
|
<span class="tw-mb-1 tw-text-xs tw-text-muted">{{ "moreFromBitwarden" | i18n }}</span>
|
||||||
|
<a *ngFor="let product of products.other" bitLink [href]="product.marketingRoute">
|
||||||
|
<span class="tw-font-normal">
|
||||||
|
<i class="bwi {{ product.icon }} tw-m-0 !tw-mr-3"></i>{{ product.name }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</bit-menu>
|
@ -0,0 +1,93 @@
|
|||||||
|
import { Component, ViewChild } from "@angular/core";
|
||||||
|
import { ActivatedRoute } from "@angular/router";
|
||||||
|
import { combineLatest, map } from "rxjs";
|
||||||
|
|
||||||
|
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { MenuComponent } from "@bitwarden/components";
|
||||||
|
|
||||||
|
type ProductSwitcherItem = {
|
||||||
|
/**
|
||||||
|
* Displayed name
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displayed icon
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route for items in the `bentoProducts$` section
|
||||||
|
*/
|
||||||
|
appRoute?: string | any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route for items in the `otherProducts$` section
|
||||||
|
*/
|
||||||
|
marketingRoute?: string | any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "product-switcher-content",
|
||||||
|
templateUrl: "./product-switcher-content.component.html",
|
||||||
|
})
|
||||||
|
export class ProductSwitcherContentComponent {
|
||||||
|
@ViewChild("menu")
|
||||||
|
menu: MenuComponent;
|
||||||
|
|
||||||
|
protected products$ = combineLatest([
|
||||||
|
this.organizationService.organizations$,
|
||||||
|
this.route.paramMap,
|
||||||
|
]).pipe(
|
||||||
|
map(([orgs, paramMap]) => {
|
||||||
|
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
|
||||||
|
// If the active route org doesn't have access to SM, find the first org that does.
|
||||||
|
const smOrg = routeOrg?.canAccessSecretsManager
|
||||||
|
? routeOrg
|
||||||
|
: orgs.find((o) => o.canAccessSecretsManager);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We can update this to the "satisfies" type upon upgrading to TypeScript 4.9
|
||||||
|
* https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/#satisfies
|
||||||
|
*/
|
||||||
|
const products: Record<"pm" | "sm" | "orgs", ProductSwitcherItem> = {
|
||||||
|
pm: {
|
||||||
|
name: "Password Manager",
|
||||||
|
icon: "bwi-lock",
|
||||||
|
appRoute: "/vault",
|
||||||
|
marketingRoute: "https://bitwarden.com/products/personal/",
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
name: "Secrets Manager Beta",
|
||||||
|
icon: "bwi-cli",
|
||||||
|
appRoute: ["/sm", smOrg?.id],
|
||||||
|
// TODO: update marketing link
|
||||||
|
marketingRoute: "#",
|
||||||
|
},
|
||||||
|
orgs: {
|
||||||
|
name: "Organizations",
|
||||||
|
icon: "bwi-business",
|
||||||
|
marketingRoute: "https://bitwarden.com/products/business/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const bento: ProductSwitcherItem[] = [products.pm];
|
||||||
|
const other: ProductSwitcherItem[] = [];
|
||||||
|
|
||||||
|
if (smOrg) {
|
||||||
|
bento.push(products.sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
other.push(products.orgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bento,
|
||||||
|
other,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private organizationService: OrganizationService, private route: ActivatedRoute) {}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
<ng-template [ngIf]="isEnabled">
|
||||||
|
<button
|
||||||
|
bitIconButton="bwi bwi-fw bwi-filter"
|
||||||
|
[bitMenuTriggerFor]="content?.menu"
|
||||||
|
[buttonType]="buttonType"
|
||||||
|
[attr.aria-label]="'switchProducts' | i18n"
|
||||||
|
></button>
|
||||||
|
<product-switcher-content #content></product-switcher-content>
|
||||||
|
</ng-template>
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Component, Input } from "@angular/core";
|
||||||
|
|
||||||
|
import { IconButtonType } from "@bitwarden/components/src/icon-button/icon-button.component";
|
||||||
|
|
||||||
|
import { flagEnabled } from "../../../utils/flags";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "product-switcher",
|
||||||
|
templateUrl: "./product-switcher.component.html",
|
||||||
|
})
|
||||||
|
export class ProductSwitcherComponent {
|
||||||
|
protected isEnabled = flagEnabled("secretsManager");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passed to the product switcher's `bitIconButton`
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
buttonType: IconButtonType = "main";
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
import { A11yModule } from "@angular/cdk/a11y";
|
||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
import { RouterModule } from "@angular/router";
|
||||||
|
|
||||||
|
import { I18nPipe } from "@bitwarden/angular/pipes/i18n.pipe";
|
||||||
|
|
||||||
|
import { SharedModule } from "../../shared";
|
||||||
|
|
||||||
|
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
|
||||||
|
import { ProductSwitcherComponent } from "./product-switcher.component";
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [SharedModule, A11yModule, RouterModule],
|
||||||
|
declarations: [ProductSwitcherComponent, ProductSwitcherContentComponent],
|
||||||
|
exports: [ProductSwitcherComponent],
|
||||||
|
providers: [I18nPipe],
|
||||||
|
})
|
||||||
|
export class ProductSwitcherModule {}
|
@ -0,0 +1,134 @@
|
|||||||
|
import { Component, Directive, Input } from "@angular/core";
|
||||||
|
import { RouterModule } from "@angular/router";
|
||||||
|
import { Meta, Story, moduleMetadata } from "@storybook/angular";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
|
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/models/domain/organization";
|
||||||
|
import { IconButtonModule, LinkModule, MenuModule } from "@bitwarden/components";
|
||||||
|
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
|
||||||
|
|
||||||
|
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
|
||||||
|
import { ProductSwitcherComponent } from "./product-switcher.component";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: "[mockOrgs]",
|
||||||
|
})
|
||||||
|
class MockOrganizationService implements Partial<OrganizationService> {
|
||||||
|
private static _orgs = new BehaviorSubject<Organization[]>([]);
|
||||||
|
organizations$ = MockOrganizationService._orgs; // eslint-disable-line rxjs/no-exposed-subjects
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set mockOrgs(orgs: Organization[]) {
|
||||||
|
this.organizations$.next(orgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "story-layout",
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
})
|
||||||
|
class StoryLayoutComponent {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "story-content",
|
||||||
|
template: ``,
|
||||||
|
})
|
||||||
|
class StoryContentComponent {}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Web/Product Switcher",
|
||||||
|
decorators: [
|
||||||
|
moduleMetadata({
|
||||||
|
declarations: [
|
||||||
|
ProductSwitcherContentComponent,
|
||||||
|
ProductSwitcherComponent,
|
||||||
|
MockOrganizationService,
|
||||||
|
StoryLayoutComponent,
|
||||||
|
StoryContentComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
JslibModule,
|
||||||
|
MenuModule,
|
||||||
|
IconButtonModule,
|
||||||
|
LinkModule,
|
||||||
|
RouterModule.forRoot(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: StoryLayoutComponent,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirectTo: "vault",
|
||||||
|
pathMatch: "full",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "sm/:organizationId",
|
||||||
|
component: StoryContentComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "vault",
|
||||||
|
component: StoryContentComponent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ useHash: true }
|
||||||
|
),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: OrganizationService, useClass: MockOrganizationService },
|
||||||
|
MockOrganizationService,
|
||||||
|
{
|
||||||
|
provide: I18nService,
|
||||||
|
useFactory: () => {
|
||||||
|
return new I18nMockService({
|
||||||
|
moreFromBitwarden: "More from Bitwarden",
|
||||||
|
switchProducts: "Switch Products",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story = (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<router-outlet [mockOrgs]="mockOrgs"></router-outlet>
|
||||||
|
<div class="tw-flex tw-gap-[200px]">
|
||||||
|
<div>
|
||||||
|
<h1 class="tw-text-main tw-text-base tw-underline">Closed</h1>
|
||||||
|
<product-switcher></product-switcher>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="tw-text-main tw-text-base tw-underline">Open</h1>
|
||||||
|
<product-switcher-content #content></product-switcher-content>
|
||||||
|
<div class="tw-h-40">
|
||||||
|
<div class="cdk-overlay-pane bit-menu-panel">
|
||||||
|
<ng-container *ngTemplateOutlet="content?.menu?.templateRef"></ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NoOrgs = Template.bind({});
|
||||||
|
NoOrgs.args = {
|
||||||
|
mockOrgs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrgWithoutSecretsManager = Template.bind({});
|
||||||
|
OrgWithoutSecretsManager.args = {
|
||||||
|
mockOrgs: [{ id: "a" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrgWithSecretsManager = Template.bind({});
|
||||||
|
OrgWithSecretsManager.args = {
|
||||||
|
mockOrgs: [{ id: "b", canAccessSecretsManager: true }],
|
||||||
|
};
|
@ -25,6 +25,7 @@ import { UserVerificationComponent } from "../components/user-verification.compo
|
|||||||
import { FooterComponent } from "../layouts/footer.component";
|
import { FooterComponent } from "../layouts/footer.component";
|
||||||
import { FrontendLayoutComponent } from "../layouts/frontend-layout.component";
|
import { FrontendLayoutComponent } from "../layouts/frontend-layout.component";
|
||||||
import { NavbarComponent } from "../layouts/navbar.component";
|
import { NavbarComponent } from "../layouts/navbar.component";
|
||||||
|
import { ProductSwitcherModule } from "../layouts/product-switcher/product-switcher.module";
|
||||||
import { UserLayoutComponent } from "../layouts/user-layout.component";
|
import { UserLayoutComponent } from "../layouts/user-layout.component";
|
||||||
import { OrganizationCreateModule } from "../organizations/create/organization-create.module";
|
import { OrganizationCreateModule } from "../organizations/create/organization-create.module";
|
||||||
import { OrganizationLayoutComponent } from "../organizations/layouts/organization-layout.component";
|
import { OrganizationLayoutComponent } from "../organizations/layouts/organization-layout.component";
|
||||||
@ -130,7 +131,13 @@ import { SharedModule } from ".";
|
|||||||
// Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left.
|
// Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left.
|
||||||
// If you are building new functionality, please create or extend a feature module instead.
|
// If you are building new functionality, please create or extend a feature module instead.
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [SharedModule, VaultFilterModule, OrganizationCreateModule, RegisterFormModule],
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
VaultFilterModule,
|
||||||
|
OrganizationCreateModule,
|
||||||
|
RegisterFormModule,
|
||||||
|
ProductSwitcherModule,
|
||||||
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
PremiumBadgeComponent,
|
PremiumBadgeComponent,
|
||||||
AcceptEmergencyComponent,
|
AcceptEmergencyComponent,
|
||||||
|
@ -17,8 +17,8 @@ import {
|
|||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
IconModule,
|
IconModule,
|
||||||
MenuModule,
|
|
||||||
LinkModule,
|
LinkModule,
|
||||||
|
MenuModule,
|
||||||
NavigationModule,
|
NavigationModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
TabsModule,
|
TabsModule,
|
||||||
@ -57,6 +57,7 @@ import { WebI18nPipe } from "../core/web-i18n.pipe";
|
|||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
IconModule,
|
IconModule,
|
||||||
|
LinkModule,
|
||||||
MenuModule,
|
MenuModule,
|
||||||
NavigationModule,
|
NavigationModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
@ -87,6 +88,7 @@ import { WebI18nPipe } from "../core/web-i18n.pipe";
|
|||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
IconModule,
|
IconModule,
|
||||||
|
LinkModule,
|
||||||
MenuModule,
|
MenuModule,
|
||||||
NavigationModule,
|
NavigationModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
|
@ -5783,6 +5783,12 @@
|
|||||||
"memberAccessAll": {
|
"memberAccessAll": {
|
||||||
"message": "This member can access and modify all items."
|
"message": "This member can access and modify all items."
|
||||||
},
|
},
|
||||||
|
"moreFromBitwarden": {
|
||||||
|
"message": "More from Bitwarden"
|
||||||
|
},
|
||||||
|
"switchProducts": {
|
||||||
|
"message": "Switch Products"
|
||||||
|
},
|
||||||
"searchMyVault": {
|
"searchMyVault": {
|
||||||
"message": "Search My Vault"
|
"message": "Search My Vault"
|
||||||
},
|
},
|
||||||
|
@ -1 +0,0 @@
|
|||||||
<i class="bwi bwi-fw bwi-filter tw-text-2xl"></i>
|
|
@ -1,7 +0,0 @@
|
|||||||
import { Component } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "sm-filter",
|
|
||||||
templateUrl: "./filter.component.html",
|
|
||||||
})
|
|
||||||
export class FilterComponent {}
|
|
@ -4,7 +4,7 @@
|
|||||||
<input bitInput class="search tw-w-full" placeholder="{{ routeData.searchTitle | i18n }}" />
|
<input bitInput class="search tw-w-full" placeholder="{{ routeData.searchTitle | i18n }}" />
|
||||||
</div>
|
</div>
|
||||||
<sm-new-menu></sm-new-menu>
|
<sm-new-menu></sm-new-menu>
|
||||||
<sm-filter></sm-filter>
|
<product-switcher></product-switcher>
|
||||||
<ng-container *ngIf="account$ | async as account">
|
<ng-container *ngIf="account$ | async as account">
|
||||||
<button [bitMenuTriggerFor]="accountMenu" class="tw-border-0 tw-bg-transparent tw-p-0">
|
<button [bitMenuTriggerFor]="accountMenu" class="tw-border-0 tw-bg-transparent tw-p-0">
|
||||||
<bit-avatar [id]="account.userId" [text]="account.name || account.email"></bit-avatar>
|
<bit-avatar [id]="account.userId" [text]="account.name || account.email"></bit-avatar>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
|
||||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||||
|
|
||||||
import { BulkStatusDialogComponent } from "../layout/dialogs/bulk-status-dialog.component";
|
import { BulkStatusDialogComponent } from "../layout/dialogs/bulk-status-dialog.component";
|
||||||
import { FilterComponent } from "../layout/filter.component";
|
|
||||||
import { HeaderComponent } from "../layout/header.component";
|
import { HeaderComponent } from "../layout/header.component";
|
||||||
import { NewMenuComponent } from "../layout/new-menu.component";
|
import { NewMenuComponent } from "../layout/new-menu.component";
|
||||||
import { NoItemsComponent } from "../layout/no-items.component";
|
import { NoItemsComponent } from "../layout/no-items.component";
|
||||||
@ -11,11 +11,10 @@ import { NoItemsComponent } from "../layout/no-items.component";
|
|||||||
import { SecretsListComponent } from "./secrets-list.component";
|
import { SecretsListComponent } from "./secrets-list.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [SharedModule],
|
imports: [SharedModule, ProductSwitcherModule],
|
||||||
exports: [
|
exports: [
|
||||||
SharedModule,
|
SharedModule,
|
||||||
BulkStatusDialogComponent,
|
BulkStatusDialogComponent,
|
||||||
FilterComponent,
|
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
NewMenuComponent,
|
NewMenuComponent,
|
||||||
NoItemsComponent,
|
NoItemsComponent,
|
||||||
@ -23,7 +22,6 @@ import { SecretsListComponent } from "./secrets-list.component";
|
|||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
BulkStatusDialogComponent,
|
BulkStatusDialogComponent,
|
||||||
FilterComponent,
|
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
NewMenuComponent,
|
NewMenuComponent,
|
||||||
NoItemsComponent,
|
NoItemsComponent,
|
||||||
|
@ -2,7 +2,7 @@ import { Component, HostBinding, Input } from "@angular/core";
|
|||||||
|
|
||||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||||
|
|
||||||
export type IconButtonType = ButtonType | "contrast" | "main" | "muted";
|
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
||||||
|
|
||||||
const focusRing = [
|
const focusRing = [
|
||||||
// Workaround for box-shadow with transparent offset issue:
|
// Workaround for box-shadow with transparent offset issue:
|
||||||
@ -99,6 +99,17 @@ const styles: Record<IconButtonType, string[]> = {
|
|||||||
"disabled:hover:tw-border-danger-500",
|
"disabled:hover:tw-border-danger-500",
|
||||||
...focusRing,
|
...focusRing,
|
||||||
],
|
],
|
||||||
|
light: [
|
||||||
|
"tw-bg-transparent",
|
||||||
|
"!tw-text-alt2",
|
||||||
|
"tw-border-transparent",
|
||||||
|
"hover:tw-bg-transparent-hover",
|
||||||
|
"hover:tw-border-text-alt2",
|
||||||
|
"focus-visible:before:tw-ring-text-alt2",
|
||||||
|
"disabled:hover:tw-border-transparent",
|
||||||
|
"disabled:hover:tw-bg-transparent",
|
||||||
|
...focusRing,
|
||||||
|
],
|
||||||
unstyled: [],
|
unstyled: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ const buttonTypes: IconButtonType[] = [
|
|||||||
"primary",
|
"primary",
|
||||||
"secondary",
|
"secondary",
|
||||||
"danger",
|
"danger",
|
||||||
|
"light",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -38,15 +39,15 @@ const Template: Story<BitIconButtonComponent> = (args: BitIconButtonComponent) =
|
|||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td *ngFor="let buttonType of buttonTypes" class="tw-capitalize tw-font-bold tw-p-4"
|
<td *ngFor="let buttonType of buttonTypes" class="tw-capitalize tw-font-bold tw-p-4"
|
||||||
[class.tw-text-contrast]="buttonType === 'contrast'"
|
[class.tw-text-contrast]="['contrast', 'light'].includes(buttonType)"
|
||||||
[class.tw-bg-primary-500]="buttonType === 'contrast'">{{buttonType}}</td>
|
[class.tw-bg-primary-500]="['contrast', 'light'].includes(buttonType)">{{buttonType}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="tw-font-bold tw-p-4 tw-text-left">Default</td>
|
<td class="tw-font-bold tw-p-4 tw-text-left">Default</td>
|
||||||
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
|
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="['contrast', 'light'].includes(buttonType)">
|
||||||
<button
|
<button
|
||||||
[bitIconButton]="bitIconButton"
|
[bitIconButton]="bitIconButton"
|
||||||
[buttonType]="buttonType"
|
[buttonType]="buttonType"
|
||||||
@ -58,7 +59,7 @@ const Template: Story<BitIconButtonComponent> = (args: BitIconButtonComponent) =
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="tw-font-bold tw-p-4 tw-text-left">Disabled</td>
|
<td class="tw-font-bold tw-p-4 tw-text-left">Disabled</td>
|
||||||
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
|
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="['contrast', 'light'].includes(buttonType)">
|
||||||
<button
|
<button
|
||||||
[bitIconButton]="bitIconButton"
|
[bitIconButton]="bitIconButton"
|
||||||
[buttonType]="buttonType"
|
[buttonType]="buttonType"
|
||||||
@ -71,7 +72,7 @@ const Template: Story<BitIconButtonComponent> = (args: BitIconButtonComponent) =
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="tw-font-bold tw-p-4 tw-text-left">Loading</td>
|
<td class="tw-font-bold tw-p-4 tw-text-left">Loading</td>
|
||||||
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
|
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="['contrast', 'light'].includes(buttonType)">
|
||||||
<button
|
<button
|
||||||
[bitIconButton]="bitIconButton"
|
[bitIconButton]="bitIconButton"
|
||||||
[buttonType]="buttonType"
|
[buttonType]="buttonType"
|
||||||
|
@ -19,7 +19,9 @@ import { MenuComponent } from "./menu.component";
|
|||||||
})
|
})
|
||||||
export class MenuTriggerForDirective implements OnDestroy {
|
export class MenuTriggerForDirective implements OnDestroy {
|
||||||
@HostBinding("attr.aria-expanded") isOpen = false;
|
@HostBinding("attr.aria-expanded") isOpen = false;
|
||||||
@HostBinding("attr.aria-haspopup") hasPopup = "menu";
|
@HostBinding("attr.aria-haspopup") get hasPopup(): "menu" | "dialog" {
|
||||||
|
return this.menu?.ariaRole || "menu";
|
||||||
|
}
|
||||||
@HostBinding("attr.role") role = "button";
|
@HostBinding("attr.role") role = "button";
|
||||||
|
|
||||||
@Input("bitMenuTriggerFor") menu: MenuComponent;
|
@Input("bitMenuTriggerFor") menu: MenuComponent;
|
||||||
@ -86,9 +88,11 @@ export class MenuTriggerForDirective implements OnDestroy {
|
|||||||
}
|
}
|
||||||
this.destroyMenu();
|
this.destroyMenu();
|
||||||
});
|
});
|
||||||
this.keyDownEventsSub = this.overlayRef
|
this.keyDownEventsSub =
|
||||||
.keydownEvents()
|
this.menu.keyManager &&
|
||||||
.subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event));
|
this.overlayRef
|
||||||
|
.keydownEvents()
|
||||||
|
.subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyMenu() {
|
private destroyMenu() {
|
||||||
@ -102,9 +106,12 @@ export class MenuTriggerForDirective implements OnDestroy {
|
|||||||
|
|
||||||
private getClosedEvents(): Observable<any> {
|
private getClosedEvents(): Observable<any> {
|
||||||
const detachments = this.overlayRef.detachments();
|
const detachments = this.overlayRef.detachments();
|
||||||
const escKey = this.overlayRef
|
const escKey = this.overlayRef.keydownEvents().pipe(
|
||||||
.keydownEvents()
|
filter((event: KeyboardEvent) => {
|
||||||
.pipe(filter((event: KeyboardEvent) => event.key === "Escape" || event.key === "Tab"));
|
const keys = this.menu.ariaRole === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
||||||
|
return keys.includes(event.key);
|
||||||
|
})
|
||||||
|
);
|
||||||
const backdrop = this.overlayRef.backdropClick();
|
const backdrop = this.overlayRef.backdropClick();
|
||||||
const menuClosed = this.menu.closed;
|
const menuClosed = this.menu.closed;
|
||||||
|
|
||||||
|
@ -2,7 +2,10 @@
|
|||||||
<div
|
<div
|
||||||
(click)="closed.emit()"
|
(click)="closed.emit()"
|
||||||
class="tw-flex tw-shrink-0 tw-flex-col tw-rounded tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-2"
|
class="tw-flex tw-shrink-0 tw-flex-col tw-rounded tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-2"
|
||||||
role="menu"
|
[attr.role]="ariaRole"
|
||||||
|
[attr.aria-label]="ariaLabel"
|
||||||
|
cdkTrapFocus
|
||||||
|
[cdkTrapFocusAutoCapture]="true"
|
||||||
>
|
>
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
ContentChildren,
|
ContentChildren,
|
||||||
QueryList,
|
QueryList,
|
||||||
AfterContentInit,
|
AfterContentInit,
|
||||||
|
Input,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
|
|
||||||
import { MenuItemDirective } from "./menu-item.directive";
|
import { MenuItemDirective } from "./menu-item.directive";
|
||||||
@ -22,9 +23,15 @@ export class MenuComponent implements AfterContentInit {
|
|||||||
@Output() closed = new EventEmitter<void>();
|
@Output() closed = new EventEmitter<void>();
|
||||||
@ContentChildren(MenuItemDirective, { descendants: true })
|
@ContentChildren(MenuItemDirective, { descendants: true })
|
||||||
menuItems: QueryList<MenuItemDirective>;
|
menuItems: QueryList<MenuItemDirective>;
|
||||||
keyManager: FocusKeyManager<MenuItemDirective>;
|
keyManager?: FocusKeyManager<MenuItemDirective>;
|
||||||
|
|
||||||
|
@Input() ariaRole: "menu" | "dialog" = "menu";
|
||||||
|
|
||||||
|
@Input() ariaLabel: string;
|
||||||
|
|
||||||
ngAfterContentInit() {
|
ngAfterContentInit() {
|
||||||
this.keyManager = new FocusKeyManager(this.menuItems).withWrap();
|
if (this.ariaRole === "menu") {
|
||||||
|
this.keyManager = new FocusKeyManager(this.menuItems).withWrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { A11yModule } from "@angular/cdk/a11y";
|
||||||
import { OverlayModule } from "@angular/cdk/overlay";
|
import { OverlayModule } from "@angular/cdk/overlay";
|
||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
@ -8,7 +9,7 @@ import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
|
|||||||
import { MenuComponent } from "./menu.component";
|
import { MenuComponent } from "./menu.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, OverlayModule],
|
imports: [A11yModule, CommonModule, OverlayModule],
|
||||||
declarations: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
declarations: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
||||||
exports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
exports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user