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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user