1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[CL-905] Migrate CL/Badge to OnPush (#16959)

This commit is contained in:
Oscar Hinton
2025-11-12 21:27:14 +01:00
committed by GitHub
parent 7989ad7b7c
commit 828fdbd169
3 changed files with 97 additions and 61 deletions

View File

@@ -1,15 +1,15 @@
<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()) {
<span class="tw-sr-only">, </span> <span class="tw-sr-only">, </span>
} }
} }
@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,38 +1,60 @@
import { Component, OnChanges, input } from "@angular/core"; import { ChangeDetectionStrategy, Component, computed, input } 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) { function transformMaxItems(value: number | undefined) {
return value == undefined ? undefined : Math.max(1, value); return value == null ? undefined : Math.max(1, value);
} }
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush /**
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection * Displays a collection of badges in a horizontal, wrapping layout.
*
* The component automatically handles overflow by showing a limited number of badges
* followed by a "+N more" badge when `maxItems` is specified and exceeded.
*
* Each badge inherits the `variant` and `truncate` settings, ensuring visual consistency
* across the list. Badges are separated by commas for screen readers to improve accessibility.
*/
@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],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BadgeListComponent implements OnChanges { export class BadgeListComponent {
protected filteredItems: string[] = []; /**
protected isFiltered = false; * The visual variant to apply to all badges in the list.
*/
readonly variant = input<BadgeVariant>("primary"); readonly variant = input<BadgeVariant>("primary");
/**
* Items to display as badges.
*/
readonly items = input<string[]>([]); readonly items = input<string[]>([]);
/**
* Whether to truncate long badge text with ellipsis.
*/
readonly truncate = input(true); readonly truncate = input(true);
/**
* Maximum number of badges to display before showing a "+N more" badge.
*/
readonly maxItems = input(undefined, { transform: transformMaxItems }); readonly maxItems = input(undefined, { transform: transformMaxItems });
ngOnChanges() { protected readonly filteredItems = computed(() => {
const maxItems = this.maxItems(); const maxItems = this.maxItems();
const items = this.items();
if (maxItems == undefined || this.items().length <= maxItems) { if (maxItems == null || items.length <= maxItems) {
this.filteredItems = this.items(); return items;
} else {
this.filteredItems = this.items().slice(0, maxItems - 1);
}
this.isFiltered = this.items().length > this.filteredItems.length;
} }
return items.slice(0, maxItems - 1);
});
protected readonly isFiltered = computed(() => {
return this.items().length > this.filteredItems().length;
});
} }

View File

@@ -1,5 +1,12 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, ElementRef, HostBinding, input } from "@angular/core"; import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
input,
} from "@angular/core";
import { FocusableElement } from "../shared/focusable-element"; import { FocusableElement } from "../shared/focusable-element";
@@ -45,26 +52,55 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
}; };
/** /**
* Badges are primarily used as labels, counters, and small buttons. * Badges are primarily used as labels, counters, and small buttons.
* Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted. * Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted.
*
* The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag * The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
*
* > `NOTE:` The Focus and Hover states only apply to badges used for interactive events. * > `NOTE:` The Focus and Hover states only apply to badges used for interactive events.
* *
* > `NOTE:` The `disabled` state only applies to buttons. * > `NOTE:` The `disabled` state only applies to buttons.
*
*/ */
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "span[bitBadge], a[bitBadge], button[bitBadge]", selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
providers: [{ provide: FocusableElement, useExisting: BadgeComponent }], providers: [{ provide: FocusableElement, useExisting: BadgeComponent }],
imports: [CommonModule], imports: [CommonModule],
templateUrl: "badge.component.html", templateUrl: "badge.component.html",
host: {
"[class]": "classList()",
"[attr.title]": "titleAttr()",
},
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BadgeComponent implements FocusableElement { export class BadgeComponent implements FocusableElement {
@HostBinding("class") get classList() { private readonly el = inject(ElementRef<HTMLElement>);
private readonly hasHoverEffects = this.el.nativeElement.nodeName !== "SPAN";
/**
* Optional override for the automatic badge title attribute when truncating.
* When truncating is enabled and this is not provided, the badge will automatically
* use its text content as the title.
*/
readonly title = input<string>();
/**
* Visual variant that determines the badge's color scheme.
*/
readonly variant = input<BadgeVariant>("primary");
/**
* Whether to truncate long text with ellipsis when it exceeds maxWidthClass.
* When enabled, a title attribute is automatically added for accessibility.
*/
readonly truncate = input(true);
/**
* Tailwind max-width class to apply when truncating is enabled.
* Must be a valid Tailwind max-width utility class (e.g., "tw-max-w-40", "tw-max-w-xs").
*/
readonly maxWidthClass = input<`tw-max-w-${string}`>("tw-max-w-40");
protected readonly classList = computed(() => {
return [ return [
"tw-inline-block", "tw-inline-block",
"tw-py-1", "tw-py-1",
@@ -94,39 +130,17 @@ export class BadgeComponent implements FocusableElement {
.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() {
protected readonly titleAttr = computed(() => {
const title = this.title(); const title = this.title();
if (title !== undefined) { if (title !== undefined) {
return title; 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.
*/
readonly title = input<string>();
/**
* Variant, sets the background color of the badge.
*/
readonly variant = input<BadgeVariant>("primary");
/**
* Truncate long text
*/
readonly truncate = input(true);
readonly maxWidthClass = input<`tw-max-w-${string}`>("tw-max-w-40");
getFocusTarget() { getFocusTarget() {
return this.el.nativeElement; return this.el.nativeElement;
} }
private hasHoverEffects = false;
constructor(private el: ElementRef<HTMLElement>) {
this.hasHoverEffects = el?.nativeElement?.nodeName != "SPAN";
}
} }