1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[CL-707] Migrate CL codebase to signals (#15340)

This commit is contained in:
Vicki League
2025-07-16 08:39:37 -04:00
committed by GitHub
parent 97ec9a6339
commit 6811ea4c0b
124 changed files with 944 additions and 809 deletions

View File

@@ -20,6 +20,6 @@ export class PopupBackBrowserDirective extends BitActionDirective {
super(buttonComponent, validationService, logService); super(buttonComponent, validationService, logService);
// override `bitAction` input; the parent handles the rest // override `bitAction` input; the parent handles the rest
this.handler = () => this.router.back(); this.handler.set(() => this.router.back());
} }
} }

View File

@@ -195,7 +195,7 @@ describe("NavigationProductSwitcherComponent", () => {
const navItem = fixture.debugElement.query(By.directive(NavItemComponent)); const navItem = fixture.debugElement.query(By.directive(NavItemComponent));
expect(navItem.componentInstance.forceActiveStyles).toBe(true); expect(navItem.componentInstance.forceActiveStyles()).toBe(true);
}); });
}); });

View File

@@ -112,7 +112,7 @@ describe("VaultBannersComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent)); const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner.componentInstance.bannerType).toBe("premium"); expect(banner.componentInstance.bannerType()).toBe("premium");
}); });
it("dismisses premium banner", async () => { it("dismisses premium banner", async () => {

View File

@@ -6,6 +6,8 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
selector: "[appA11yTitle]", selector: "[appA11yTitle]",
}) })
export class A11yTitleDirective implements OnInit { export class A11yTitleDirective implements OnInit {
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() set appA11yTitle(title: string) { @Input() set appA11yTitle(title: string) {
this.title = title; this.title = title;
this.setAttributes(); this.setAttributes();

View File

@@ -6,7 +6,7 @@
}" }"
> >
<a <a
*ngIf="!hideLogo" *ngIf="!hideLogo()"
[routerLink]="['/']" [routerLink]="['/']"
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top" class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
> >
@@ -14,29 +14,29 @@
</a> </a>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass"> <div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
<div *ngIf="!hideIcon" class="tw-w-24 sm:tw-w-28 md:tw-w-32 tw-mx-auto"> <div *ngIf="!hideIcon()" class="tw-w-24 sm:tw-w-28 md:tw-w-32 tw-mx-auto">
<bit-icon [icon]="icon"></bit-icon> <bit-icon [icon]="icon()"></bit-icon>
</div> </div>
<ng-container *ngIf="title"> <ng-container *ngIf="title()">
<!-- Small screens --> <!-- Small screens -->
<h1 bitTypography="h3" class="tw-mt-2 sm:tw-hidden"> <h1 bitTypography="h3" class="tw-mt-2 sm:tw-hidden">
{{ title }} {{ title() }}
</h1> </h1>
<!-- Medium to Larger screens --> <!-- Medium to Larger screens -->
<h1 bitTypography="h2" class="tw-mt-2 tw-hidden sm:tw-block"> <h1 bitTypography="h2" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title }} {{ title() }}
</h1> </h1>
</ng-container> </ng-container>
<div *ngIf="subtitle" class="tw-text-sm sm:tw-text-base">{{ subtitle }}</div> <div *ngIf="subtitle()" class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
</div> </div>
<div <div
class="tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]" class="tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="maxWidthClass" [ngClass]="maxWidthClass"
> >
@if (hideCardWrapper) { @if (hideCardWrapper()) {
<div class="tw-mb-6 sm:tw-mb-10"> <div class="tw-mb-6 sm:tw-mb-10">
<ng-container *ngTemplateOutlet="defaultContent"></ng-container> <ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</div> </div>
@@ -50,11 +50,11 @@
<ng-content select="[slot=secondary]"></ng-content> <ng-content select="[slot=secondary]"></ng-content>
</div> </div>
<footer *ngIf="!hideFooter" class="tw-text-center tw-mt-4 sm:tw-mt-6"> <footer *ngIf="!hideFooter()" class="tw-text-center tw-mt-4 sm:tw-mt-6">
<div *ngIf="showReadonlyHostname" bitTypography="body2"> <div *ngIf="showReadonlyHostname()" bitTypography="body2">
{{ "accessing" | i18n }} {{ hostname }} {{ "accessing" | i18n }} {{ hostname }}
</div> </div>
<ng-container *ngIf="!showReadonlyHostname"> <ng-container *ngIf="!showReadonlyHostname()">
<ng-content select="[slot=environment-selector]"></ng-content> <ng-content select="[slot=environment-selector]"></ng-content>
</ng-container> </ng-container>
<ng-container *ngIf="!hideYearAndVersion"> <ng-container *ngIf="!hideYearAndVersion">

View File

@@ -1,7 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, HostBinding, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core"; import {
Component,
HostBinding,
OnChanges,
OnInit,
SimpleChanges,
input,
model,
} from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@@ -29,21 +37,21 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
return ["tw-h-full"]; return ["tw-h-full"];
} }
@Input() title: string; readonly title = input<string>();
@Input() subtitle: string; readonly subtitle = input<string>();
@Input() icon: Icon; readonly icon = model<Icon>();
@Input() showReadonlyHostname: boolean; readonly showReadonlyHostname = input<boolean>(false);
@Input() hideLogo: boolean = false; readonly hideLogo = input<boolean>(false);
@Input() hideFooter: boolean = false; readonly hideFooter = input<boolean>(false);
@Input() hideIcon: boolean = false; readonly hideIcon = input<boolean>(false);
@Input() hideCardWrapper: boolean = false; readonly hideCardWrapper = input<boolean>(false);
/** /**
* Max width of the anon layout title, subtitle, and content areas. * Max width of the anon layout title, subtitle, and content areas.
* *
* @default 'md' * @default 'md'
*/ */
@Input() maxWidth: AnonLayoutMaxWidth = "md"; readonly maxWidth = model<AnonLayoutMaxWidth>("md");
protected logo = BitwardenLogo; protected logo = BitwardenLogo;
protected year: string; protected year: string;
@@ -54,7 +62,8 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
protected hideYearAndVersion = false; protected hideYearAndVersion = false;
get maxWidthClass(): string { get maxWidthClass(): string {
switch (this.maxWidth) { const maxWidth = this.maxWidth();
switch (maxWidth) {
case "md": case "md":
return "tw-max-w-md"; return "tw-max-w-md";
case "lg": case "lg":
@@ -78,19 +87,19 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
} }
async ngOnInit() { async ngOnInit() {
this.maxWidth = this.maxWidth ?? "md"; this.maxWidth.set(this.maxWidth() ?? "md");
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname(); this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
this.version = await this.platformUtilsService.getApplicationVersion(); this.version = await this.platformUtilsService.getApplicationVersion();
// If there is no icon input, then use the default icon // If there is no icon input, then use the default icon
if (this.icon == null) { if (this.icon() == null) {
this.icon = AnonLayoutBitwardenShield; this.icon.set(AnonLayoutBitwardenShield);
} }
} }
async ngOnChanges(changes: SimpleChanges) { async ngOnChanges(changes: SimpleChanges) {
if (changes.maxWidth) { if (changes.maxWidth) {
this.maxWidth = changes.maxWidth.currentValue ?? "md"; this.maxWidth.set(changes.maxWidth.currentValue ?? "md");
} }
} }
} }

View File

@@ -19,17 +19,7 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getClientType = () => ClientType.Web; getClientType = () => ClientType.Web;
} }
type StoryArgs = Pick< type StoryArgs = AnonLayoutComponent & {
AnonLayoutComponent,
| "title"
| "subtitle"
| "showReadonlyHostname"
| "hideCardWrapper"
| "hideIcon"
| "hideLogo"
| "hideFooter"
| "maxWidth"
> & {
contentLength: "normal" | "long" | "thin"; contentLength: "normal" | "long" | "thin";
showSecondary: boolean; showSecondary: boolean;
useDefaultIcon: boolean; useDefaultIcon: boolean;
@@ -72,15 +62,14 @@ export default {
], ],
}), }),
], ],
render: (args) => {
render: (args: StoryArgs) => {
const { useDefaultIcon, icon, ...rest } = args; const { useDefaultIcon, icon, ...rest } = args;
return { return {
props: { props: {
...rest, ...rest,
icon: useDefaultIcon ? null : icon, icon: useDefaultIcon ? null : icon,
}, },
template: ` template: /*html*/ `
<auth-anon-layout <auth-anon-layout
[title]="title" [title]="title"
[subtitle]="subtitle" [subtitle]="subtitle"
@@ -160,7 +149,7 @@ export default {
contentLength: "normal", contentLength: "normal",
showSecondary: false, showSecondary: false,
}, },
} as Meta<StoryArgs>; } satisfies Meta<StoryArgs>;
type Story = StoryObj<StoryArgs>; type Story = StoryObj<StoryArgs>;

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core"; import { Directive, HostListener, model, OnDestroy, Optional } from "@angular/core";
import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs"; import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -38,7 +38,7 @@ export class BitActionDirective implements OnDestroy {
disabled = false; disabled = false;
@Input("bitAction") handler: FunctionReturningAwaitable; readonly handler = model<FunctionReturningAwaitable>(undefined, { alias: "bitAction" });
constructor( constructor(
private buttonComponent: ButtonLikeAbstraction, private buttonComponent: ButtonLikeAbstraction,
@@ -48,12 +48,12 @@ export class BitActionDirective implements OnDestroy {
@HostListener("click") @HostListener("click")
protected async onClick() { protected async onClick() {
if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled()) { if (!this.handler() || this.loading || this.disabled || this.buttonComponent.disabled()) {
return; return;
} }
this.loading = true; this.loading = true;
functionToObservable(this.handler) functionToObservable(this.handler())
.pipe( .pipe(
tap({ tap({
error: (err: unknown) => { error: (err: unknown) => {

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core"; import { Directive, OnDestroy, OnInit, Optional, input } from "@angular/core";
import { FormGroupDirective } from "@angular/forms"; import { FormGroupDirective } from "@angular/forms";
import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs"; import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs";
@@ -20,9 +20,9 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
private _loading$ = new BehaviorSubject<boolean>(false); private _loading$ = new BehaviorSubject<boolean>(false);
private _disabled$ = new BehaviorSubject<boolean>(false); private _disabled$ = new BehaviorSubject<boolean>(false);
@Input("bitSubmit") handler: FunctionReturningAwaitable; readonly handler = input<FunctionReturningAwaitable>(undefined, { alias: "bitSubmit" });
@Input() allowDisabledFormSubmit?: boolean = false; readonly allowDisabledFormSubmit = input<boolean>(false);
readonly loading$ = this._loading$.asObservable(); readonly loading$ = this._loading$.asObservable();
readonly disabled$ = this._disabled$.asObservable(); readonly disabled$ = this._disabled$.asObservable();
@@ -38,7 +38,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
switchMap(() => { switchMap(() => {
// Calling functionToObservable executes the sync part of the handler // Calling functionToObservable executes the sync part of the handler
// allowing the function to check form validity before it gets disabled. // allowing the function to check form validity before it gets disabled.
const awaitable = functionToObservable(this.handler); const awaitable = functionToObservable(this.handler());
// Disable form // Disable form
this.loading = true; this.loading = true;
@@ -61,7 +61,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.formGroupDirective.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => { this.formGroupDirective.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
if (this.allowDisabledFormSubmit) { if (this.allowDisabledFormSubmit()) {
this._disabled$.next(false); this._disabled$.next(false);
} else { } else {
this._disabled$.next(c === "DISABLED"); this._disabled$.next(c === "DISABLED");

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Directive, Input, OnDestroy, Optional } from "@angular/core"; import { Directive, OnDestroy, Optional, input } from "@angular/core";
import { Subject, takeUntil } from "rxjs"; import { Subject, takeUntil } from "rxjs";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
@@ -29,8 +29,8 @@ import { BitSubmitDirective } from "./bit-submit.directive";
export class BitFormButtonDirective implements OnDestroy { export class BitFormButtonDirective implements OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@Input() type: string; readonly type = input<string>();
@Input() disabled?: boolean; readonly disabled = input<boolean>();
constructor( constructor(
buttonComponent: ButtonLikeAbstraction, buttonComponent: ButtonLikeAbstraction,
@@ -39,16 +39,17 @@ export class BitFormButtonDirective implements OnDestroy {
) { ) {
if (submitDirective && buttonComponent) { if (submitDirective && buttonComponent) {
submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
if (this.type === "submit") { if (this.type() === "submit") {
buttonComponent.loading.set(loading); buttonComponent.loading.set(loading);
} else { } else {
buttonComponent.disabled.set(this.disabled || loading); buttonComponent.disabled.set(this.disabled() || loading);
} }
}); });
submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
if (this.disabled !== false) { const disabledValue = this.disabled();
buttonComponent.disabled.set(this.disabled || disabled); if (disabledValue !== false) {
buttonComponent.disabled.set(disabledValue || disabled);
} }
}); });
} }

View File

@@ -22,7 +22,6 @@ const template = /*html*/ `
template, template,
selector: "app-promise-example", selector: "app-promise-example",
imports: [AsyncActionsModule, ButtonModule, IconButtonModule], imports: [AsyncActionsModule, ButtonModule, IconButtonModule],
standalone: true,
}) })
class PromiseExampleComponent { class PromiseExampleComponent {
statusEmoji = "🟡"; statusEmoji = "🟡";

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { NgClass } from "@angular/common"; import { NgClass } from "@angular/common";
import { Component, Input, OnChanges } from "@angular/core"; import { Component, OnChanges, input } from "@angular/core";
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -25,17 +25,17 @@ const SizeClasses: Record<SizeTypes, string[]> = {
@Component({ @Component({
selector: "bit-avatar", selector: "bit-avatar",
template: `@if (src) { template: `@if (src) {
<img [src]="src" title="{{ title || text }}" [ngClass]="classList" /> <img [src]="src" title="{{ title() || text() }}" [ngClass]="classList" />
}`, }`,
imports: [NgClass], imports: [NgClass],
}) })
export class AvatarComponent implements OnChanges { export class AvatarComponent implements OnChanges {
@Input() border = false; readonly border = input(false);
@Input() color?: string; readonly color = input<string>();
@Input() id?: string; readonly id = input<string>();
@Input() text?: string; readonly text = input<string>();
@Input() title: string; readonly title = input<string>();
@Input() size: SizeTypes = "default"; readonly size = input<SizeTypes>("default");
private svgCharCount = 2; private svgCharCount = 2;
private svgFontSize = 20; private svgFontSize = 20;
@@ -51,13 +51,13 @@ export class AvatarComponent implements OnChanges {
get classList() { get classList() {
return ["tw-rounded-full"] return ["tw-rounded-full"]
.concat(SizeClasses[this.size] ?? []) .concat(SizeClasses[this.size()] ?? [])
.concat(this.border ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []); .concat(this.border() ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []);
} }
private generate() { private generate() {
let chars: string = null; let chars: string = null;
const upperCaseText = this.text?.toUpperCase() ?? ""; const upperCaseText = this.text()?.toUpperCase() ?? "";
chars = this.getFirstLetters(upperCaseText, this.svgCharCount); chars = this.getFirstLetters(upperCaseText, this.svgCharCount);
@@ -71,12 +71,13 @@ export class AvatarComponent implements OnChanges {
} }
let svg: HTMLElement; let svg: HTMLElement;
let hexColor = this.color; let hexColor = this.color();
if (!Utils.isNullOrWhitespace(this.color)) { const id = this.id();
if (!Utils.isNullOrWhitespace(this.color())) {
svg = this.createSvgElement(this.svgSize, hexColor); svg = this.createSvgElement(this.svgSize, hexColor);
} else if (!Utils.isNullOrWhitespace(this.id)) { } else if (!Utils.isNullOrWhitespace(id)) {
hexColor = Utils.stringToColor(this.id.toString()); hexColor = Utils.stringToColor(id.toString());
svg = this.createSvgElement(this.svgSize, hexColor); svg = this.createSvgElement(this.svgSize, hexColor);
} else { } else {
hexColor = Utils.stringToColor(upperCaseText); hexColor = Utils.stringToColor(upperCaseText);

View File

@@ -1,6 +1,6 @@
<div class="tw-inline-flex tw-flex-wrap tw-gap-2"> <div class="tw-inline-flex tw-flex-wrap tw-gap-2">
@for (item of filteredItems; track item; let last = $last) { @for (item of filteredItems; track item; let last = $last) {
<span bitBadge [variant]="variant" [truncate]="truncate"> <span bitBadge [variant]="variant()" [truncate]="truncate()">
{{ item }} {{ item }}
</span> </span>
@if (!last || isFiltered) { @if (!last || isFiltered) {
@@ -8,8 +8,8 @@
} }
} }
@if (isFiltered) { @if (isFiltered) {
<span bitBadge [variant]="variant"> <span bitBadge [variant]="variant()">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }} {{ "plusNMore" | i18n: (items().length - filteredItems.length).toString() }}
</span> </span>
} }
</div> </div>

View File

@@ -1,42 +1,36 @@
// FIXME: Update this file to be type safe and remove this and next line import { Component, OnChanges, input } from "@angular/core";
// @ts-strict-ignore
import { Component, Input, OnChanges } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { BadgeModule, BadgeVariant } from "../badge"; import { BadgeModule, BadgeVariant } from "../badge";
function transformMaxItems(value: number | undefined) {
return value == undefined ? undefined : Math.max(1, value);
}
@Component({ @Component({
selector: "bit-badge-list", selector: "bit-badge-list",
templateUrl: "badge-list.component.html", templateUrl: "badge-list.component.html",
imports: [BadgeModule, I18nPipe], imports: [BadgeModule, I18nPipe],
}) })
export class BadgeListComponent implements OnChanges { export class BadgeListComponent implements OnChanges {
private _maxItems: number;
protected filteredItems: string[] = []; protected filteredItems: string[] = [];
protected isFiltered = false; protected isFiltered = false;
@Input() variant: BadgeVariant = "primary"; readonly variant = input<BadgeVariant>("primary");
@Input() items: string[] = []; readonly items = input<string[]>([]);
@Input() truncate = true; readonly truncate = input(true);
@Input() readonly maxItems = input(undefined, { transform: transformMaxItems });
get maxItems(): number | undefined {
return this._maxItems;
}
set maxItems(value: number | undefined) {
this._maxItems = value == undefined ? undefined : Math.max(1, value);
}
ngOnChanges() { ngOnChanges() {
if (this.maxItems == undefined || this.items.length <= this.maxItems) { const maxItems = this.maxItems();
this.filteredItems = this.items;
if (maxItems == undefined || this.items().length <= maxItems) {
this.filteredItems = this.items();
} else { } else {
this.filteredItems = this.items.slice(0, this.maxItems - 1); this.filteredItems = this.items().slice(0, maxItems - 1);
} }
this.isFiltered = this.items.length > this.filteredItems.length; this.isFiltered = this.items().length > this.filteredItems.length;
} }
} }

View File

@@ -1,3 +1,3 @@
<span [ngClass]="{ 'tw-truncate tw-block': truncate }"> <span [ngClass]="{ 'tw-truncate tw-block': truncate() }">
<ng-content></ng-content> <ng-content></ng-content>
</span> </span>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, ElementRef, HostBinding, Input } from "@angular/core"; import { Component, ElementRef, HostBinding, input } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element"; import { FocusableElement } from "../shared/focusable-element";
@@ -89,33 +89,34 @@ export class BadgeComponent implements FocusableElement {
"disabled:hover:!tw-text-muted", "disabled:hover:!tw-text-muted",
"disabled:tw-cursor-not-allowed", "disabled:tw-cursor-not-allowed",
] ]
.concat(styles[this.variant]) .concat(styles[this.variant()])
.concat(this.hasHoverEffects ? [...hoverStyles[this.variant], "tw-min-w-10"] : []) .concat(this.hasHoverEffects ? [...hoverStyles[this.variant()], "tw-min-w-10"] : [])
.concat(this.truncate ? this.maxWidthClass : []); .concat(this.truncate() ? this.maxWidthClass() : []);
} }
@HostBinding("attr.title") get titleAttr() { @HostBinding("attr.title") get titleAttr() {
if (this.title !== undefined) { const title = this.title();
return this.title; if (title !== undefined) {
return title;
} }
return this.truncate ? this?.el?.nativeElement?.textContent?.trim() : null; return this.truncate() ? this?.el?.nativeElement?.textContent?.trim() : null;
} }
/** /**
* Optional override for the automatic badge title when truncating. * Optional override for the automatic badge title when truncating.
*/ */
@Input() title?: string; readonly title = input<string>();
/** /**
* Variant, sets the background color of the badge. * Variant, sets the background color of the badge.
*/ */
@Input() variant: BadgeVariant = "primary"; readonly variant = input<BadgeVariant>("primary");
/** /**
* Truncate long text * Truncate long text
*/ */
@Input() truncate = true; readonly truncate = input(true);
@Input() maxWidthClass: `tw-max-w-${string}` = "tw-max-w-40"; readonly maxWidthClass = input<`tw-max-w-${string}`>("tw-max-w-40");
getFocusTarget() { getFocusTarget() {
return this.el.nativeElement; return this.el.nativeElement;

View File

@@ -1,10 +1,10 @@
<div <div
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0" class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
[ngClass]="bannerClass" [ngClass]="bannerClass"
[attr.role]="useAlertRole ? 'status' : null" [attr.role]="useAlertRole() ? 'status' : null"
[attr.aria-live]="useAlertRole ? 'polite' : null" [attr.aria-live]="useAlertRole() ? 'polite' : null"
> >
@if (icon) { @if (icon(); as icon) {
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" aria-hidden="true"></i> <i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" aria-hidden="true"></i>
} }
<!-- Overriding focus-visible color for link buttons for a11y against colored background --> <!-- Overriding focus-visible color for link buttons for a11y against colored background -->
@@ -12,7 +12,7 @@
<ng-content></ng-content> <ng-content></ng-content>
</span> </span>
<!-- Overriding hover and focus-visible colors for a11y against colored background --> <!-- Overriding hover and focus-visible colors for a11y against colored background -->
@if (showClose) { @if (showClose()) {
<button <button
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main" class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
type="button" type="button"

View File

@@ -30,17 +30,17 @@ describe("BannerComponent", () => {
}); });
it("should create with alert", () => { it("should create with alert", () => {
expect(component.useAlertRole).toBe(true); expect(component.useAlertRole()).toBe(true);
const el = fixture.nativeElement.children[0]; const el = fixture.nativeElement.children[0];
expect(el.getAttribute("role")).toEqual("status"); expect(el.getAttribute("role")).toEqual("status");
expect(el.getAttribute("aria-live")).toEqual("polite"); expect(el.getAttribute("aria-live")).toEqual("polite");
}); });
it("useAlertRole=false", () => { it("useAlertRole=false", () => {
component.useAlertRole = false; fixture.componentRef.setInput("useAlertRole", false);
fixture.autoDetectChanges(); fixture.autoDetectChanges();
expect(component.useAlertRole).toBe(false); expect(component.useAlertRole()).toBe(false);
const el = fixture.nativeElement.children[0]; const el = fixture.nativeElement.children[0];
expect(el.getAttribute("role")).toBeNull(); expect(el.getAttribute("role")).toBeNull();
expect(el.getAttribute("aria-live")).toBeNull(); expect(el.getAttribute("aria-live")).toBeNull();

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input, OnInit, Output, EventEmitter } from "@angular/core"; import { Component, OnInit, Output, EventEmitter, input, model } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -31,19 +29,22 @@ const defaultIcon: Record<BannerType, string> = {
imports: [CommonModule, IconButtonModule, I18nPipe], imports: [CommonModule, IconButtonModule, I18nPipe],
}) })
export class BannerComponent implements OnInit { export class BannerComponent implements OnInit {
@Input("bannerType") bannerType: BannerType = "info"; readonly bannerType = input<BannerType>("info");
@Input() icon: string;
@Input() useAlertRole = true; readonly icon = model<string>();
@Input() showClose = true; readonly useAlertRole = input(true);
readonly showClose = input(true);
@Output() onClose = new EventEmitter<void>(); @Output() onClose = new EventEmitter<void>();
ngOnInit(): void { ngOnInit(): void {
this.icon ??= defaultIcon[this.bannerType]; if (!this.icon()) {
this.icon.set(defaultIcon[this.bannerType()]);
}
} }
get bannerClass() { get bannerClass() {
switch (this.bannerType) { switch (this.bannerType()) {
case "danger": case "danger":
return "tw-bg-danger-100 tw-border-b-danger-700"; return "tw-bg-danger-100 tw-border-b-danger-700";
case "info": case "info":

View File

@@ -1,5 +1,5 @@
<ng-template> <ng-template>
@if (icon) { @if (icon(); as icon) {
<i class="bwi {{ icon }} !tw-me-2" aria-hidden="true"></i> <i class="bwi {{ icon }} !tw-me-2" aria-hidden="true"></i>
} }
<ng-content></ng-content> <ng-content></ng-content>

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core"; import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core";
import { QueryParamsHandling } from "@angular/router"; import { QueryParamsHandling } from "@angular/router";
@Component({ @Component({
@@ -9,17 +9,13 @@ import { QueryParamsHandling } from "@angular/router";
templateUrl: "./breadcrumb.component.html", templateUrl: "./breadcrumb.component.html",
}) })
export class BreadcrumbComponent { export class BreadcrumbComponent {
@Input() readonly icon = input<string>();
icon?: string;
@Input() readonly route = input<string | any[]>();
route?: string | any[] = undefined;
@Input() readonly queryParams = input<Record<string, string>>({});
queryParams?: Record<string, string> = {};
@Input() readonly queryParamsHandling = input<QueryParamsHandling>();
queryParamsHandling?: QueryParamsHandling;
@Output() @Output()
click = new EventEmitter(); click = new EventEmitter();

View File

@@ -1,17 +1,16 @@
@for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) { @for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) {
@if (breadcrumb.route) { @if (breadcrumb.route(); as route) {
<a <a
bitLink bitLink
linkType="primary" linkType="primary"
class="tw-my-2 tw-inline-block" class="tw-my-2 tw-inline-block"
[routerLink]="breadcrumb.route" [routerLink]="route"
[queryParams]="breadcrumb.queryParams" [queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling" [queryParamsHandling]="breadcrumb.queryParamsHandling()"
> >
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container> <ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a> </a>
} } @else {
@if (!breadcrumb.route) {
<button <button
type="button" type="button"
bitLink bitLink
@@ -39,18 +38,17 @@
></button> ></button>
<bit-menu #overflowMenu> <bit-menu #overflowMenu>
@for (breadcrumb of overflow; track breadcrumb) { @for (breadcrumb of overflow; track breadcrumb) {
@if (breadcrumb.route) { @if (breadcrumb.route(); as route) {
<a <a
bitMenuItem bitMenuItem
linkType="primary" linkType="primary"
[routerLink]="breadcrumb.route" [routerLink]="route"
[queryParams]="breadcrumb.queryParams" [queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling" [queryParamsHandling]="breadcrumb.queryParamsHandling()"
> >
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container> <ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a> </a>
} } @else {
@if (!breadcrumb.route) {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)"> <button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container> <ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button> </button>
@@ -59,19 +57,18 @@
</bit-menu> </bit-menu>
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i> <i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
@for (breadcrumb of afterOverflow; track breadcrumb; let last = $last) { @for (breadcrumb of afterOverflow; track breadcrumb; let last = $last) {
@if (breadcrumb.route) { @if (breadcrumb.route(); as route) {
<a <a
bitLink bitLink
linkType="primary" linkType="primary"
class="tw-my-2 tw-inline-block" class="tw-my-2 tw-inline-block"
[routerLink]="breadcrumb.route" [routerLink]="route"
[queryParams]="breadcrumb.queryParams" [queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling" [queryParamsHandling]="breadcrumb.queryParamsHandling()"
> >
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container> <ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a> </a>
} } @else {
@if (!breadcrumb.route) {
<button <button
type="button" type="button"
bitLink bitLink

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, ContentChildren, Input, QueryList } from "@angular/core"; import { Component, ContentChildren, QueryList, input } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { IconButtonModule } from "../icon-button"; import { IconButtonModule } from "../icon-button";
@@ -19,8 +19,7 @@ import { BreadcrumbComponent } from "./breadcrumb.component";
imports: [CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule], imports: [CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule],
}) })
export class BreadcrumbsComponent { export class BreadcrumbsComponent {
@Input() readonly show = input(3);
show = 3;
private breadcrumbs: BreadcrumbComponent[] = []; private breadcrumbs: BreadcrumbComponent[] = [];
@@ -31,14 +30,14 @@ export class BreadcrumbsComponent {
protected get beforeOverflow() { protected get beforeOverflow() {
if (this.hasOverflow) { if (this.hasOverflow) {
return this.breadcrumbs.slice(0, this.show - 1); return this.breadcrumbs.slice(0, this.show() - 1);
} }
return this.breadcrumbs; return this.breadcrumbs;
} }
protected get overflow() { protected get overflow() {
return this.breadcrumbs.slice(this.show - 1, -1); return this.breadcrumbs.slice(this.show() - 1, -1);
} }
protected get afterOverflow() { protected get afterOverflow() {
@@ -46,6 +45,6 @@ export class BreadcrumbsComponent {
} }
protected get hasOverflow() { protected get hasOverflow() {
return this.breadcrumbs.length > this.show; return this.breadcrumbs.length > this.show();
} }
} }

View File

@@ -1,7 +1,5 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass } from "@angular/common"; import { NgClass } from "@angular/common";
import { import {
Input,
HostBinding, HostBinding,
Component, Component,
model, model,
@@ -10,6 +8,7 @@ import {
ElementRef, ElementRef,
inject, inject,
Signal, Signal,
booleanAttribute,
} from "@angular/core"; } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { debounce, interval } from "rxjs"; import { debounce, interval } from "rxjs";
@@ -79,7 +78,7 @@ export class ButtonComponent implements ButtonLikeAbstraction {
"hover:tw-no-underline", "hover:tw-no-underline",
"focus:tw-outline-none", "focus:tw-outline-none",
] ]
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"]) .concat(this.block() ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
.concat( .concat(
this.showDisabledStyles() || this.disabled() this.showDisabledStyles() || this.disabled()
? [ ? [
@@ -95,7 +94,7 @@ export class ButtonComponent implements ButtonLikeAbstraction {
] ]
: [], : [],
) )
.concat(buttonStyles[this.buttonType ?? "secondary"]) .concat(buttonStyles[this.buttonType() ?? "secondary"])
.concat(buttonSizeStyles[this.size() || "default"]); .concat(buttonSizeStyles[this.size() || "default"]);
} }
@@ -116,22 +115,13 @@ export class ButtonComponent implements ButtonLikeAbstraction {
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false); return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
}); });
@Input() buttonType: ButtonType = "secondary"; readonly buttonType = input<ButtonType>("secondary");
size = input<ButtonSize>("default"); readonly size = input<ButtonSize>("default");
private _block = false; readonly block = input(false, { transform: booleanAttribute });
@Input() readonly loading = model<boolean>(false);
get block(): boolean {
return this._block;
}
set block(value: boolean | "") {
this._block = coerceBooleanProperty(value);
}
loading = model<boolean>(false);
/** /**
* Determine whether it is appropriate to display a loading spinner. We only want to show * Determine whether it is appropriate to display a loading spinner. We only want to show
@@ -149,7 +139,7 @@ export class ButtonComponent implements ButtonLikeAbstraction {
toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))), toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))),
); );
disabled = model<boolean>(false); readonly disabled = model<boolean>(false);
private el = inject(ElementRef<HTMLButtonElement>); private el = inject(ElementRef<HTMLButtonElement>);
constructor() { constructor() {

View File

@@ -3,12 +3,12 @@
[ngClass]="calloutClass" [ngClass]="calloutClass"
[attr.aria-labelledby]="titleId" [attr.aria-labelledby]="titleId"
> >
@if (title) { @if (titleComputed(); as title) {
<header <header
id="{{ titleId }}" id="{{ titleId }}"
class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-start" class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-start"
> >
@if (icon) { @if (iconComputed(); as icon) {
<i <i
class="bwi !tw-text-main tw-relative tw-top-[3px]" class="bwi !tw-text-main tw-relative tw-top-[3px]"
[ngClass]="[icon]" [ngClass]="[icon]"

View File

@@ -30,31 +30,31 @@ describe("Callout", () => {
describe("default state", () => { describe("default state", () => {
it("success", () => { it("success", () => {
component.type = "success"; fixture.componentRef.setInput("type", "success");
fixture.detectChanges(); fixture.detectChanges();
expect(component.title).toBeUndefined(); expect(component.titleComputed()).toBeUndefined();
expect(component.icon).toBe("bwi-check-circle"); expect(component.iconComputed()).toBe("bwi-check-circle");
}); });
it("info", () => { it("info", () => {
component.type = "info"; fixture.componentRef.setInput("type", "info");
fixture.detectChanges(); fixture.detectChanges();
expect(component.title).toBeUndefined(); expect(component.titleComputed()).toBeUndefined();
expect(component.icon).toBe("bwi-info-circle"); expect(component.iconComputed()).toBe("bwi-info-circle");
}); });
it("warning", () => { it("warning", () => {
component.type = "warning"; fixture.componentRef.setInput("type", "warning");
fixture.detectChanges(); fixture.detectChanges();
expect(component.title).toBe("Warning"); expect(component.titleComputed()).toBe("Warning");
expect(component.icon).toBe("bwi-exclamation-triangle"); expect(component.iconComputed()).toBe("bwi-exclamation-triangle");
}); });
it("danger", () => { it("danger", () => {
component.type = "danger"; fixture.componentRef.setInput("type", "danger");
fixture.detectChanges(); fixture.detectChanges();
expect(component.title).toBe("Error"); expect(component.titleComputed()).toBe("Error");
expect(component.icon).toBe("bwi-error"); expect(component.iconComputed()).toBe("bwi-error");
}); });
}); });
}); });

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core"; import { Component, computed, input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -34,24 +34,28 @@ let nextId = 0;
templateUrl: "callout.component.html", templateUrl: "callout.component.html",
imports: [SharedModule, TypographyModule], imports: [SharedModule, TypographyModule],
}) })
export class CalloutComponent implements OnInit { export class CalloutComponent {
@Input() type: CalloutTypes = "info"; readonly type = input<CalloutTypes>("info");
@Input() icon: string; readonly icon = input<string>();
@Input() title: string; readonly title = input<string>();
@Input() useAlertRole = false; readonly useAlertRole = input(false);
readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]);
readonly titleComputed = computed(() => {
const title = this.title();
const type = this.type();
if (title == null && defaultI18n[type] != null) {
return this.i18nService.t(defaultI18n[type]);
}
return title;
});
protected titleId = `bit-callout-title-${nextId++}`; protected titleId = `bit-callout-title-${nextId++}`;
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService) {}
ngOnInit() {
this.icon ??= defaultIcon[this.type];
if (this.title == null && defaultI18n[this.type] != null) {
this.title = this.i18nService.t(defaultI18n[this.type]);
}
}
get calloutClass() { get calloutClass() {
switch (this.type) { switch (this.type()) {
case "danger": case "danger":
return "tw-bg-danger-100"; return "tw-bg-danger-100";
case "info": case "info":

View File

@@ -68,3 +68,18 @@ export const Danger: Story = {
type: "danger", type: "danger",
}, },
}; };
export const CustomIcon: Story = {
...Info,
args: {
...Info.args,
icon: "bwi-star",
},
};
export const NoTitle: Story = {
...Info,
args: {
icon: "bwi-star",
},
};

View File

@@ -104,6 +104,8 @@ export class CheckboxComponent implements BitFormControlAbstraction {
protected indeterminateImage = protected indeterminateImage =
`url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`; `url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`;
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@HostBinding() @HostBinding()
@Input() @Input()
get disabled() { get disabled() {
@@ -114,6 +116,8 @@ export class CheckboxComponent implements BitFormControlAbstraction {
} }
private _disabled: boolean; private _disabled: boolean;
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() @Input()
get required() { get required() {
return ( return (

View File

@@ -31,7 +31,7 @@ const template = /*html*/ `
@Component({ @Component({
selector: "app-example", selector: "app-example",
template, template,
imports: [CheckboxModule, FormFieldModule, ReactiveFormsModule], imports: [FormControlModule, CheckboxModule, FormsModule, FormFieldModule, ReactiveFormsModule],
}) })
class ExampleComponent { class ExampleComponent {
protected formObj = this.formBuilder.group({ protected formObj = this.formBuilder.group({

View File

@@ -69,10 +69,10 @@
bitMenuItem bitMenuItem
(click)="viewOption(parent, $event)" (click)="viewOption(parent, $event)"
class="tw-text-[length:inherit]" class="tw-text-[length:inherit]"
[title]="'backTo' | i18n: parent.label ?? placeholderText" [title]="'backTo' | i18n: parent.label ?? placeholderText()"
> >
<i slot="start" class="bwi bwi-angle-left" aria-hidden="true"></i> <i slot="start" class="bwi bwi-angle-left" aria-hidden="true"></i>
{{ "backTo" | i18n: parent.label ?? placeholderText }} {{ "backTo" | i18n: parent.label ?? placeholderText() }}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -14,6 +14,7 @@ import {
booleanAttribute, booleanAttribute,
inject, inject,
signal, signal,
input,
} from "@angular/core"; } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
@@ -54,12 +55,15 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
@ViewChild("chipSelectButton") chipSelectButton: ElementRef<HTMLButtonElement>; @ViewChild("chipSelectButton") chipSelectButton: ElementRef<HTMLButtonElement>;
/** Text to show when there is no selected option */ /** Text to show when there is no selected option */
@Input({ required: true }) placeholderText: string; readonly placeholderText = input.required<string>();
/** Icon to show when there is no selected option or the selected option does not have an icon */ /** Icon to show when there is no selected option or the selected option does not have an icon */
@Input() placeholderIcon: string; readonly placeholderIcon = input<string>();
private _options: ChipSelectOption<T>[]; private _options: ChipSelectOption<T>[];
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
/** The select options to render */ /** The select options to render */
@Input({ required: true }) @Input({ required: true })
get options(): ChipSelectOption<T>[] { get options(): ChipSelectOption<T>[] {
@@ -71,10 +75,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
} }
/** Disables the entire chip */ /** Disables the entire chip */
// TODO: Skipped for signal migration because:
// Your application code writes to the input. This prevents migration.
@Input({ transform: booleanAttribute }) disabled = false; @Input({ transform: booleanAttribute }) disabled = false;
/** Chip will stretch to full width of its container */ /** Chip will stretch to full width of its container */
@Input({ transform: booleanAttribute }) fullWidth?: boolean; readonly fullWidth = input<boolean, unknown>(undefined, { transform: booleanAttribute });
/** /**
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within` * We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
@@ -91,7 +97,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
@HostBinding("class") @HostBinding("class")
get classList() { get classList() {
return ["tw-inline-block", this.fullWidth ? "tw-w-full" : "tw-max-w-52"]; return ["tw-inline-block", this.fullWidth() ? "tw-w-full" : "tw-max-w-52"];
} }
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -113,12 +119,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
/** The label to show in the chip button */ /** The label to show in the chip button */
protected get label(): string { protected get label(): string {
return this.selectedOption?.label || this.placeholderText; return this.selectedOption?.label || this.placeholderText();
} }
/** The icon to show in the chip button */ /** The icon to show in the chip button */
protected get icon(): string { protected get icon(): string {
return this.selectedOption?.icon || this.placeholderIcon; return this.selectedOption?.icon || this.placeholderIcon();
} }
/** /**

View File

@@ -1,4 +1,12 @@
import { Directive, HostListener, Input, InjectionToken, Inject, Optional } from "@angular/core"; import {
Directive,
HostListener,
Input,
InjectionToken,
Inject,
Optional,
input,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -28,13 +36,13 @@ export class CopyClickDirective {
@Optional() @Inject(COPY_CLICK_LISTENER) private copyListener?: CopyClickListener, @Optional() @Inject(COPY_CLICK_LISTENER) private copyListener?: CopyClickListener,
) {} ) {}
@Input("appCopyClick") valueToCopy = ""; readonly valueToCopy = input("", { alias: "appCopyClick" });
/** /**
* When set, the toast displayed will show `<valueLabel> copied` * When set, the toast displayed will show `<valueLabel> copied`
* instead of the default messaging. * instead of the default messaging.
*/ */
@Input() valueLabel?: string; readonly valueLabel = input<string>();
/** /**
* When set without a value, a success toast will be shown when the value is copied * When set without a value, a success toast will be shown when the value is copied
@@ -49,6 +57,8 @@ export class CopyClickDirective {
* <app-component [appCopyClick]="value to copy" showToast="info"/></app-component> * <app-component [appCopyClick]="value to copy" showToast="info"/></app-component>
* ``` * ```
*/ */
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() set showToast(value: ToastVariant | "") { @Input() set showToast(value: ToastVariant | "") {
// When the `showToast` is set without a value, an empty string will be passed // When the `showToast` is set without a value, an empty string will be passed
if (value === "") { if (value === "") {
@@ -60,15 +70,17 @@ export class CopyClickDirective {
} }
@HostListener("click") onClick() { @HostListener("click") onClick() {
this.platformUtilsService.copyToClipboard(this.valueToCopy); const valueToCopy = this.valueToCopy();
this.platformUtilsService.copyToClipboard(valueToCopy);
if (this.copyListener) { if (this.copyListener) {
this.copyListener.onCopy(this.valueToCopy); this.copyListener.onCopy(valueToCopy);
} }
if (this._showToast) { if (this._showToast) {
const message = this.valueLabel const valueLabel = this.valueLabel();
? this.i18nService.t("valueCopied", this.valueLabel) const message = valueLabel
? this.i18nService.t("valueCopied", valueLabel)
: this.i18nService.t("copySuccessful"); : this.i18nService.t("copySuccessful");
this.toastService.showToast({ this.toastService.showToast({

View File

@@ -6,7 +6,7 @@
cdkTrapFocus cdkTrapFocus
cdkTrapFocusAutoCapture cdkTrapFocusAutoCapture
> >
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top; @let showHeaderBorder = !isDrawer || background() === "alt" || bodyHasScrolledFrom().top;
<header <header
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid" class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
[ngClass]="{ [ngClass]="{
@@ -22,10 +22,10 @@
noMargin noMargin
class="tw-text-main tw-mb-0 tw-truncate" class="tw-text-main tw-mb-0 tw-truncate"
> >
{{ title }} {{ title() }}
@if (subtitle) { @if (subtitle(); as subtitleText) {
<span class="tw-text-muted tw-font-normal tw-text-sm"> <span class="tw-text-muted tw-font-normal tw-text-sm">
{{ subtitle }} {{ subtitleText }}
</span> </span>
} }
<ng-content select="[bitDialogTitle]"></ng-content> <ng-content select="[bitDialogTitle]"></ng-content>
@@ -46,12 +46,12 @@
<div <div
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden" class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
[ngClass]="{ [ngClass]="{
'tw-min-h-60': loading, 'tw-min-h-60': loading(),
'tw-bg-background': background === 'default', 'tw-bg-background': background() === 'default',
'tw-bg-background-alt': background === 'alt', 'tw-bg-background-alt': background() === 'alt',
}" }"
> >
@if (loading) { @if (loading()) {
<div class="tw-absolute tw-flex tw-size-full tw-items-center tw-justify-center"> <div class="tw-absolute tw-flex tw-size-full tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i> <i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i>
</div> </div>
@@ -59,17 +59,17 @@
<div <div
cdkScrollable cdkScrollable
[ngClass]="{ [ngClass]="{
'tw-p-4': !disablePadding && !isDrawer, 'tw-p-4': !disablePadding() && !isDrawer,
'tw-px-6 tw-py-4': !disablePadding && isDrawer, 'tw-px-6 tw-py-4': !disablePadding() && isDrawer,
'tw-overflow-y-auto': !loading, 'tw-overflow-y-auto': !loading(),
'tw-invisible tw-overflow-y-hidden': loading, 'tw-invisible tw-overflow-y-hidden': loading(),
}" }"
> >
<ng-content select="[bitDialogContent]"></ng-content> <ng-content select="[bitDialogContent]"></ng-content>
</div> </div>
</div> </div>
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom; @let showFooterBorder = !isDrawer || background() === "alt" || bodyHasScrolledFrom().bottom;
<footer <footer
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background" class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']" [ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"

View File

@@ -1,10 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CdkTrapFocus } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { CdkScrollable } from "@angular/cdk/scrolling"; import { CdkScrollable } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core"; import { Component, HostBinding, inject, viewChild, input, booleanAttribute } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -40,39 +37,32 @@ export class DialogComponent {
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody); protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
/** Background color */ /** Background color */
@Input() readonly background = input<"default" | "alt">("default");
background: "default" | "alt" = "default";
/** /**
* Dialog size, more complex dialogs should use large, otherwise default is fine. * Dialog size, more complex dialogs should use large, otherwise default is fine.
*/ */
@Input() dialogSize: "small" | "default" | "large" = "default"; readonly dialogSize = input<"small" | "default" | "large">("default");
/** /**
* Title to show in the dialog's header * Title to show in the dialog's header
*/ */
@Input() title: string; readonly title = input<string>();
/** /**
* Subtitle to show in the dialog's header * Subtitle to show in the dialog's header
*/ */
@Input() subtitle: string; readonly subtitle = input<string>();
private _disablePadding = false;
/** /**
* Disable the built-in padding on the dialog, for use with tabbed dialogs. * Disable the built-in padding on the dialog, for use with tabbed dialogs.
*/ */
@Input() set disablePadding(value: boolean | "") { readonly disablePadding = input(false, { transform: booleanAttribute });
this._disablePadding = coerceBooleanProperty(value);
}
get disablePadding() {
return this._disablePadding;
}
/** /**
* Mark the dialog as loading which replaces the content with a spinner. * Mark the dialog as loading which replaces the content with a spinner.
*/ */
@Input() loading = false; readonly loading = input(false);
@HostBinding("class") get classes() { @HostBinding("class") get classes() {
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
@@ -94,7 +84,7 @@ export class DialogComponent {
} }
get width() { get width() {
switch (this.dialogSize) { switch (this.dialogSize()) {
case "small": { case "small": {
return "md:tw-max-w-sm"; return "md:tw-max-w-sm";
} }

View File

@@ -1,11 +1,11 @@
import { DialogRef } from "@angular/cdk/dialog"; import { DialogRef } from "@angular/cdk/dialog";
import { Directive, HostBinding, HostListener, Input, Optional } from "@angular/core"; import { Directive, HostBinding, HostListener, Optional, input } from "@angular/core";
@Directive({ @Directive({
selector: "[bitDialogClose]", selector: "[bitDialogClose]",
}) })
export class DialogCloseDirective { export class DialogCloseDirective {
@Input("bitDialogClose") dialogResult: any; readonly dialogResult = input<any>(undefined, { alias: "bitDialogClose" });
constructor(@Optional() public dialogRef: DialogRef) {} constructor(@Optional() public dialogRef: DialogRef) {}
@@ -20,6 +20,6 @@ export class DialogCloseDirective {
return; return;
} }
this.dialogRef.close(this.dialogResult); this.dialogRef.close(this.dialogResult());
} }
} }

View File

@@ -1,5 +1,5 @@
import { CdkDialogContainer, DialogRef } from "@angular/cdk/dialog"; import { CdkDialogContainer, DialogRef } from "@angular/cdk/dialog";
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core"; import { Directive, HostBinding, OnInit, Optional, input } from "@angular/core";
// Increments for each instance of this component // Increments for each instance of this component
let nextId = 0; let nextId = 0;
@@ -10,7 +10,7 @@ let nextId = 0;
export class DialogTitleContainerDirective implements OnInit { export class DialogTitleContainerDirective implements OnInit {
@HostBinding("id") id = `bit-dialog-title-${nextId++}`; @HostBinding("id") id = `bit-dialog-title-${nextId++}`;
@Input() simple = false; readonly simple = input(false);
constructor(@Optional() private dialogRef: DialogRef<any>) {} constructor(@Optional() private dialogRef: DialogRef<any>) {}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Directive, HostBinding, HostListener, Input } from "@angular/core"; import { Directive, HostBinding, HostListener, input } from "@angular/core";
import { DisclosureComponent } from "./disclosure.component"; import { DisclosureComponent } from "./disclosure.component";
@@ -12,17 +12,17 @@ export class DisclosureTriggerForDirective {
/** /**
* Accepts template reference for a bit-disclosure component instance * Accepts template reference for a bit-disclosure component instance
*/ */
@Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent; readonly disclosure = input<DisclosureComponent>(undefined, { alias: "bitDisclosureTriggerFor" });
@HostBinding("attr.aria-expanded") get ariaExpanded() { @HostBinding("attr.aria-expanded") get ariaExpanded() {
return this.disclosure.open; return this.disclosure().open;
} }
@HostBinding("attr.aria-controls") get ariaControls() { @HostBinding("attr.aria-controls") get ariaControls() {
return this.disclosure.id; return this.disclosure().id;
} }
@HostListener("click") click() { @HostListener("click") click() {
this.disclosure.open = !this.disclosure.open; this.disclosure().open = !this.disclosure().open;
} }
} }

View File

@@ -48,6 +48,8 @@ export class DisclosureComponent {
/** /**
* Optionally init the disclosure in its opened state * Optionally init the disclosure in its opened state
*/ */
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input({ transform: booleanAttribute }) set open(isOpen: boolean) { @Input({ transform: booleanAttribute }) set open(isOpen: boolean) {
this._open = isOpen; this._open = isOpen;
this.openChange.emit(isOpen); this.openChange.emit(isOpen);

View File

@@ -1,8 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass } from "@angular/common"; import { NgClass } from "@angular/common";
import { Component, ContentChild, HostBinding, Input } from "@angular/core"; import { booleanAttribute, Component, ContentChild, HostBinding, input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -17,30 +16,18 @@ import { BitFormControlAbstraction } from "./form-control.abstraction";
imports: [NgClass, TypographyDirective, I18nPipe], imports: [NgClass, TypographyDirective, I18nPipe],
}) })
export class FormControlComponent { export class FormControlComponent {
@Input() label: string; readonly label = input<string>();
private _inline = false; readonly inline = input(false, { transform: booleanAttribute });
@Input() get inline() {
return this._inline;
}
set inline(value: boolean | "") {
this._inline = coerceBooleanProperty(value);
}
private _disableMargin = false; readonly disableMargin = input(false, { transform: booleanAttribute });
@Input() set disableMargin(value: boolean | "") {
this._disableMargin = coerceBooleanProperty(value);
}
get disableMargin() {
return this._disableMargin;
}
@ContentChild(BitFormControlAbstraction) protected formControl: BitFormControlAbstraction; @ContentChild(BitFormControlAbstraction) protected formControl: BitFormControlAbstraction;
@HostBinding("class") get classes() { @HostBinding("class") get classes() {
return [] return []
.concat(this.inline ? ["tw-inline-block", "tw-me-4"] : ["tw-block"]) .concat(this.inline() ? ["tw-inline-block", "tw-me-4"] : ["tw-block"])
.concat(this.disableMargin ? [] : ["tw-mb-4"]); .concat(this.disableMargin() ? [] : ["tw-mb-4"]);
} }
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService) {}

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, ElementRef, HostBinding, Input, Optional } from "@angular/core"; import { Component, ElementRef, HostBinding, input, Optional } from "@angular/core";
import { FormControlComponent } from "./form-control.component"; import { FormControlComponent } from "./form-control.component";
@@ -12,6 +12,10 @@ let nextId = 0;
selector: "bit-label", selector: "bit-label",
templateUrl: "label.component.html", templateUrl: "label.component.html",
imports: [CommonModule], imports: [CommonModule],
host: {
"[class]": "classList",
"[id]": "id()",
},
}) })
export class BitLabel { export class BitLabel {
constructor( constructor(
@@ -19,15 +23,19 @@ export class BitLabel {
@Optional() private parentFormControl: FormControlComponent, @Optional() private parentFormControl: FormControlComponent,
) {} ) {}
@HostBinding("class") @Input() get classList() { readonly classList = [
return ["tw-inline-flex", "tw-gap-1", "tw-items-baseline", "tw-flex-row", "tw-min-w-0"]; "tw-inline-flex",
} "tw-gap-1",
"tw-items-baseline",
"tw-flex-row",
"tw-min-w-0",
];
@HostBinding("title") get title() { @HostBinding("title") get title() {
return this.elementRef.nativeElement.textContent.trim(); return this.elementRef.nativeElement.textContent.trim();
} }
@HostBinding() @Input() id = `bit-label-${nextId++}`; readonly id = input(`bit-label-${nextId++}`);
get isInsideFormControl() { get isInsideFormControl() {
return !!this.parentFormControl; return !!this.parentFormControl;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, Input } from "@angular/core"; import { Component, input } from "@angular/core";
import { AbstractControl, UntypedFormGroup } from "@angular/forms"; import { AbstractControl, UntypedFormGroup } from "@angular/forms";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -18,11 +18,10 @@ import { I18nPipe } from "@bitwarden/ui-common";
imports: [I18nPipe], imports: [I18nPipe],
}) })
export class BitErrorSummary { export class BitErrorSummary {
@Input() readonly formGroup = input<UntypedFormGroup>();
formGroup: UntypedFormGroup;
get errorCount(): number { get errorCount(): number {
return this.getErrorCount(this.formGroup); return this.getErrorCount(this.formGroup());
} }
get errorString() { get errorString() {

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, HostBinding, Input } from "@angular/core"; import { Component, HostBinding, input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -18,37 +18,38 @@ let nextId = 0;
export class BitErrorComponent { export class BitErrorComponent {
@HostBinding() id = `bit-error-${nextId++}`; @HostBinding() id = `bit-error-${nextId++}`;
@Input() error: [string, any]; readonly error = input<[string, any]>();
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService) {}
get displayError() { get displayError() {
switch (this.error[0]) { const error = this.error();
switch (error[0]) {
case "required": case "required":
return this.i18nService.t("inputRequired"); return this.i18nService.t("inputRequired");
case "email": case "email":
return this.i18nService.t("inputEmail"); return this.i18nService.t("inputEmail");
case "minlength": case "minlength":
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength); return this.i18nService.t("inputMinLength", error[1]?.requiredLength);
case "maxlength": case "maxlength":
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength); return this.i18nService.t("inputMaxLength", error[1]?.requiredLength);
case "min": case "min":
return this.i18nService.t("inputMinValue", this.error[1]?.min); return this.i18nService.t("inputMinValue", error[1]?.min);
case "max": case "max":
return this.i18nService.t("inputMaxValue", this.error[1]?.max); return this.i18nService.t("inputMaxValue", error[1]?.max);
case "forbiddenCharacters": case "forbiddenCharacters":
return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", ")); return this.i18nService.t("inputForbiddenCharacters", error[1]?.characters.join(", "));
case "multipleEmails": case "multipleEmails":
return this.i18nService.t("multipleInputEmails"); return this.i18nService.t("multipleInputEmails");
case "trim": case "trim":
return this.i18nService.t("inputTrimValidator"); return this.i18nService.t("inputTrimValidator");
default: default:
// Attempt to show a custom error message. // Attempt to show a custom error message.
if (this.error[1]?.message) { if (error[1]?.message) {
return this.error[1]?.message; return error[1]?.message;
} }
return this.error; return error;
} }
} }
} }

View File

@@ -1,4 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
import { ModelSignal, Signal } from "@angular/core";
// @ts-strict-ignore // @ts-strict-ignore
export type InputTypes = export type InputTypes =
| "text" | "text"
@@ -14,13 +17,13 @@ export type InputTypes =
export abstract class BitFormFieldControl { export abstract class BitFormFieldControl {
ariaDescribedBy: string; ariaDescribedBy: string;
id: string; id: Signal<string>;
labelForId: string; labelForId: string;
required: boolean; required: boolean;
hasError: boolean; hasError: boolean;
error: [string, any]; error: [string, any];
type?: InputTypes; type?: ModelSignal<InputTypes>;
spellcheck?: boolean; spellcheck?: ModelSignal<boolean | undefined>;
readOnly?: boolean; readOnly?: boolean;
focus?: () => void; focus?: () => void;
} }

View File

@@ -9,9 +9,10 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
HostListener, HostListener,
Input,
ViewChild, ViewChild,
signal, signal,
input,
Input,
} from "@angular/core"; } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -38,10 +39,11 @@ export class BitFormFieldComponent implements AfterContentChecked {
@ViewChild(BitErrorComponent) error: BitErrorComponent; @ViewChild(BitErrorComponent) error: BitErrorComponent;
@Input({ transform: booleanAttribute }) readonly disableMargin = input(false, { transform: booleanAttribute });
disableMargin = false;
/** If `true`, remove the bottom border for `readonly` inputs */ /** If `true`, remove the bottom border for `readonly` inputs */
// TODO: Skipped for signal migration because:
// Your application code writes to the input. This prevents migration.
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
disableReadOnlyBorder = false; disableReadOnlyBorder = false;
@@ -76,7 +78,7 @@ export class BitFormFieldComponent implements AfterContentChecked {
@HostBinding("class") @HostBinding("class")
get classList() { get classList() {
return ["tw-block"] return ["tw-block"]
.concat(this.disableMargin ? [] : ["tw-mb-4", "bit-compact:tw-mb-3"]) .concat(this.disableMargin() ? [] : ["tw-mb-4", "bit-compact:tw-mb-3"])
.concat(this.readOnly ? [] : "tw-pt-2"); .concat(this.readOnly ? [] : "tw-pt-2");
} }

View File

@@ -414,13 +414,18 @@ export const Select: Story = {
export const AdvancedSelect: Story = { export const AdvancedSelect: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: {
formObj: fb.group({
select: "value1",
}),
...args,
},
template: /*html*/ ` template: /*html*/ `
<bit-form-field> <bit-form-field [formGroup]="formObj">
<bit-label>Label</bit-label> <bit-label>Label</bit-label>
<bit-select> <bit-select formControlName="select">
<bit-option label="Select"></bit-option> <bit-option label="Select" value="value1"></bit-option>
<bit-option label="Other"></bit-option> <bit-option label="Other" value="value2"></bit-option>
</bit-select> </bit-select>
</bit-form-field> </bit-form-field>
`, `,

View File

@@ -5,7 +5,7 @@ import {
Host, Host,
HostBinding, HostBinding,
HostListener, HostListener,
Input, model,
OnChanges, OnChanges,
Output, Output,
} from "@angular/core"; } from "@angular/core";
@@ -18,12 +18,15 @@ import { BitFormFieldComponent } from "./form-field.component";
@Directive({ @Directive({
selector: "[bitPasswordInputToggle]", selector: "[bitPasswordInputToggle]",
host: {
"[attr.aria-pressed]": "toggled()",
},
}) })
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges { export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
/** /**
* Whether the input is toggled to show the password. * Whether the input is toggled to show the password.
*/ */
@HostBinding("attr.aria-pressed") @Input() toggled = false; readonly toggled = model(false);
@Output() toggledChange = new EventEmitter<boolean>(); @Output() toggledChange = new EventEmitter<boolean>();
@HostBinding("attr.title") title = this.i18nService.t("toggleVisibility"); @HostBinding("attr.title") title = this.i18nService.t("toggleVisibility");
@@ -33,8 +36,8 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
* Click handler to toggle the state of the input type. * Click handler to toggle the state of the input type.
*/ */
@HostListener("click") onClick() { @HostListener("click") onClick() {
this.toggled = !this.toggled; this.toggled.update((toggled) => !toggled);
this.toggledChange.emit(this.toggled); this.toggledChange.emit(this.toggled());
this.update(); this.update();
} }
@@ -46,7 +49,7 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
) {} ) {}
get icon() { get icon() {
return this.toggled ? "bwi-eye-slash" : "bwi-eye"; return this.toggled() ? "bwi-eye-slash" : "bwi-eye";
} }
ngOnChanges(): void { ngOnChanges(): void {
@@ -55,16 +58,16 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
ngAfterContentInit(): void { ngAfterContentInit(): void {
if (this.formField.input?.type) { if (this.formField.input?.type) {
this.toggled = this.formField.input.type !== "password"; this.toggled.set(this.formField.input.type() !== "password");
} }
this.button.icon = this.icon; this.button.icon.set(this.icon);
} }
private update() { private update() {
this.button.icon = this.icon; this.button.icon.set(this.icon);
if (this.formField.input?.type != null) { if (this.formField.input?.type != null) {
this.formField.input.type = this.toggled ? "text" : "password"; this.formField.input.type.set(this.toggled() ? "text" : "password");
this.formField.input.spellcheck = this.toggled ? false : undefined; this.formField?.input?.spellcheck?.set(this.toggled() ? false : undefined);
} }
} }
} }

View File

@@ -60,15 +60,15 @@ describe("PasswordInputToggle", () => {
describe("initial state", () => { describe("initial state", () => {
it("has correct icon", () => { it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye"); expect(button.icon()).toBe("bwi-eye");
}); });
it("input is type password", () => { it("input is type password", () => {
expect(input.type).toBe("password"); expect(input.type!()).toBe("password");
}); });
it("spellcheck is disabled", () => { it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(undefined); expect(input.spellcheck!()).toBe(undefined);
}); });
}); });
@@ -78,15 +78,15 @@ describe("PasswordInputToggle", () => {
}); });
it("has correct icon", () => { it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye-slash"); expect(button.icon()).toBe("bwi-eye-slash");
}); });
it("input is type text", () => { it("input is type text", () => {
expect(input.type).toBe("text"); expect(input.type!()).toBe("text");
}); });
it("spellcheck is disabled", () => { it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(false); expect(input.spellcheck!()).toBe(false);
}); });
}); });
@@ -97,15 +97,15 @@ describe("PasswordInputToggle", () => {
}); });
it("has correct icon", () => { it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye"); expect(button.icon()).toBe("bwi-eye");
}); });
it("input is type password", () => { it("input is type password", () => {
expect(input.type).toBe("password"); expect(input.type!()).toBe("password");
}); });
it("spellcheck is disabled", () => { it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(undefined); expect(input.spellcheck!()).toBe(undefined);
}); });
}); });
}); });

View File

@@ -1,20 +1,21 @@
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core"; import { Directive, OnInit, Optional } from "@angular/core";
import { BitIconButtonComponent } from "../icon-button/icon-button.component"; import { BitIconButtonComponent } from "../icon-button/icon-button.component";
@Directive({ @Directive({
selector: "[bitPrefix]", selector: "[bitPrefix]",
host: {
"[class]": "classList",
},
}) })
export class BitPrefixDirective implements OnInit { export class BitPrefixDirective implements OnInit {
@HostBinding("class") @Input() get classList() { readonly classList = ["tw-text-muted"];
return ["tw-text-muted"];
}
constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {} constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {}
ngOnInit() { ngOnInit() {
if (this.iconButtonComponent) { if (this.iconButtonComponent) {
this.iconButtonComponent.size = "small"; this.iconButtonComponent.size.set("small");
} }
} }
} }

View File

@@ -1,20 +1,21 @@
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core"; import { Directive, OnInit, Optional } from "@angular/core";
import { BitIconButtonComponent } from "../icon-button/icon-button.component"; import { BitIconButtonComponent } from "../icon-button/icon-button.component";
@Directive({ @Directive({
selector: "[bitSuffix]", selector: "[bitSuffix]",
host: {
"[class]": "classList",
},
}) })
export class BitSuffixDirective implements OnInit { export class BitSuffixDirective implements OnInit {
@HostBinding("class") @Input() get classList() { readonly classList = ["tw-text-muted"];
return ["tw-text-muted"];
}
constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {} constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {}
ngOnInit() { ngOnInit() {
if (this.iconButtonComponent) { if (this.iconButtonComponent) {
this.iconButtonComponent.size = "small"; this.iconButtonComponent.size.set("small");
} }
} }
} }

View File

@@ -9,7 +9,7 @@
<i <i
class="bwi bwi-spinner bwi-spin" class="bwi bwi-spinner bwi-spin"
aria-hidden="true" aria-hidden="true"
[ngClass]="{ 'bwi-lg': size === 'default' }" [ngClass]="{ 'bwi-lg': size() === 'default' }"
></i> ></i>
</span> </span>
</span> </span>

View File

@@ -7,7 +7,7 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
inject, inject,
Input, input,
model, model,
Signal, Signal,
} from "@angular/core"; } from "@angular/core";
@@ -177,11 +177,11 @@ const sizes: Record<IconButtonSize, string[]> = {
}, },
}) })
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
@Input("bitIconButton") icon: string; readonly icon = model<string>(undefined, { alias: "bitIconButton" });
@Input() buttonType: IconButtonType = "main"; readonly buttonType = input<IconButtonType>("main");
@Input() size: IconButtonSize = "default"; readonly size = model<IconButtonSize>("default");
@HostBinding("class") get classList() { @HostBinding("class") get classList() {
return [ return [
@@ -193,13 +193,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
"hover:tw-no-underline", "hover:tw-no-underline",
"focus:tw-outline-none", "focus:tw-outline-none",
] ]
.concat(styles[this.buttonType]) .concat(styles[this.buttonType()])
.concat(sizes[this.size]) .concat(sizes[this.size()])
.concat(this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType] : []); .concat(
this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType()] : [],
);
} }
get iconClass() { get iconClass() {
return [this.icon, "!tw-m-0"]; return [this.icon(), "!tw-m-0"];
} }
protected disabledAttr = computed(() => { protected disabledAttr = computed(() => {
@@ -219,7 +221,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false); return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
}); });
loading = model(false); readonly loading = model(false);
/** /**
* Determine whether it is appropriate to display a loading spinner. We only want to show * Determine whether it is appropriate to display a loading spinner. We only want to show
@@ -237,7 +239,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))), toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))),
); );
disabled = model<boolean>(false); readonly disabled = model<boolean>(false);
getFocusTarget() { getFocusTarget() {
return this.elementRef.nativeElement; return this.elementRef.nativeElement;

View File

@@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core"; import { Component, effect, input } from "@angular/core";
import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
import { Icon, isIcon } from "./icon"; import { Icon, isIcon } from "./icon";
@@ -6,8 +6,8 @@ import { Icon, isIcon } from "./icon";
@Component({ @Component({
selector: "bit-icon", selector: "bit-icon",
host: { host: {
"[attr.aria-hidden]": "!ariaLabel", "[attr.aria-hidden]": "!ariaLabel()",
"[attr.aria-label]": "ariaLabel", "[attr.aria-label]": "ariaLabel()",
"[innerHtml]": "innerHtml", "[innerHtml]": "innerHtml",
}, },
template: ``, template: ``,
@@ -15,16 +15,18 @@ import { Icon, isIcon } from "./icon";
export class BitIconComponent { export class BitIconComponent {
innerHtml: SafeHtml | null = null; innerHtml: SafeHtml | null = null;
@Input() set icon(icon: Icon) { readonly icon = input<Icon>();
if (!isIcon(icon)) {
return;
}
const svg = icon.svg; readonly ariaLabel = input<string>();
this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg);
constructor(private domSanitizer: DomSanitizer) {
effect(() => {
const icon = this.icon();
if (!isIcon(icon)) {
return;
}
const svg = icon.svg;
this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg);
});
} }
@Input() ariaLabel: string | undefined = undefined;
constructor(private domSanitizer: DomSanitizer) {}
} }

View File

@@ -4,7 +4,6 @@ import { Icon, svgIcon } from "./icon";
import { BitIconComponent } from "./icon.component"; import { BitIconComponent } from "./icon.component";
describe("IconComponent", () => { describe("IconComponent", () => {
let component: BitIconComponent;
let fixture: ComponentFixture<BitIconComponent>; let fixture: ComponentFixture<BitIconComponent>;
beforeEach(async () => { beforeEach(async () => {
@@ -13,14 +12,13 @@ describe("IconComponent", () => {
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(BitIconComponent); fixture = TestBed.createComponent(BitIconComponent);
component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should have empty innerHtml when input is not an Icon", () => { it("should have empty innerHtml when input is not an Icon", () => {
const fakeIcon = { svg: "harmful user input" } as Icon; const fakeIcon = { svg: "harmful user input" } as Icon;
component.icon = fakeIcon; fixture.componentRef.setInput("icon", fakeIcon);
fixture.detectChanges(); fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement; const el = fixture.nativeElement as HTMLElement;
@@ -30,7 +28,7 @@ describe("IconComponent", () => {
it("should contain icon when input is a safe Icon", () => { it("should contain icon when input is a safe Icon", () => {
const icon = svgIcon`<svg><text x="0" y="15">safe icon</text></svg>`; const icon = svgIcon`<svg><text x="0" y="15">safe icon</text></svg>`;
component.icon = icon; fixture.componentRef.setInput("icon", icon);
fixture.detectChanges(); fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement; const el = fixture.nativeElement as HTMLElement;

View File

@@ -1,6 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { AfterContentChecked, Directive, ElementRef, Input, NgZone, Optional } from "@angular/core"; import {
AfterContentChecked,
booleanAttribute,
Directive,
ElementRef,
input,
NgZone,
Optional,
} from "@angular/core";
import { take } from "rxjs/operators"; import { take } from "rxjs/operators";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -21,11 +29,7 @@ import { FocusableElement } from "../shared/focusable-element";
selector: "[appAutofocus], [bitAutofocus]", selector: "[appAutofocus], [bitAutofocus]",
}) })
export class AutofocusDirective implements AfterContentChecked { export class AutofocusDirective implements AfterContentChecked {
@Input() set appAutofocus(condition: boolean | string) { readonly appAutofocus = input(undefined, { transform: booleanAttribute });
this.autofocus = condition === "" || condition === true;
}
private autofocus: boolean;
// Track if we have already focused the element. // Track if we have already focused the element.
private focused = false; private focused = false;
@@ -46,7 +50,7 @@ export class AutofocusDirective implements AfterContentChecked {
*/ */
ngAfterContentChecked() { ngAfterContentChecked() {
// We only want to focus the element on initial render and it's not a mobile browser // We only want to focus the element on initial render and it's not a mobile browser
if (this.focused || !this.autofocus || Utils.isMobileBrowser) { if (this.focused || !this.appAutofocus() || Utils.isMobileBrowser) {
return; return;
} }

View File

@@ -0,0 +1,21 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./autofocus.stories";
<Meta of={stories} />
```ts
import { AutofocusDirective } from "@bitwarden/components";
```
<Title />
<Description />
<Primary />
<Controls />
## Accessibility
The autofocus directive has accessibility implications, because it will steal focus from wherever it
would naturally be placed on page load. Please consider whether or not the user truly needs the
element truly needs to be manually focused on their behalf.

View File

@@ -0,0 +1,26 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { FormFieldModule } from "../form-field";
import { AutofocusDirective } from "./autofocus.directive";
export default {
title: "Component Library/Form/Autofocus Directive",
component: AutofocusDirective,
decorators: [
moduleMetadata({
imports: [AutofocusDirective, FormFieldModule],
}),
],
} as Meta;
export const AutofocusField: StoryObj = {
render: (args) => ({
template: /*html*/ `
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" appAutofocus />
</bit-form-field>
`,
}),
};

View File

@@ -9,6 +9,8 @@ import {
NgZone, NgZone,
Optional, Optional,
Self, Self,
input,
model,
} from "@angular/core"; } from "@angular/core";
import { NgControl, Validators } from "@angular/forms"; import { NgControl, Validators } from "@angular/forms";
@@ -30,9 +32,15 @@ export function inputBorderClasses(error: boolean) {
@Directive({ @Directive({
selector: "input[bitInput], select[bitInput], textarea[bitInput]", selector: "input[bitInput], select[bitInput], textarea[bitInput]",
providers: [{ provide: BitFormFieldControl, useExisting: BitInputDirective }], providers: [{ provide: BitFormFieldControl, useExisting: BitInputDirective }],
host: {
"[class]": "classList()",
"[id]": "id()",
"[attr.type]": "type()",
"[attr.spellcheck]": "spellcheck()",
},
}) })
export class BitInputDirective implements BitFormFieldControl { export class BitInputDirective implements BitFormFieldControl {
@HostBinding("class") @Input() get classList() { classList() {
const classes = [ const classes = [
"tw-block", "tw-block",
"tw-w-full", "tw-w-full",
@@ -52,7 +60,7 @@ export class BitInputDirective implements BitFormFieldControl {
return classes.filter((s) => s != ""); return classes.filter((s) => s != "");
} }
@HostBinding() @Input() id = `bit-input-${nextId++}`; readonly id = input(`bit-input-${nextId++}`);
@HostBinding("attr.aria-describedby") ariaDescribedBy: string; @HostBinding("attr.aria-describedby") ariaDescribedBy: string;
@@ -60,10 +68,12 @@ export class BitInputDirective implements BitFormFieldControl {
return this.hasError ? true : undefined; return this.hasError ? true : undefined;
} }
@HostBinding("attr.type") @Input() type?: InputTypes; readonly type = model<InputTypes>();
@HostBinding("attr.spellcheck") @Input() spellcheck?: boolean; readonly spellcheck = model<boolean>();
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@HostBinding() @HostBinding()
@Input() @Input()
get required() { get required() {
@@ -74,13 +84,13 @@ export class BitInputDirective implements BitFormFieldControl {
} }
private _required: boolean; private _required: boolean;
@Input() hasPrefix = false; readonly hasPrefix = input(false);
@Input() hasSuffix = false; readonly hasSuffix = input(false);
@Input() showErrorsWhenDisabled? = false; readonly showErrorsWhenDisabled = input<boolean>(false);
get labelForId(): string { get labelForId(): string {
return this.id; return this.id();
} }
@HostListener("input") @HostListener("input")
@@ -89,7 +99,7 @@ export class BitInputDirective implements BitFormFieldControl {
} }
get hasError() { get hasError() {
if (this.showErrorsWhenDisabled) { if (this.showErrorsWhenDisabled()) {
return ( return (
(this.ngControl?.status === "INVALID" || this.ngControl?.status === "DISABLED") && (this.ngControl?.status === "INVALID" || this.ngControl?.status === "DISABLED") &&
this.ngControl?.touched && this.ngControl?.touched &&

View File

@@ -8,8 +8,8 @@
> >
<div <div
[ngClass]="{ [ngClass]="{
'tw-truncate': truncate, 'tw-truncate': truncate(),
'tw-text-wrap tw-overflow-auto tw-break-words': !truncate, 'tw-text-wrap tw-overflow-auto tw-break-words': !truncate(),
}" }"
> >
<ng-content></ng-content> <ng-content></ng-content>
@@ -22,8 +22,8 @@
bitTypography="helper" bitTypography="helper"
class="tw-text-muted tw-w-full" class="tw-text-muted tw-w-full"
[ngClass]="{ [ngClass]="{
'tw-truncate': truncate, 'tw-truncate': truncate(),
'tw-text-wrap tw-overflow-auto tw-break-words': !truncate, 'tw-text-wrap tw-overflow-auto tw-break-words': !truncate(),
}" }"
> >
<ng-content select="[slot=secondary]"></ng-content> <ng-content select="[slot=secondary]"></ng-content>

View File

@@ -7,9 +7,9 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
ElementRef, ElementRef,
Input,
signal, signal,
ViewChild, ViewChild,
input,
} from "@angular/core"; } from "@angular/core";
import { TypographyModule } from "../typography"; import { TypographyModule } from "../typography";
@@ -39,7 +39,7 @@ export class ItemContentComponent implements AfterContentChecked {
* *
* Default behavior is truncation. * Default behavior is truncation.
*/ */
@Input() truncate = true; readonly truncate = input(true);
ngAfterContentChecked(): void { ngAfterContentChecked(): void {
this.endSlotHasChildren.set(this.endSlot?.nativeElement.childElementCount > 0); this.endSlotHasChildren.set(this.endSlot?.nativeElement.childElementCount > 0);

View File

@@ -1,12 +1,4 @@
import { import { HostBinding, Directive, inject, ElementRef, input, booleanAttribute } from "@angular/core";
Input,
HostBinding,
Directive,
inject,
ElementRef,
input,
booleanAttribute,
} from "@angular/core";
import { ariaDisableElement } from "../utils"; import { ariaDisableElement } from "../utils";
@@ -77,8 +69,7 @@ const commonStyles = [
@Directive() @Directive()
abstract class LinkDirective { abstract class LinkDirective {
@Input() readonly linkType = input<LinkType>("primary");
linkType: LinkType = "primary";
} }
/** /**
@@ -96,7 +87,7 @@ export class AnchorLinkDirective extends LinkDirective {
@HostBinding("class") get classList() { @HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.125rem]"] return ["before:-tw-inset-y-[0.125rem]"]
.concat(commonStyles) .concat(commonStyles)
.concat(linkStyles[this.linkType] ?? []); .concat(linkStyles[this.linkType()] ?? []);
} }
} }
@@ -111,7 +102,7 @@ export class ButtonLinkDirective extends LinkDirective {
@HostBinding("class") get classList() { @HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.25rem]"] return ["before:-tw-inset-y-[0.25rem]"]
.concat(commonStyles) .concat(commonStyles)
.concat(linkStyles[this.linkType] ?? []); .concat(linkStyles[this.linkType()] ?? []);
} }
constructor() { constructor() {

View File

@@ -39,6 +39,9 @@ export class MenuItemDirective implements FocusableOption {
return this.disabled || null; // native disabled attr must be null when false return this.disabled || null; // native disabled attr must be null when false
} }
// TODO: Skipped for signal migration because:
// This input overrides a field from a superclass, while the superclass field
// is not migrated.
@Input({ transform: coerceBooleanProperty }) disabled?: boolean = false; @Input({ transform: coerceBooleanProperty }) disabled?: boolean = false;
constructor(public elementRef: ElementRef<HTMLButtonElement>) {} constructor(public elementRef: ElementRef<HTMLButtonElement>) {}

View File

@@ -8,26 +8,30 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
HostListener, HostListener,
Input,
OnDestroy, OnDestroy,
ViewContainerRef, ViewContainerRef,
input,
} from "@angular/core"; } from "@angular/core";
import { Observable, Subscription } from "rxjs"; import { Observable, Subscription } from "rxjs";
import { filter, mergeWith } from "rxjs/operators"; import { filter, mergeWith } from "rxjs/operators";
import { MenuComponent } from "./menu.component"; import { MenuComponent } from "./menu.component";
@Directive({ selector: "[bitMenuTriggerFor]", exportAs: "menuTrigger", standalone: true }) @Directive({
selector: "[bitMenuTriggerFor]",
exportAs: "menuTrigger",
standalone: true,
host: { "[attr.role]": "this.role()" },
})
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") get hasPopup(): "menu" | "dialog" { @HostBinding("attr.aria-haspopup") get hasPopup(): "menu" | "dialog" {
return this.menu?.ariaRole || "menu"; return this.menu()?.ariaRole() || "menu";
} }
@HostBinding("attr.role")
@Input()
role = "button";
@Input("bitMenuTriggerFor") menu: MenuComponent; readonly role = input("button");
readonly menu = input<MenuComponent>(undefined, { alias: "bitMenuTriggerFor" });
private overlayRef: OverlayRef; private overlayRef: OverlayRef;
private defaultMenuConfig: OverlayConfig = { private defaultMenuConfig: OverlayConfig = {
@@ -66,14 +70,15 @@ export class MenuTriggerForDirective implements OnDestroy {
} }
private openMenu() { private openMenu() {
if (this.menu == null) { const menu = this.menu();
if (menu == null) {
throw new Error("Cannot find bit-menu element"); throw new Error("Cannot find bit-menu element");
} }
this.isOpen = true; this.isOpen = true;
this.overlayRef = this.overlay.create(this.defaultMenuConfig); this.overlayRef = this.overlay.create(this.defaultMenuConfig);
const templatePortal = new TemplatePortal(this.menu.templateRef, this.viewContainerRef); const templatePortal = new TemplatePortal(menu.templateRef, this.viewContainerRef);
this.overlayRef.attach(templatePortal); this.overlayRef.attach(templatePortal);
this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => { this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => {
@@ -90,11 +95,11 @@ export class MenuTriggerForDirective implements OnDestroy {
} }
this.destroyMenu(); this.destroyMenu();
}); });
if (this.menu.keyManager) { if (menu.keyManager) {
this.menu.keyManager.setFirstItemActive(); menu.keyManager.setFirstItemActive();
this.keyDownEventsSub = this.overlayRef this.keyDownEventsSub = this.overlayRef
.keydownEvents() .keydownEvents()
.subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event)); .subscribe((event: KeyboardEvent) => this.menu().keyManager.onKeydown(event));
} }
} }
@@ -105,19 +110,19 @@ export class MenuTriggerForDirective implements OnDestroy {
this.isOpen = false; this.isOpen = false;
this.disposeAll(); this.disposeAll();
this.menu.closed.emit(); this.menu().closed.emit();
} }
private getClosedEvents(): Observable<any> { private getClosedEvents(): Observable<any> {
const detachments = this.overlayRef.detachments(); const detachments = this.overlayRef.detachments();
const escKey = this.overlayRef.keydownEvents().pipe( const escKey = this.overlayRef.keydownEvents().pipe(
filter((event: KeyboardEvent) => { filter((event: KeyboardEvent) => {
const keys = this.menu.ariaRole === "menu" ? ["Escape", "Tab"] : ["Escape"]; const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
return keys.includes(event.key); 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;
return detachments.pipe(mergeWith(escKey, backdrop, menuClosed)); return detachments.pipe(mergeWith(escKey, backdrop, menuClosed));
} }

View File

@@ -2,10 +2,10 @@
<div <div
(click)="closed.emit()" (click)="closed.emit()"
class="tw-flex tw-shrink-0 tw-flex-col tw-rounded-lg tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-1 tw-overflow-y-auto" class="tw-flex tw-shrink-0 tw-flex-col tw-rounded-lg tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-1 tw-overflow-y-auto"
[attr.role]="ariaRole" [attr.role]="ariaRole()"
[attr.aria-label]="ariaLabel" [attr.aria-label]="ariaLabel()"
cdkTrapFocus cdkTrapFocus
[cdkTrapFocusAutoCapture]="ariaRole === 'dialog'" [cdkTrapFocusAutoCapture]="ariaRole() === 'dialog'"
> >
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

View File

@@ -10,7 +10,7 @@ import {
ContentChildren, ContentChildren,
QueryList, QueryList,
AfterContentInit, AfterContentInit,
Input, input,
} from "@angular/core"; } from "@angular/core";
import { MenuItemDirective } from "./menu-item.directive"; import { MenuItemDirective } from "./menu-item.directive";
@@ -28,12 +28,12 @@ export class MenuComponent implements AfterContentInit {
menuItems: QueryList<MenuItemDirective>; menuItems: QueryList<MenuItemDirective>;
keyManager?: FocusKeyManager<MenuItemDirective>; keyManager?: FocusKeyManager<MenuItemDirective>;
@Input() ariaRole: "menu" | "dialog" = "menu"; readonly ariaRole = input<"menu" | "dialog">("menu");
@Input() ariaLabel: string; readonly ariaLabel = input<string>();
ngAfterContentInit() { ngAfterContentInit() {
if (this.ariaRole === "menu") { if (this.ariaRole() === "menu") {
this.keyManager = new FocusKeyManager(this.menuItems) this.keyManager = new FocusKeyManager(this.menuItems)
.withWrap() .withWrap()
.skipPredicate((item) => item.disabled); .skipPredicate((item) => item.disabled);

View File

@@ -1,12 +1,12 @@
<ng-select <ng-select
[items]="baseItems" [items]="baseItems()"
[(ngModel)]="selectedItems" [(ngModel)]="selectedItems"
(ngModelChange)="onChange($event)" (ngModelChange)="onChange($event)"
(blur)="onBlur()" (blur)="onBlur()"
bindLabel="listName" bindLabel="listName"
groupBy="parentGrouping" groupBy="parentGrouping"
[placeholder]="placeholder" [placeholder]="placeholder()"
[loading]="loading" [loading]="loading()"
[loadingText]="loadingText" [loadingText]="loadingText"
notFoundText="{{ 'multiSelectNotFound' | i18n }}" notFoundText="{{ 'multiSelectNotFound' | i18n }}"
clearAllText="{{ 'multiSelectClearAll' | i18n }}" clearAllText="{{ 'multiSelectClearAll' | i18n }}"

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { hasModifierKey } from "@angular/cdk/keycodes"; import { hasModifierKey } from "@angular/cdk/keycodes";
import { import {
Component, Component,
@@ -12,6 +11,9 @@ import {
HostBinding, HostBinding,
Optional, Optional,
Self, Self,
input,
model,
booleanAttribute,
} from "@angular/core"; } from "@angular/core";
import { import {
ControlValueAccessor, ControlValueAccessor,
@@ -38,6 +40,9 @@ let nextId = 0;
templateUrl: "./multi-select.component.html", templateUrl: "./multi-select.component.html",
providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }], providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }],
imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, I18nPipe], imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, I18nPipe],
host: {
"[id]": "this.id()",
},
}) })
/** /**
* This component has been implemented to only support Multi-select list events * This component has been implemented to only support Multi-select list events
@@ -46,12 +51,14 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
@ViewChild(NgSelectComponent) select: NgSelectComponent; @ViewChild(NgSelectComponent) select: NgSelectComponent;
// Parent component should only pass selectable items (complete list - selected items = baseItems) // Parent component should only pass selectable items (complete list - selected items = baseItems)
@Input() baseItems: SelectItemView[]; readonly baseItems = model<SelectItemView[]>();
// Defaults to native ng-select behavior - set to "true" to clear selected items on dropdown close // Defaults to native ng-select behavior - set to "true" to clear selected items on dropdown close
@Input() removeSelectedItems = false; readonly removeSelectedItems = input(false);
@Input() placeholder: string; readonly placeholder = model<string>();
@Input() loading = false; readonly loading = input(false);
@Input({ transform: coerceBooleanProperty }) disabled?: boolean; // TODO: Skipped for signal migration because:
// Your application code writes to the input. This prevents migration.
@Input({ transform: booleanAttribute }) disabled?: boolean;
// Internal tracking of selected items // Internal tracking of selected items
protected selectedItems: SelectItemView[]; protected selectedItems: SelectItemView[];
@@ -79,7 +86,9 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
ngOnInit(): void { ngOnInit(): void {
// Default Text Values // Default Text Values
this.placeholder = this.placeholder ?? this.i18nService.t("multiSelectPlaceholder"); this.placeholder.update(
(placeholder) => placeholder ?? this.i18nService.t("multiSelectPlaceholder"),
);
this.loadingText = this.i18nService.t("multiSelectLoading"); this.loadingText = this.i18nService.t("multiSelectLoading");
} }
@@ -119,15 +128,15 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
this.onItemsConfirmed.emit(this.selectedItems); this.onItemsConfirmed.emit(this.selectedItems);
// Remove selected items from base list based on input property // Remove selected items from base list based on input property
if (this.removeSelectedItems) { if (this.removeSelectedItems()) {
let updatedBaseItems = this.baseItems; let updatedBaseItems = this.baseItems();
this.selectedItems.forEach((selectedItem) => { this.selectedItems.forEach((selectedItem) => {
updatedBaseItems = updatedBaseItems.filter((item) => selectedItem.id !== item.id); updatedBaseItems = updatedBaseItems.filter((item) => selectedItem.id !== item.id);
}); });
// Reset Lists // Reset Lists
this.selectedItems = null; this.selectedItems = null;
this.baseItems = updatedBaseItems; this.baseItems.set(updatedBaseItems);
} }
} }
@@ -186,9 +195,11 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
} }
/**Implemented as part of BitFormFieldControl */ /**Implemented as part of BitFormFieldControl */
@HostBinding() @Input() id = `bit-multi-select-${nextId++}`; readonly id = input(`bit-multi-select-${nextId++}`);
/**Implemented as part of BitFormFieldControl */ /**Implemented as part of BitFormFieldControl */
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@HostBinding("attr.required") @HostBinding("attr.required")
@Input() @Input()
get required() { get required() {

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Directive, EventEmitter, Input, Output } from "@angular/core"; import { Directive, EventEmitter, Output, input } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router"; import { RouterLink, RouterLinkActive } from "@angular/router";
/** /**
@@ -11,17 +11,17 @@ export abstract class NavBaseComponent {
/** /**
* Text to display in main content * Text to display in main content
*/ */
@Input() text: string; readonly text = input<string>();
/** /**
* `aria-label` for main content * `aria-label` for main content
*/ */
@Input() ariaLabel: string; readonly ariaLabel = input<string>();
/** /**
* Optional icon, e.g. `"bwi-collection-shared"` * Optional icon, e.g. `"bwi-collection-shared"`
*/ */
@Input() icon: string; readonly icon = input<string>();
/** /**
* Optional route to be passed to internal `routerLink`. If not provided, the nav component will render as a button. * Optional route to be passed to internal `routerLink`. If not provided, the nav component will render as a button.
@@ -34,31 +34,31 @@ export abstract class NavBaseComponent {
* *
* See: {@link https://github.com/angular/angular/issues/24482} * See: {@link https://github.com/angular/angular/issues/24482}
*/ */
@Input() route?: RouterLink["routerLink"]; readonly route = input<RouterLink["routerLink"]>();
/** /**
* Passed to internal `routerLink` * Passed to internal `routerLink`
* *
* See {@link RouterLink.relativeTo} * See {@link RouterLink.relativeTo}
*/ */
@Input() relativeTo?: RouterLink["relativeTo"]; readonly relativeTo = input<RouterLink["relativeTo"]>();
/** /**
* Passed to internal `routerLink` * Passed to internal `routerLink`
* *
* See {@link RouterLinkActive.routerLinkActiveOptions} * See {@link RouterLinkActive.routerLinkActiveOptions}
*/ */
@Input() routerLinkActiveOptions?: RouterLinkActive["routerLinkActiveOptions"] = { readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
paths: "subset", paths: "subset",
queryParams: "ignored", queryParams: "ignored",
fragment: "ignored", fragment: "ignored",
matrixParams: "ignored", matrixParams: "ignored",
}; });
/** /**
* If `true`, do not change styles when nav item is active. * If `true`, do not change styles when nav item is active.
*/ */
@Input() hideActiveStyles = false; readonly hideActiveStyles = input(false);
/** /**
* Fires when main content is clicked * Fires when main content is clicked

View File

@@ -1,28 +1,28 @@
<!-- This a higher order component that composes `NavItemComponent` --> <!-- This a higher order component that composes `NavItemComponent` -->
@if (!hideIfEmpty || nestedNavComponents.length > 0) { @if (!hideIfEmpty() || nestedNavComponents.length > 0) {
<bit-nav-item <bit-nav-item
[text]="text" [text]="text()"
[icon]="icon" [icon]="icon()"
[route]="route" [route]="route()"
[relativeTo]="relativeTo" [relativeTo]="relativeTo()"
[routerLinkActiveOptions]="routerLinkActiveOptions" [routerLinkActiveOptions]="routerLinkActiveOptions()"
(mainContentClicked)="handleMainContentClicked()" (mainContentClicked)="handleMainContentClicked()"
[ariaLabel]="ariaLabel" [ariaLabel]="ariaLabel()"
[hideActiveStyles]="parentHideActiveStyles" [hideActiveStyles]="parentHideActiveStyles"
> >
<ng-template #button> <ng-template #button>
<button <button
type="button" type="button"
class="tw-ms-auto" class="tw-ms-auto"
[bitIconButton]="open ? 'bwi-angle-up' : 'bwi-angle-down'" [bitIconButton]="open() ? 'bwi-angle-up' : 'bwi-angle-down'"
[buttonType]="'light'" [buttonType]="'light'"
(click)="toggle($event)" (click)="toggle($event)"
size="small" size="small"
[title]="'toggleCollapse' | i18n" [title]="'toggleCollapse' | i18n"
aria-haspopup="true" aria-haspopup="true"
[attr.aria-expanded]="open.toString()" [attr.aria-expanded]="open().toString()"
[attr.aria-controls]="contentId" [attr.aria-controls]="contentId"
[attr.aria-label]="['toggleCollapse' | i18n, text].join(' ')" [attr.aria-label]="['toggleCollapse' | i18n, text()].join(' ')"
></button> ></button>
</ng-template> </ng-template>
<ng-container slot="end"> <ng-container slot="end">
@@ -32,10 +32,10 @@
</bit-nav-item> </bit-nav-item>
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element --> <!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
@if (sideNavService.open$ | async) { @if (sideNavService.open$ | async) {
@if (open) { @if (open()) {
<div <div
[attr.id]="contentId" [attr.id]="contentId"
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')" [attr.aria-label]="[text(), 'submenu' | i18n].join(' ')"
role="group" role="group"
> >
<ng-content></ng-content> <ng-content></ng-content>

View File

@@ -4,11 +4,12 @@ import {
Component, Component,
ContentChildren, ContentChildren,
EventEmitter, EventEmitter,
Input,
Optional, Optional,
Output, Output,
QueryList, QueryList,
SkipSelf, SkipSelf,
input,
model,
} from "@angular/core"; } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -36,7 +37,7 @@ export class NavGroupComponent extends NavBaseComponent {
/** When the side nav is open, the parent nav item should not show active styles when open. */ /** When the side nav is open, the parent nav item should not show active styles when open. */
protected get parentHideActiveStyles(): boolean { protected get parentHideActiveStyles(): boolean {
return this.hideActiveStyles || (this.open && this.sideNavService.open); return this.hideActiveStyles() || (this.open() && this.sideNavService.open);
} }
/** /**
@@ -47,14 +48,12 @@ export class NavGroupComponent extends NavBaseComponent {
/** /**
* Is `true` if the expanded content is visible * Is `true` if the expanded content is visible
*/ */
@Input() readonly open = model(false);
open = false;
/** /**
* Automatically hide the nav group if there are no child buttons * Automatically hide the nav group if there are no child buttons
*/ */
@Input({ transform: booleanAttribute }) readonly hideIfEmpty = input(false, { transform: booleanAttribute });
hideIfEmpty = false;
@Output() @Output()
openChange = new EventEmitter<boolean>(); openChange = new EventEmitter<boolean>();
@@ -67,24 +66,24 @@ export class NavGroupComponent extends NavBaseComponent {
} }
setOpen(isOpen: boolean) { setOpen(isOpen: boolean) {
this.open = isOpen; this.open.set(isOpen);
this.openChange.emit(this.open); this.openChange.emit(this.open());
// FIXME: Remove when updating file. Eslint update // FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.open && this.parentNavGroup?.setOpen(this.open); this.open() && this.parentNavGroup?.setOpen(this.open());
} }
protected toggle(event?: MouseEvent) { protected toggle(event?: MouseEvent) {
event?.stopPropagation(); event?.stopPropagation();
this.setOpen(!this.open); this.setOpen(!this.open());
} }
protected handleMainContentClicked() { protected handleMainContentClicked() {
if (!this.sideNavService.open) { if (!this.sideNavService.open) {
if (!this.route) { if (!this.route()) {
this.sideNavService.setOpen(); this.sideNavService.setOpen();
} }
this.open = true; this.open.set(true);
} else { } else {
this.toggle(); this.toggle();
} }

View File

@@ -1,6 +1,6 @@
<div class="tw-ps-2 tw-pe-2"> <div class="tw-ps-2 tw-pe-2">
@let open = sideNavService.open$ | async; @let open = sideNavService.open$ | async;
@if (open || icon) { @if (open || icon()) {
<div <div
class="tw-relative tw-rounded-md tw-h-10" class="tw-relative tw-rounded-md tw-h-10"
[ngClass]="[ [ngClass]="[
@@ -16,17 +16,17 @@
<!-- Main content of `NavItem` --> <!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent> <ng-template #anchorAndButtonContent>
<div <div
[title]="text" [title]="text()"
class="tw-gap-2 tw-items-center tw-font-bold tw-h-full tw-content-center" class="tw-gap-2 tw-items-center tw-font-bold tw-h-full tw-content-center"
[ngClass]="{ 'tw-text-center': !open, 'tw-flex': open }" [ngClass]="{ 'tw-text-center': !open, 'tw-flex': open }"
> >
<i <i
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon }}" class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
[attr.aria-hidden]="open" [attr.aria-hidden]="open"
[attr.aria-label]="text" [attr.aria-label]="text()"
></i> ></i>
@if (open) { @if (open) {
<span class="tw-truncate">{{ text }}</span> <span class="tw-truncate">{{ text() }}</span>
} }
</div> </div>
</ng-template> </ng-template>
@@ -38,11 +38,11 @@
<a <a
class="tw-size-full tw-px-3 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none" class="tw-size-full tw-px-3 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
data-fvw data-fvw
[routerLink]="route" [routerLink]="route()"
[relativeTo]="relativeTo" [relativeTo]="relativeTo()"
[attr.aria-label]="ariaLabel || text" [attr.aria-label]="ariaLabel() || text()"
routerLinkActive routerLinkActive
[routerLinkActiveOptions]="routerLinkActiveOptions" [routerLinkActiveOptions]="routerLinkActiveOptions()"
[ariaCurrentWhenActive]="'page'" [ariaCurrentWhenActive]="'page'"
(isActiveChange)="setIsActive($event)" (isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()" (click)="mainContentClicked.emit()"

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, HostListener, Input, Optional } from "@angular/core"; import { Component, HostListener, Optional, input } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
@@ -21,7 +21,7 @@ export abstract class NavGroupAbstraction {
}) })
export class NavItemComponent extends NavBaseComponent { export class NavItemComponent extends NavBaseComponent {
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */ /** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
@Input() forceActiveStyles? = false; readonly forceActiveStyles = input<boolean>(false);
/** /**
* Is `true` if `to` matches the current route * Is `true` if `to` matches the current route
@@ -34,7 +34,7 @@ export class NavItemComponent extends NavBaseComponent {
} }
} }
protected get showActiveStyles() { protected get showActiveStyles() {
return this.forceActiveStyles || (this._isActive && !this.hideActiveStyles); return this.forceActiveStyles() || (this._isActive && !this.hideActiveStyles());
} }
/** /**

View File

@@ -6,14 +6,14 @@
class="tw-px-2 tw-pt-5" class="tw-px-2 tw-pt-5"
> >
<a <a
[routerLink]="route" [routerLink]="route()"
class="tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast [&_path]:tw-fill-text-alt2" class="tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast [&_path]:tw-fill-text-alt2"
[ngClass]="{ '[&_svg]:tw-w-[1.687rem]': !sideNavService.open }" [ngClass]="{ '[&_svg]:tw-w-[1.687rem]': !sideNavService.open }"
[attr.aria-label]="label" [attr.aria-label]="label()"
[title]="label" [title]="label()"
routerLinkActive routerLinkActive
[ariaCurrentWhenActive]="'page'" [ariaCurrentWhenActive]="'page'"
> >
<bit-icon [icon]="sideNavService.open ? openIcon : closedIcon"></bit-icon> <bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
</a> </a>
</div> </div>

View File

@@ -1,7 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { Component, input } from "@angular/core";
import { RouterLinkActive, RouterLink } from "@angular/router"; import { RouterLinkActive, RouterLink } from "@angular/router";
import { Icon } from "../icon"; import { Icon } from "../icon";
@@ -17,18 +18,18 @@ import { SideNavService } from "./side-nav.service";
}) })
export class NavLogoComponent { export class NavLogoComponent {
/** Icon that is displayed when the side nav is closed */ /** Icon that is displayed when the side nav is closed */
@Input() closedIcon = BitwardenShield; readonly closedIcon = input(BitwardenShield);
/** Icon that is displayed when the side nav is open */ /** Icon that is displayed when the side nav is open */
@Input({ required: true }) openIcon: Icon; readonly openIcon = input.required<Icon>();
/** /**
* Route to be passed to internal `routerLink` * Route to be passed to internal `routerLink`
*/ */
@Input({ required: true }) route: string | any[]; readonly route = input.required<string | any[]>();
/** Passed to `attr.aria-label` and `attr.title` */ /** Passed to `attr.aria-label` and `attr.title` */
@Input({ required: true }) label: string; readonly label = input.required<string>();
constructor(protected sideNavService: SideNavService) {} constructor(protected sideNavService: SideNavService) {}
} }

View File

@@ -10,7 +10,7 @@
class="tw-fixed md:tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none" class="tw-fixed md:tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[ngClass]="{ 'tw-w-60': data.open }" [ngClass]="{ 'tw-w-60': data.open }"
[ngStyle]=" [ngStyle]="
variant === 'secondary' && { variant() === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)', '--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)', '--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)', '--color-background-alt4': 'var(--color-secondary-300)',

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, ElementRef, Input, ViewChild } from "@angular/core"; import { Component, ElementRef, ViewChild, input } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -19,7 +19,7 @@ export type SideNavVariant = "primary" | "secondary";
imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe], imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe],
}) })
export class SideNavComponent { export class SideNavComponent {
@Input() variant: SideNavVariant = "primary"; readonly variant = input<SideNavVariant>("primary");
@ViewChild("toggleButton", { read: ElementRef, static: true }) @ViewChild("toggleButton", { read: ElementRef, static: true })
private toggleButton: ElementRef<HTMLButtonElement>; private toggleButton: ElementRef<HTMLButtonElement>;

View File

@@ -1,6 +1,6 @@
<div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6"> <div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6">
<div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center"> <div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center">
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon> <bit-icon [icon]="icon()" aria-hidden="true"></bit-icon>
<h3 class="tw-font-semibold tw-text-center tw-mt-4"> <h3 class="tw-font-semibold tw-text-center tw-mt-4">
<ng-content select="[slot=title]"></ng-content> <ng-content select="[slot=title]"></ng-content>
</h3> </h3>

View File

@@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core"; import { Component, input } from "@angular/core";
import { Icons } from ".."; import { Icons } from "..";
import { BitIconComponent } from "../icon/icon.component"; import { BitIconComponent } from "../icon/icon.component";
@@ -12,5 +12,5 @@ import { BitIconComponent } from "../icon/icon.component";
imports: [BitIconComponent], imports: [BitIconComponent],
}) })
export class NoItemsComponent { export class NoItemsComponent {
@Input() icon = Icons.Search; readonly icon = input(Icons.Search);
} }

View File

@@ -6,11 +6,11 @@ import {
AfterViewInit, AfterViewInit,
Directive, Directive,
ElementRef, ElementRef,
HostBinding,
HostListener, HostListener,
Input,
OnDestroy, OnDestroy,
ViewContainerRef, ViewContainerRef,
input,
model,
} from "@angular/core"; } from "@angular/core";
import { Observable, Subscription, filter, mergeWith } from "rxjs"; import { Observable, Subscription, filter, mergeWith } from "rxjs";
@@ -20,27 +20,26 @@ import { PopoverComponent } from "./popover.component";
@Directive({ @Directive({
selector: "[bitPopoverTriggerFor]", selector: "[bitPopoverTriggerFor]",
exportAs: "popoverTrigger", exportAs: "popoverTrigger",
host: {
"[attr.aria-expanded]": "this.popoverOpen()",
},
}) })
export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
@Input() readonly popoverOpen = model(false);
@HostBinding("attr.aria-expanded")
popoverOpen = false;
@Input("bitPopoverTriggerFor") readonly popover = input<PopoverComponent>(undefined, { alias: "bitPopoverTriggerFor" });
popover: PopoverComponent;
@Input("position") readonly position = input<string>();
position: string;
private overlayRef: OverlayRef; private overlayRef: OverlayRef;
private closedEventsSub: Subscription; private closedEventsSub: Subscription;
get positions() { get positions() {
if (!this.position) { if (!this.position()) {
return defaultPositions; return defaultPositions;
} }
const preferredPosition = defaultPositions.find((position) => position.id === this.position); const preferredPosition = defaultPositions.find((position) => position.id === this.position());
if (preferredPosition) { if (preferredPosition) {
return [preferredPosition, ...defaultPositions]; return [preferredPosition, ...defaultPositions];
@@ -72,7 +71,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
@HostListener("click") @HostListener("click")
togglePopover() { togglePopover() {
if (this.popoverOpen) { if (this.popoverOpen()) {
this.closePopover(); this.closePopover();
} else { } else {
this.openPopover(); this.openPopover();
@@ -80,10 +79,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
} }
private openPopover() { private openPopover() {
this.popoverOpen = true; this.popoverOpen.set(true);
this.overlayRef = this.overlay.create(this.defaultPopoverConfig); this.overlayRef = this.overlay.create(this.defaultPopoverConfig);
const templatePortal = new TemplatePortal(this.popover.templateRef, this.viewContainerRef); const templatePortal = new TemplatePortal(this.popover().templateRef, this.viewContainerRef);
this.overlayRef.attach(templatePortal); this.overlayRef.attach(templatePortal);
this.closedEventsSub = this.getClosedEvents().subscribe(() => { this.closedEventsSub = this.getClosedEvents().subscribe(() => {
@@ -97,17 +96,17 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
.keydownEvents() .keydownEvents()
.pipe(filter((event: KeyboardEvent) => event.key === "Escape")); .pipe(filter((event: KeyboardEvent) => event.key === "Escape"));
const backdrop = this.overlayRef.backdropClick(); const backdrop = this.overlayRef.backdropClick();
const popoverClosed = this.popover.closed; const popoverClosed = this.popover().closed;
return detachments.pipe(mergeWith(escKey, backdrop, popoverClosed)); return detachments.pipe(mergeWith(escKey, backdrop, popoverClosed));
} }
private destroyPopover() { private destroyPopover() {
if (this.overlayRef == null || !this.popoverOpen) { if (this.overlayRef == null || !this.popoverOpen()) {
return; return;
} }
this.popoverOpen = false; this.popoverOpen.set(false);
this.disposeAll(); this.disposeAll();
} }
@@ -117,7 +116,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
} }
ngAfterViewInit() { ngAfterViewInit() {
if (this.popoverOpen) { if (this.popoverOpen()) {
this.openPopover(); this.openPopover();
} }
} }

View File

@@ -6,7 +6,7 @@
> >
<div class="tw-mb-1 tw-me-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-ps-4"> <div class="tw-mb-1 tw-me-2 tw-flex tw-items-start tw-justify-between tw-gap-4 tw-ps-4">
<h2 bitTypography="h5" class="tw-mt-1 tw-font-semibold"> <h2 bitTypography="h5" class="tw-mt-1 tw-font-semibold">
{{ title }} {{ title() }}
</h2> </h2>
<button <button
type="button" type="button"

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { A11yModule } from "@angular/cdk/a11y"; import { A11yModule } from "@angular/cdk/a11y";
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core"; import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core";
import { IconButtonModule } from "../icon-button/icon-button.module"; import { IconButtonModule } from "../icon-button/icon-button.module";
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared/shared.module";
@@ -15,6 +15,6 @@ import { TypographyModule } from "../typography";
}) })
export class PopoverComponent { export class PopoverComponent {
@ViewChild(TemplateRef) templateRef: TemplateRef<any>; @ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@Input() title = ""; readonly title = input("");
@Output() closed = new EventEmitter(); @Output() closed = new EventEmitter();
} }

View File

@@ -4,8 +4,8 @@
role="progressbar" role="progressbar"
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
attr.aria-valuenow="{{ barWidth }}" attr.aria-valuenow="{{ barWidth() }}"
[ngStyle]="{ width: barWidth + '%' }" [ngStyle]="{ width: barWidth() + '%' }"
> >
@if (displayText) { @if (displayText) {
<div class="tw-flex tw-h-full tw-flex-wrap tw-items-center tw-overflow-hidden"> <div class="tw-flex tw-h-full tw-flex-wrap tw-items-center tw-overflow-hidden">

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { Component, input } from "@angular/core";
type ProgressSizeType = "small" | "default" | "large"; type ProgressSizeType = "small" | "default" | "large";
type BackgroundType = "danger" | "primary" | "success" | "warning"; type BackgroundType = "danger" | "primary" | "success" | "warning";
@@ -26,19 +26,19 @@ const BackgroundClasses: Record<BackgroundType, string[]> = {
imports: [CommonModule], imports: [CommonModule],
}) })
export class ProgressComponent { export class ProgressComponent {
@Input() barWidth = 0; readonly barWidth = input(0);
@Input() bgColor: BackgroundType = "primary"; readonly bgColor = input<BackgroundType>("primary");
@Input() showText = true; readonly showText = input(true);
@Input() size: ProgressSizeType = "default"; readonly size = input<ProgressSizeType>("default");
@Input() text?: string; readonly text = input<string>();
get displayText() { get displayText() {
return this.showText && this.size !== "small"; return this.showText() && this.size() !== "small";
} }
get outerBarStyles() { get outerBarStyles() {
return ["tw-overflow-hidden", "tw-rounded", "tw-bg-secondary-100"].concat( return ["tw-overflow-hidden", "tw-rounded", "tw-bg-secondary-100"].concat(
SizeClasses[this.size], SizeClasses[this.size()],
); );
} }
@@ -53,11 +53,11 @@ export class ProgressComponent {
"tw-text-contrast", "tw-text-contrast",
"tw-transition-all", "tw-transition-all",
] ]
.concat(SizeClasses[this.size]) .concat(SizeClasses[this.size()])
.concat(BackgroundClasses[this.bgColor]); .concat(BackgroundClasses[this.bgColor()]);
} }
get textContent() { get textContent() {
return this.text || this.barWidth + "%"; return this.text() || this.barWidth() + "%";
} }
} }

View File

@@ -3,8 +3,8 @@
type="radio" type="radio"
bitRadio bitRadio
[id]="inputId" [id]="inputId"
[disabled]="groupDisabled || disabled" [disabled]="groupDisabled || disabled()"
[value]="value" [value]="value()"
[checked]="selected" [checked]="selected"
(change)="onInputChange()" (change)="onInputChange()"
(blur)="onBlur()" (blur)="onBlur()"

View File

@@ -1,5 +1,6 @@
import { Component } from "@angular/core"; import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -10,70 +11,62 @@ import { RadioButtonModule } from "./radio-button.module";
import { RadioGroupComponent } from "./radio-group.component"; import { RadioGroupComponent } from "./radio-group.component";
describe("RadioButton", () => { describe("RadioButton", () => {
let mockGroupComponent: MockedButtonGroupComponent; let fixture: ComponentFixture<TestComponent>;
let fixture: ComponentFixture<TestApp>; let radioButtonGroup: RadioGroupComponent;
let testAppComponent: TestApp; let radioButtons: DebugElement[];
let radioButton: HTMLInputElement;
beforeEach(async () => { beforeEach(async () => {
mockGroupComponent = new MockedButtonGroupComponent();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TestApp], imports: [TestComponent],
providers: [ providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
{ provide: RadioGroupComponent, useValue: mockGroupComponent },
{ provide: I18nService, useValue: new I18nMockService({}) },
],
}); });
await TestBed.compileComponents(); await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp); fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges(); fixture.detectChanges();
testAppComponent = fixture.debugElement.componentInstance; radioButtonGroup = fixture.debugElement.query(
radioButton = fixture.debugElement.query(By.css("input[type=radio]")).nativeElement; By.directive(RadioGroupComponent),
).componentInstance;
radioButtons = fixture.debugElement.queryAll(By.css("input[type=radio]"));
}); });
it("should emit value when clicking on radio button", () => { it("should emit value when clicking on radio button", () => {
testAppComponent.value = "value"; const spyFn = jest.spyOn(radioButtonGroup, "onInputChange");
radioButtons[1].triggerEventHandler("change");
fixture.detectChanges(); fixture.detectChanges();
radioButton.click(); expect(spyFn).toHaveBeenCalledWith(1);
fixture.detectChanges();
expect(mockGroupComponent.onInputChange).toHaveBeenCalledWith("value");
}); });
it("should check radio button when selected matches value", () => { it("should check radio button only when selected matches value", () => {
testAppComponent.value = "value";
fixture.detectChanges(); fixture.detectChanges();
mockGroupComponent.selected = "value"; expect(radioButtons[0].nativeElement.checked).toBe(true);
expect(radioButtons[1].nativeElement.checked).toBe(false);
radioButtons[1].triggerEventHandler("change");
fixture.detectChanges(); fixture.detectChanges();
expect(radioButton.checked).toBe(true); expect(radioButtons[0].nativeElement.checked).toBe(false);
}); expect(radioButtons[1].nativeElement.checked).toBe(true);
it("should not check radio button when selected does not match value", () => {
testAppComponent.value = "value";
fixture.detectChanges();
mockGroupComponent.selected = "nonMatchingValue";
fixture.detectChanges();
expect(radioButton.checked).toBe(false);
}); });
}); });
class MockedButtonGroupComponent implements Partial<RadioGroupComponent> {
onInputChange = jest.fn();
selected: unknown = null;
}
@Component({ @Component({
selector: "test-app", selector: "test-component",
template: `<bit-radio-button [value]="value"><bit-label>Element</bit-label></bit-radio-button>`, template: `
imports: [RadioButtonModule], <form [formGroup]="formObj">
<bit-radio-group formControlName="radio">
<bit-radio-button [value]="0"><bit-label>Element</bit-label></bit-radio-button>
<bit-radio-button [value]="1"><bit-label>Element</bit-label></bit-radio-button>
</bit-radio-group>
</form>
`,
imports: [FormsModule, ReactiveFormsModule, RadioButtonModule],
}) })
class TestApp { class TestComponent {
value?: string; formObj = new FormGroup({
radio: new FormControl(0),
});
} }

View File

@@ -1,4 +1,4 @@
import { Component, HostBinding, Input } from "@angular/core"; import { Component, HostBinding, input } from "@angular/core";
import { FormControlModule } from "../form-control/form-control.module"; import { FormControlModule } from "../form-control/form-control.module";
@@ -11,20 +11,23 @@ let nextId = 0;
selector: "bit-radio-button", selector: "bit-radio-button",
templateUrl: "radio-button.component.html", templateUrl: "radio-button.component.html",
imports: [FormControlModule, RadioInputComponent], imports: [FormControlModule, RadioInputComponent],
host: {
"[id]": "this.id()",
},
}) })
export class RadioButtonComponent { export class RadioButtonComponent {
@HostBinding("attr.id") @Input() id = `bit-radio-button-${nextId++}`; readonly id = input(`bit-radio-button-${nextId++}`);
@HostBinding("class") get classList() { @HostBinding("class") get classList() {
return [this.block ? "tw-block" : "tw-inline-block", "tw-mb-1", "[&_bit-hint]:tw-mt-0"]; return [this.block ? "tw-block" : "tw-inline-block", "tw-mb-1", "[&_bit-hint]:tw-mt-0"];
} }
@Input() value: unknown; readonly value = input<unknown>();
@Input() disabled = false; readonly disabled = input(false);
constructor(private groupComponent: RadioGroupComponent) {} constructor(private groupComponent: RadioGroupComponent) {}
get inputId() { get inputId() {
return `${this.id}-input`; return `${this.id()}-input`;
} }
get name() { get name() {
@@ -32,7 +35,7 @@ export class RadioButtonComponent {
} }
get selected() { get selected() {
return this.groupComponent.selected === this.value; return this.groupComponent.selected === this.value();
} }
get groupDisabled() { get groupDisabled() {
@@ -40,11 +43,11 @@ export class RadioButtonComponent {
} }
get block() { get block() {
return this.groupComponent.block; return this.groupComponent.block();
} }
protected onInputChange() { protected onInputChange() {
this.groupComponent.onInputChange(this.value); this.groupComponent.onInputChange(this.value());
} }
protected onBlur() { protected onBlur() {

View File

@@ -177,15 +177,15 @@ export const Disabled: Story = {
<bit-radio-group formControlName="radio" aria-label="Example radio group"> <bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-label>Group of radio buttons</bit-label> <bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" [value]="0" [disabled]="true"> <bit-radio-button [value]="0" [disabled]="true">
<bit-label>First</bit-label> <bit-label>First</bit-label>
</bit-radio-button> </bit-radio-button>
<bit-radio-button id="radio-second" [value]="1" [disabled]="true"> <bit-radio-button [value]="1" [disabled]="true">
<bit-label>Second</bit-label> <bit-label>Second</bit-label>
</bit-radio-button> </bit-radio-button>
<bit-radio-button id="radio-third" [value]="2" [disabled]="true"> <bit-radio-button [value]="2" [disabled]="true">
<bit-label>Third</bit-label> <bit-label>Third</bit-label>
</bit-radio-button> </bit-radio-button>
</bit-radio-group> </bit-radio-group>

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { NgTemplateOutlet } from "@angular/common"; import { NgTemplateOutlet } from "@angular/common";
import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core"; import { Component, ContentChild, HostBinding, Optional, Input, Self, input } from "@angular/core";
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms"; import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
@@ -14,11 +14,16 @@ let nextId = 0;
selector: "bit-radio-group", selector: "bit-radio-group",
templateUrl: "radio-group.component.html", templateUrl: "radio-group.component.html",
imports: [NgTemplateOutlet, I18nPipe], imports: [NgTemplateOutlet, I18nPipe],
host: {
"[id]": "id()",
},
}) })
export class RadioGroupComponent implements ControlValueAccessor { export class RadioGroupComponent implements ControlValueAccessor {
selected: unknown; selected: unknown;
disabled = false; disabled = false;
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
private _name?: string; private _name?: string;
@Input() get name() { @Input() get name() {
return this._name ?? this.ngControl?.name?.toString(); return this._name ?? this.ngControl?.name?.toString();
@@ -27,10 +32,10 @@ export class RadioGroupComponent implements ControlValueAccessor {
this._name = value; this._name = value;
} }
@Input() block = false; readonly block = input(false);
@HostBinding("attr.role") role = "radiogroup"; @HostBinding("attr.role") role = "radiogroup";
@HostBinding("attr.id") @Input() id = `bit-radio-group-${nextId++}`; readonly id = input(`bit-radio-group-${nextId++}`);
@HostBinding("class") classList = ["tw-block", "tw-mb-4"]; @HostBinding("class") classList = ["tw-block", "tw-mb-4"];
@ContentChild(BitLabel) protected label: BitLabel; @ContentChild(BitLabel) protected label: BitLabel;

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, HostBinding, Input, Optional, Self } from "@angular/core"; import { Component, HostBinding, input, Input, Optional, Self } from "@angular/core";
import { NgControl, Validators } from "@angular/forms"; import { NgControl, Validators } from "@angular/forms";
import { BitFormControlAbstraction } from "../form-control"; import { BitFormControlAbstraction } from "../form-control";
@@ -11,9 +11,12 @@ let nextId = 0;
selector: "input[type=radio][bitRadio]", selector: "input[type=radio][bitRadio]",
template: "", template: "",
providers: [{ provide: BitFormControlAbstraction, useExisting: RadioInputComponent }], providers: [{ provide: BitFormControlAbstraction, useExisting: RadioInputComponent }],
host: {
"[id]": "this.id()",
},
}) })
export class RadioInputComponent implements BitFormControlAbstraction { export class RadioInputComponent implements BitFormControlAbstraction {
@HostBinding("attr.id") @Input() id = `bit-radio-input-${nextId++}`; readonly id = input(`bit-radio-input-${nextId++}`);
@HostBinding("class") @HostBinding("class")
protected inputClasses = [ protected inputClasses = [
@@ -73,6 +76,8 @@ export class RadioInputComponent implements BitFormControlAbstraction {
constructor(@Optional() @Self() private ngControl?: NgControl) {} constructor(@Optional() @Self() private ngControl?: NgControl) {}
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@HostBinding() @HostBinding()
@Input() @Input()
get disabled() { get disabled() {
@@ -83,6 +88,8 @@ export class RadioInputComponent implements BitFormControlAbstraction {
} }
private _disabled: boolean; private _disabled: boolean;
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() @Input()
get required() { get required() {
return ( return (

View File

@@ -12,12 +12,12 @@
bitInput bitInput
[type]="inputType" [type]="inputType"
[id]="id" [id]="id"
[placeholder]="placeholder ?? ('search' | i18n)" [placeholder]="placeholder() ?? ('search' | i18n)"
class="tw-ps-9" class="tw-ps-9"
[ngModel]="searchText" [ngModel]="searchText"
(ngModelChange)="onChange($event)" (ngModelChange)="onChange($event)"
(blur)="onTouch()" (blur)="onTouch()"
[disabled]="disabled" [disabled]="disabled()"
[attr.autocomplete]="autocomplete" [attr.autocomplete]="autocomplete()"
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, ElementRef, Input, ViewChild } from "@angular/core"; import { Component, ElementRef, ViewChild, input, model } from "@angular/core";
import { import {
ControlValueAccessor, ControlValueAccessor,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
@@ -43,9 +43,9 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
// Use `type="text"` for Safari to improve rendering performance // Use `type="text"` for Safari to improve rendering performance
protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const); protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const);
@Input() disabled: boolean; readonly disabled = model<boolean>();
@Input() placeholder: string; readonly placeholder = input<string>();
@Input() autocomplete: string; readonly autocomplete = input<string>();
getFocusTarget() { getFocusTarget() {
return this.input?.nativeElement; return this.input?.nativeElement;
@@ -76,6 +76,6 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
} }
setDisabledState(isDisabled: boolean) { setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled; this.disabled.set(isDisabled);
} }
} }

View File

@@ -1,6 +1,6 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { Component, input } from "@angular/core";
@Component({ @Component({
selector: "bit-section", selector: "bit-section",
@@ -9,7 +9,7 @@ import { Component, Input } from "@angular/core";
<section <section
[ngClass]="{ [ngClass]="{
'tw-mb-5 bit-compact:tw-mb-4 [&:not(bit-dialog_*):not(popup-page_*)]:md:tw-mb-12': 'tw-mb-5 bit-compact:tw-mb-4 [&:not(bit-dialog_*):not(popup-page_*)]:md:tw-mb-12':
!disableMargin, !disableMargin(),
}" }"
> >
<ng-content></ng-content> <ng-content></ng-content>
@@ -17,5 +17,5 @@ import { Component, Input } from "@angular/core";
`, `,
}) })
export class SectionComponent { export class SectionComponent {
@Input({ transform: coerceBooleanProperty }) disableMargin = false; readonly disableMargin = input(false, { transform: coerceBooleanProperty });
} }

View File

@@ -1,23 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, Input, booleanAttribute } from "@angular/core"; import { Component, booleanAttribute, input } from "@angular/core";
import { Option } from "./option"; import { MappedOptionComponent } from "./option";
@Component({ @Component({
selector: "bit-option", selector: "bit-option",
template: `<ng-template><ng-content></ng-content></ng-template>`, template: `<ng-template><ng-content></ng-content></ng-template>`,
}) })
export class OptionComponent<T = unknown> implements Option<T> { export class OptionComponent<T = unknown> implements MappedOptionComponent<T> {
@Input() readonly icon = input<string>();
icon?: string;
@Input({ required: true }) readonly value = input.required<T>();
value: T;
@Input({ required: true }) readonly label = input.required<string>();
label: string;
@Input({ transform: booleanAttribute }) readonly disabled = input(undefined, { transform: booleanAttribute });
disabled: boolean;
} }

View File

@@ -1,6 +1,10 @@
import { MappedDataToSignal } from "../shared/data-to-signal-type";
export interface Option<T> { export interface Option<T> {
icon?: string; icon?: string;
value: T | null; value: T | null;
label?: string; label?: string;
disabled?: boolean; disabled?: boolean;
} }
export type MappedOptionComponent<T> = MappedDataToSignal<Option<T>>;

View File

@@ -1,9 +1,9 @@
<ng-select <ng-select
[(ngModel)]="selectedOption" [ngModel]="selectedOption()"
(ngModelChange)="onChange($event)" (ngModelChange)="onChange($event)"
[disabled]="disabled" [disabled]="disabled"
[placeholder]="placeholder" [placeholder]="placeholder()"
[items]="items" [items]="items()"
(blur)="onBlur()" (blur)="onBlur()"
[labelForId]="labelForId" [labelForId]="labelForId"
[clearable]="false" [clearable]="false"

View File

@@ -37,15 +37,15 @@ describe("Select Component", () => {
describe("initial state", () => { describe("initial state", () => {
it("selected option should update when items input changes", () => { it("selected option should update when items input changes", () => {
expect(select.selectedOption?.value).toBeUndefined(); expect(select.selectedOption()?.value).toBeUndefined();
select.items = [ select.items.set([
{ label: "Apple", value: "apple" }, { label: "Apple", value: "apple" },
{ label: "Pear", value: "pear" }, { label: "Pear", value: "pear" },
{ label: "Banana", value: "banana" }, { label: "Banana", value: "banana" },
]; ]);
expect(select.selectedOption?.value).toBe("apple"); expect(select.selectedOption()?.value).toBe("apple");
}); });
}); });
}); });

View File

@@ -13,6 +13,11 @@ import {
ViewChild, ViewChild,
Output, Output,
EventEmitter, EventEmitter,
input,
Signal,
computed,
model,
signal,
} from "@angular/core"; } from "@angular/core";
import { import {
ControlValueAccessor, ControlValueAccessor,
@@ -37,29 +42,23 @@ let nextId = 0;
templateUrl: "select.component.html", templateUrl: "select.component.html",
providers: [{ provide: BitFormFieldControl, useExisting: SelectComponent }], providers: [{ provide: BitFormFieldControl, useExisting: SelectComponent }],
imports: [NgSelectModule, ReactiveFormsModule, FormsModule], imports: [NgSelectModule, ReactiveFormsModule, FormsModule],
host: {
"[id]": "id()",
},
}) })
export class SelectComponent<T> implements BitFormFieldControl, ControlValueAccessor { export class SelectComponent<T> implements BitFormFieldControl, ControlValueAccessor {
@ViewChild(NgSelectComponent) select: NgSelectComponent; @ViewChild(NgSelectComponent) select: NgSelectComponent;
private _items: Option<T>[] = [];
/** Optional: Options can be provided using an array input or using `bit-option` */ /** Optional: Options can be provided using an array input or using `bit-option` */
@Input() readonly items = model<Option<T>[] | undefined>();
get items(): Option<T>[] {
return this._items;
}
set items(next: Option<T>[]) {
this._items = next;
this._selectedOption = this.findSelectedOption(next, this.selectedValue);
}
@Input() placeholder = this.i18nService.t("selectPlaceholder"); readonly placeholder = input(this.i18nService.t("selectPlaceholder"));
@Output() closed = new EventEmitter(); @Output() closed = new EventEmitter();
protected selectedValue: T; protected selectedValue = signal<T>(undefined);
protected _selectedOption: Option<T>; selectedOption: Signal<Option<T>> = computed(() =>
get selectedOption() { this.findSelectedOption(this.items(), this.selectedValue()),
return this._selectedOption; );
}
protected searchInputId = `bit-select-search-input-${nextId++}`; protected searchInputId = `bit-select-search-input-${nextId++}`;
private notifyOnChange?: (value: T) => void; private notifyOnChange?: (value: T) => void;
@@ -79,7 +78,14 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
if (value == null || value.length == 0) { if (value == null || value.length == 0) {
return; return;
} }
this.items = value.toArray(); this.items.set(
value.toArray().map((option) => ({
icon: option.icon(),
value: option.value(),
label: option.label(),
disabled: option.disabled(),
})),
);
} }
@HostBinding("class") protected classes = ["tw-block", "tw-w-full", "tw-h-full"]; @HostBinding("class") protected classes = ["tw-block", "tw-w-full", "tw-h-full"];
@@ -89,6 +95,8 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
get disabledAttr() { get disabledAttr() {
return this.disabled || null; return this.disabled || null;
} }
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() @Input()
get disabled() { get disabled() {
return this._disabled ?? this.ngControl?.disabled ?? false; return this._disabled ?? this.ngControl?.disabled ?? false;
@@ -100,8 +108,7 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
/**Implemented as part of NG_VALUE_ACCESSOR */ /**Implemented as part of NG_VALUE_ACCESSOR */
writeValue(obj: T): void { writeValue(obj: T): void {
this.selectedValue = obj; this.selectedValue.set(obj);
this._selectedOption = this.findSelectedOption(this.items, this.selectedValue);
} }
/**Implemented as part of NG_VALUE_ACCESSOR */ /**Implemented as part of NG_VALUE_ACCESSOR */
@@ -121,6 +128,8 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
/**Implemented as part of NG_VALUE_ACCESSOR */ /**Implemented as part of NG_VALUE_ACCESSOR */
protected onChange(option: Option<T> | null) { protected onChange(option: Option<T> | null) {
this.selectedValue.set(option?.value);
if (!this.notifyOnChange) { if (!this.notifyOnChange) {
return; return;
} }
@@ -154,9 +163,11 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
} }
/**Implemented as part of BitFormFieldControl */ /**Implemented as part of BitFormFieldControl */
@HostBinding() @Input() id = `bit-multi-select-${nextId++}`; readonly id = input(`bit-multi-select-${nextId++}`);
/**Implemented as part of BitFormFieldControl */ /**Implemented as part of BitFormFieldControl */
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@HostBinding("attr.required") @HostBinding("attr.required")
@Input() @Input()
get required() { get required() {
@@ -178,8 +189,8 @@ export class SelectComponent<T> implements BitFormFieldControl, ControlValueAcce
return [key, this.ngControl?.errors[key]]; return [key, this.ngControl?.errors[key]];
} }
private findSelectedOption(items: Option<T>[], value: T): Option<T> | undefined { private findSelectedOption(items: Option<T>[] | undefined, value: T): Option<T> | undefined {
return items.find((item) => item.value === value); return items?.find((item) => item.value === value);
} }
/**Emits the closed event. */ /**Emits the closed event. */

View File

@@ -0,0 +1,5 @@
import { Signal } from "@angular/core";
export type MappedDataToSignal<T> = {
[Property in keyof T]: Signal<T[Property]>;
};

View File

@@ -42,6 +42,8 @@ export class StepperComponent extends CdkStepper {
private initialOrientation: StepperOrientation | undefined = undefined; private initialOrientation: StepperOrientation | undefined = undefined;
// overriding CdkStepper orientation input so we can default to vertical // overriding CdkStepper orientation input so we can default to vertical
// TODO: Skipped for signal migration because:
// Accessor inputs cannot be migrated as they are too complex.
@Input() @Input()
override get orientation() { override get orientation() {
return this.internalOrientation || "vertical"; return this.internalOrientation || "vertical";

View File

@@ -1,13 +1,13 @@
import { Directive, HostBinding, Input } from "@angular/core"; import { Directive, HostBinding, input } from "@angular/core";
@Directive({ @Directive({
selector: "tr[bitRow]", selector: "tr[bitRow]",
}) })
export class RowDirective { export class RowDirective {
@Input() alignContent: "top" | "middle" | "bottom" | "baseline" = "middle"; readonly alignContent = input<"top" | "middle" | "bottom" | "baseline">("middle");
get alignmentClass(): string { get alignmentClass(): string {
switch (this.alignContent) { switch (this.alignContent()) {
case "top": case "top":
return "tw-align-top"; return "tw-align-top";
case "middle": case "middle":

Some files were not shown because too many files have changed in this diff Show More