1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-30 22:41:33 +01:00

[PM-14954] implement multi input totp styling (#12449)

* update menu and button position for multi-input totp

* update test to better handle breaking changes

* fix sizing bug by filtering duplicate opid fields

* update getTotpFields usage

* revert private changes per feedback

* Update apps/browser/src/autofill/utils/index.ts with positive fucntion

Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>

* fix type and update rectNotZero function

---------

Co-authored-by: Evan Bassler <evanbassler@EvanBasslersMBP.attlocal.net>
Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
Co-authored-by: Evan Bassler <evanbassler@Mac.attlocal.net>
This commit is contained in:
Evan Bassler 2025-01-13 14:18:42 -06:00 committed by GitHub
parent 83ee64ba1d
commit d471fe0418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 248 additions and 4 deletions

View File

@ -30,6 +30,7 @@
"dist:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:safari",
"test": "jest",
"test:watch": "jest --watch",
"test:watch:all": "jest --watchAll"
"test:watch:all": "jest --watchAll",
"test:clearCache": "jest --clear-cache"
}
}

View File

@ -57,6 +57,17 @@ export type InlineMenuElementPosition = {
height: number;
};
export type FieldRect = {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
x: number;
y: number;
};
export type InlineMenuPosition = {
button?: InlineMenuElementPosition;
list?: InlineMenuElementPosition;
@ -134,6 +145,7 @@ export type OverlayBackgroundExtensionMessage = {
isFieldCurrentlyFilling?: boolean;
subFrameData?: SubFrameOffsetData;
focusedFieldData?: FocusedFieldData;
allFieldsRect?: any;
isOpeningFullInlineMenu?: boolean;
styles?: Partial<CSSStyleDeclaration>;
data?: LockedVaultPendingNotificationsData;

View File

@ -2913,6 +2913,124 @@ describe("OverlayBackground", () => {
);
});
});
describe("handles menu position when input is focused", () => {
it("sets button and menu width and position when non-multi-input totp field is focused", async () => {
const subframe = {
top: 0,
left: 0,
url: "",
frameId: 0,
};
overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({
focusedFieldRects: {
width: 49.328125,
height: 64,
top: 302.171875,
left: 1270.8125,
},
});
const buttonPostion = overlayBackground["getInlineMenuButtonPosition"](subframe);
const menuPostion = overlayBackground["getInlineMenuListPosition"](subframe);
expect(menuPostion).toEqual({
width: "49px",
top: "366px",
left: "1271px",
});
expect(buttonPostion).toEqual({
width: "34px",
height: "34px",
top: "317px",
left: "1271px",
});
});
it("sets button and menu width and position when multi-input totp field is focused", async () => {
const subframe = {
top: 0,
left: 0,
url: "",
frameId: 0,
};
const totpFields = [
createAutofillFieldMock({ autoCompleteType: "one-time-code", opid: "__0" }),
createAutofillFieldMock({ autoCompleteType: "one-time-code", opid: "__1" }),
createAutofillFieldMock({ autoCompleteType: "one-time-code", opid: "__2" }),
];
const allFieldData = [
createAutofillFieldMock({
autoCompleteType: "one-time-code",
opid: "__0",
rect: {
x: 1041.5,
y: 302.171875,
width: 49.328125,
height: 64,
top: 302.171875,
right: 1090.828125,
bottom: 366.171875,
left: 1041.5,
},
}),
createAutofillFieldMock({
autoCompleteType: "one-time-code",
opid: "__1",
rect: {
x: 1098.828125,
y: 302.171875,
width: 49.328125,
height: 64,
top: 302.171875,
right: 1148.15625,
bottom: 366.171875,
left: 1098.828125,
},
}),
createAutofillFieldMock({
autoCompleteType: "one-time-code",
opid: "__2",
rect: {
x: 1156.15625,
y: 302.171875,
width: 249.328125,
height: 64,
top: 302.171875,
right: 2205.484375,
bottom: 366.171875,
left: 2156.15625,
},
}),
];
overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({
focusedFieldRects: {
width: 49.328125,
height: 64,
top: 302.171875,
left: 1270.8125,
},
});
overlayBackground["allFieldData"] = allFieldData;
jest.spyOn(overlayBackground as any, "isTotpFieldForCurrentField").mockReturnValue(true);
jest.spyOn(overlayBackground as any, "getTotpFields").mockReturnValue(totpFields);
const buttonPostion = overlayBackground["getInlineMenuButtonPosition"](subframe);
const menuPostion = overlayBackground["getInlineMenuListPosition"](subframe);
expect(menuPostion).toEqual({
width: "1164px",
top: "366px",
left: "1042px",
});
expect(buttonPostion).toEqual({
width: "34px",
height: "34px",
top: "292px",
left: "2187px",
});
});
});
describe("triggerDelayedAutofillInlineMenuClosure message handler", () => {
it("skips triggering the delayed closure of the inline menu if a field is currently focused", async () => {

View File

@ -70,6 +70,7 @@ import {
generateDomainMatchPatterns,
generateRandomChars,
isInvalidResponseStatusCode,
rectHasSize,
specialCharacterToKeyMap,
} from "../utils";
@ -130,6 +131,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
private currentInlineMenuCiphersCount: number = 0;
private currentAddNewItemData: CurrentAddNewItemData;
private focusedFieldData: FocusedFieldData;
private allFieldData: AutofillField[];
private isFieldCurrentlyFocused: boolean = false;
private isFieldCurrentlyFilling: boolean = false;
private isInlineMenuButtonVisible: boolean = false;
@ -1367,6 +1369,71 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.isInlineMenuListVisible = false;
}
/**
* Get all the totp fields for the tab and frame of the currently focused field
*/
private getTotpFields(): AutofillField[] {
const currentTabId = this.focusedFieldData?.tabId;
const currentFrameId = this.focusedFieldData?.frameId;
const pageDetailsMap = this.pageDetailsForTab[currentTabId];
const pageDetails = pageDetailsMap?.get(currentFrameId);
const fields = pageDetails.details.fields;
const totpFields = fields.filter((f) =>
this.inlineMenuFieldQualificationService.isTotpField(f),
);
return totpFields;
}
/**
* calculates the postion and width for multi-input totp field inline menu
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
*/
private calculateTotpMultiInputMenuBounds(totpFieldArray: AutofillField[]) {
// Filter the fields based on the provided totpfields
const filteredObjects = this.allFieldData.filter((obj) =>
totpFieldArray.some((o) => o.opid === obj.opid),
);
// Return null if no matching objects are found
if (filteredObjects.length === 0) {
return null;
}
// Calculate the smallest left and largest right values to determine width
const left = Math.min(
...filteredObjects.filter((obj) => rectHasSize(obj.rect)).map((obj) => obj.rect.left),
);
const largestRight = Math.max(
...filteredObjects.filter((obj) => rectHasSize(obj.rect)).map((obj) => obj.rect.right),
);
const width = largestRight - left;
return { left, width };
}
/**
* calculates the postion for multi-input totp field inline button
* @param totpFieldArray - the totp fields used to evaluate the position of the menu
*/
private calculateTotpMultiInputButtonBounds(totpFieldArray: AutofillField[]) {
const filteredObjects = this.allFieldData.filter((obj) =>
totpFieldArray.some((o) => o.opid === obj.opid),
);
if (filteredObjects.length === 0) {
return null;
}
const maxRight = Math.max(...filteredObjects.map((obj) => obj.rect.right));
const maxObject = filteredObjects.find((obj) => obj.rect.right === maxRight);
const top = maxObject.rect.top - maxObject.rect.height * 0.39;
const left = maxRight - maxObject.rect.height * 0.3;
return { left, top };
}
/**
* Updates the position of either the inline menu list or button. The position
* is based on the focused field's position and dimensions.
@ -1472,8 +1539,17 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const subFrameTopOffset = subFrameOffsets?.top || 0;
const subFrameLeftOffset = subFrameOffsets?.left || 0;
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
const { width, height } = this.focusedFieldData.focusedFieldRects;
let { top, left } = this.focusedFieldData.focusedFieldRects;
const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles;
if (this.isTotpFieldForCurrentField()) {
const totpFields = this.getTotpFields();
if (totpFields.length > 1) {
({ left, top } = this.calculateTotpMultiInputButtonBounds(totpFields));
}
}
let elementOffset = height * 0.37;
if (height >= 35) {
elementOffset = height >= 50 ? height * 0.47 : height * 0.42;
@ -1512,7 +1588,16 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const subFrameTopOffset = subFrameOffsets?.top || 0;
const subFrameLeftOffset = subFrameOffsets?.left || 0;
const { top, left, width, height } = this.focusedFieldData.focusedFieldRects;
const { top, height } = this.focusedFieldData.focusedFieldRects;
let { left, width } = this.focusedFieldData.focusedFieldRects;
if (this.isTotpFieldForCurrentField()) {
const totpFields = this.getTotpFields();
if (totpFields.length > 1) {
({ left, width } = this.calculateTotpMultiInputMenuBounds(totpFields));
}
}
this.inlineMenuPosition.list = {
top: Math.round(top + height + subFrameTopOffset),
@ -1535,7 +1620,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* @param sender - The sender of the extension message
*/
private setFocusedFieldData(
{ focusedFieldData }: OverlayBackgroundExtensionMessage,
{ focusedFieldData, allFieldsRect }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
if (
@ -1552,6 +1637,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const previousFocusedFieldData = this.focusedFieldData;
this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId };
this.allFieldData = allFieldsRect;
this.isFieldCurrentlyFocused = true;
if (this.shouldUpdatePasswordGeneratorMenuOnFieldFocus()) {

View File

@ -1,3 +1,4 @@
import { FieldRect } from "../background/abstractions/overlay.background";
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AutofillFieldQualifierType } from "../enums/autofill-field.enums";
@ -124,4 +125,9 @@ export default class AutofillField {
fieldQualifier?: AutofillFieldQualifierType;
accountCreationFieldType?: InlineMenuAccountCreationFieldTypes;
/**
* used for totp multiline calculations
*/
fieldRect?: FieldRect;
}

View File

@ -957,8 +957,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
accountCreationFieldType: autofillFieldData?.accountCreationFieldType,
};
const allFields = this.formFieldElements;
const allFieldsRect = [];
for (const key of allFields.keys()) {
const rect = await this.getMostRecentlyFocusedFieldRects(key);
allFieldsRect.push({ ...allFields.get(key), rect }); // Add the combined result to the array
}
await this.sendExtensionMessage("updateFocusedFieldData", {
focusedFieldData: this.focusedFieldData,
allFieldsRect,
});
}

View File

@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FieldRect } from "../background/abstractions/overlay.background";
import { AutofillPort } from "../enums/autofill-port.enum";
import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types";
@ -545,6 +546,17 @@ export const specialCharacterToKeyMap: Record<string, string> = {
"/": "forwardSlashCharacterDescriptor",
};
/**
* Determines if the current rect values are not all 0.
*/
export function rectHasSize(rect: FieldRect): boolean {
if (rect.right > 0 && rect.left > 0 && rect.top > 0 && rect.bottom > 0) {
return true;
}
return false;
}
/**
* Checks if all the values corresponding to the specified keys in an object are null.
* If no keys are specified, checks all keys in the object.