diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ecea2deb9e..39f95f0990 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3323,16 +3323,6 @@ "clearFiltersOrTryAnother": { "message": "Clear filters or try another search term" }, - "copyInfoLabel": { - "message": "Copy info, $ITEMNAME$", - "description": "Aria label for a button that opens a menu with options to copy information from an item.", - "placeholders": { - "itemname": { - "content": "$1", - "example": "Secret Item" - } - } - }, "copyInfoTitle": { "message": "Copy info - $ITEMNAME$", "description": "Title for a button that opens a menu with options to copy information from an item.", @@ -3343,16 +3333,6 @@ } } }, - "copyNoteLabel": { - "message": "Copy Note, $ITEMNAME$", - "description": "Aria label for a button copies a note to the clipboard.", - "placeholders": { - "itemname": { - "content": "$1", - "example": "Secret Note Item" - } - } - }, "copyNoteTitle": { "message": "Copy Note - $ITEMNAME$", "description": "Title for a button copies a note to the clipboard.", @@ -3393,6 +3373,19 @@ } } }, + "autofillTitle": { + "message": "Auto-fill - $ITEMNAME$", + "description": "Title for a button that auto-fills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "noValuesToCopy": { + "message": "No values to copy" + }, "assignCollections": { "message": "Assign collections" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html index 08133c6b46..487168539b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -3,8 +3,10 @@ type="button" bitIconButton="bwi-clone" size="small" - [attr.aria-label]="'copyInfoLabel' | i18n: cipher.name" - [title]="'copyInfoTitle' | i18n: cipher.name" + [appA11yTitle]=" + hasLoginValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) + " + [disabled]="!hasLoginValues" [bitMenuTriggerFor]="loginOptions" > @@ -25,8 +27,10 @@ type="button" bitIconButton="bwi-clone" size="small" - [attr.aria-label]="'copyInfoLabel' | i18n: cipher.name" - [title]="'copyInfoTitle' | i18n: cipher.name" + [appA11yTitle]=" + hasCardValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) + " + [disabled]="!hasCardValues" [bitMenuTriggerFor]="cardOptions" > @@ -44,8 +48,10 @@ type="button" bitIconButton="bwi-clone" size="small" - [attr.aria-label]="'copyInfoLabel' | i18n: cipher.name" - [title]="'copyInfoTitle' | i18n: cipher.name" + [appA11yTitle]=" + hasIdentityValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) + " + [disabled]="!hasIdentityValues" [bitMenuTriggerFor]="identityOptions" > @@ -69,8 +75,9 @@ type="button" bitIconButton="bwi-clone" size="small" - [attr.aria-label]="'copyNoteLabel' | i18n: cipher.name" - [title]="'copyNoteTitle' | i18n: cipher.name" + [appA11yTitle]=" + hasSecureNoteValue ? ('copyNoteTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) + " appCopyField="secureNote" [cipher]="cipher" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts index c89fcca3b3..a53c4a7c35 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -25,5 +25,28 @@ export class ItemCopyActionsComponent { protected CipherType = CipherType; + get hasLoginValues() { + return ( + !!this.cipher.login.hasTotp || !!this.cipher.login.password || !!this.cipher.login.username + ); + } + + get hasCardValues() { + return !!this.cipher.card.code || !!this.cipher.card.number; + } + + get hasIdentityValues() { + return ( + !!this.cipher.identity.fullAddressForCopy || + !!this.cipher.identity.email || + !!this.cipher.identity.username || + !!this.cipher.identity.phone + ); + } + + get hasSecureNoteValue() { + return !!this.cipher.notes; + } + constructor() {} } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 1d7a2a8cd0..ef451bd934 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -8,7 +8,7 @@ [bitMenuTriggerFor]="moreOptions" > - + + diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index f96bb095b9..c6d155c521 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable, NgZone } from "@angular/core"; import { BehaviorSubject, combineLatest, + concatMap, distinctUntilChanged, distinctUntilKeyChanged, from, @@ -176,7 +177,12 @@ export class VaultPopupItemsService { * Ciphers are sorted by name. */ remainingCiphers$: Observable = this.favoriteCiphers$.pipe( - withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$), + concatMap( + ( + favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$ + ) => + of(favoriteCiphers).pipe(withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$)), + ), map(([favoriteCiphers, ciphers, autoFillCiphers]) => ciphers.filter( (cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher), diff --git a/libs/components/src/badge/badge.directive.ts b/libs/components/src/badge/badge.directive.ts index ce41072706..55977f10f9 100644 --- a/libs/components/src/badge/badge.directive.ts +++ b/libs/components/src/badge/badge.directive.ts @@ -51,10 +51,18 @@ export class BadgeDirective implements FocusableElement { .concat(this.hasHoverEffects ? hoverStyles[this.variant] : []) .concat(this.truncate ? ["tw-truncate", this.maxWidthClass] : []); } - @HostBinding("attr.title") get title() { + @HostBinding("attr.title") get titleAttr() { + if (this.title !== undefined) { + return this.title; + } return this.truncate ? this.el.nativeElement.textContent.trim() : null; } + /** + * Optional override for the automatic badge title when truncating. + */ + @Input() title?: string; + /** * Variant, sets the background color of the badge. */ diff --git a/libs/components/src/menu/menu-item.directive.ts b/libs/components/src/menu/menu-item.directive.ts index 37289c9364..3f4b23e1cc 100644 --- a/libs/components/src/menu/menu-item.directive.ts +++ b/libs/components/src/menu/menu-item.directive.ts @@ -1,5 +1,6 @@ import { FocusableOption } from "@angular/cdk/a11y"; -import { Component, ElementRef, HostBinding } from "@angular/core"; +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { Component, ElementRef, HostBinding, Input } from "@angular/core"; @Component({ selector: "[bitMenuItem]", @@ -32,6 +33,11 @@ export class MenuItemDirective implements FocusableOption { ]; @HostBinding("attr.role") role = "menuitem"; @HostBinding("tabIndex") tabIndex = "-1"; + @HostBinding("attr.disabled") get disabledAttr() { + return this.disabled || null; // native disabled attr must be null when false + } + + @Input({ transform: coerceBooleanProperty }) disabled?: boolean = false; constructor(private elementRef: ElementRef) {} diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 2b79742c66..7d842c36bf 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -1,6 +1,7 @@ -import { Directive, HostBinding, HostListener, Input, OnChanges } from "@angular/core"; +import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { MenuItemDirective } from "@bitwarden/components"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; /** @@ -9,6 +10,8 @@ import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; * * Automatically disables the host element if the field to copy is not available or null. * + * If the host element is a menu item, it will be hidden when disabled. + * * @example * ```html * @@ -27,11 +30,23 @@ export class CopyCipherFieldDirective implements OnChanges { @Input({ required: true }) cipher: CipherView; - constructor(private copyCipherFieldService: CopyCipherFieldService) {} + constructor( + private copyCipherFieldService: CopyCipherFieldService, + @Optional() private menuItemDirective?: MenuItemDirective, + ) {} @HostBinding("attr.disabled") protected disabled: boolean | null = null; + /** + * Hide the element if it is disabled and is a menu item. + * @private + */ + @HostBinding("class.tw-hidden") + private get hidden() { + return this.disabled && this.menuItemDirective; + } + @HostListener("click") async copy() { const value = this.getValueToCopy(); @@ -49,6 +64,11 @@ export class CopyCipherFieldDirective implements OnChanges { (this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher))) ? true : null; + + // If the directive is used on a menu item, update the menu item to prevent keyboard navigation + if (this.menuItemDirective) { + this.menuItemDirective.disabled = this.disabled; + } } private getValueToCopy() {