1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-06 23:51:28 +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", "dist:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:safari",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "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; height: number;
}; };
export type FieldRect = {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
x: number;
y: number;
};
export type InlineMenuPosition = { export type InlineMenuPosition = {
button?: InlineMenuElementPosition; button?: InlineMenuElementPosition;
list?: InlineMenuElementPosition; list?: InlineMenuElementPosition;
@ -134,6 +145,7 @@ export type OverlayBackgroundExtensionMessage = {
isFieldCurrentlyFilling?: boolean; isFieldCurrentlyFilling?: boolean;
subFrameData?: SubFrameOffsetData; subFrameData?: SubFrameOffsetData;
focusedFieldData?: FocusedFieldData; focusedFieldData?: FocusedFieldData;
allFieldsRect?: any;
isOpeningFullInlineMenu?: boolean; isOpeningFullInlineMenu?: boolean;
styles?: Partial<CSSStyleDeclaration>; styles?: Partial<CSSStyleDeclaration>;
data?: LockedVaultPendingNotificationsData; 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", () => { describe("triggerDelayedAutofillInlineMenuClosure message handler", () => {
it("skips triggering the delayed closure of the inline menu if a field is currently focused", async () => { 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, generateDomainMatchPatterns,
generateRandomChars, generateRandomChars,
isInvalidResponseStatusCode, isInvalidResponseStatusCode,
rectHasSize,
specialCharacterToKeyMap, specialCharacterToKeyMap,
} from "../utils"; } from "../utils";
@ -130,6 +131,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
private currentInlineMenuCiphersCount: number = 0; private currentInlineMenuCiphersCount: number = 0;
private currentAddNewItemData: CurrentAddNewItemData; private currentAddNewItemData: CurrentAddNewItemData;
private focusedFieldData: FocusedFieldData; private focusedFieldData: FocusedFieldData;
private allFieldData: AutofillField[];
private isFieldCurrentlyFocused: boolean = false; private isFieldCurrentlyFocused: boolean = false;
private isFieldCurrentlyFilling: boolean = false; private isFieldCurrentlyFilling: boolean = false;
private isInlineMenuButtonVisible: boolean = false; private isInlineMenuButtonVisible: boolean = false;
@ -1367,6 +1369,71 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.isInlineMenuListVisible = false; 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 * Updates the position of either the inline menu list or button. The position
* is based on the focused field's position and dimensions. * 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 subFrameTopOffset = subFrameOffsets?.top || 0;
const subFrameLeftOffset = subFrameOffsets?.left || 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; 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; let elementOffset = height * 0.37;
if (height >= 35) { if (height >= 35) {
elementOffset = height >= 50 ? height * 0.47 : height * 0.42; elementOffset = height >= 50 ? height * 0.47 : height * 0.42;
@ -1512,7 +1588,16 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const subFrameTopOffset = subFrameOffsets?.top || 0; const subFrameTopOffset = subFrameOffsets?.top || 0;
const subFrameLeftOffset = subFrameOffsets?.left || 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 = { this.inlineMenuPosition.list = {
top: Math.round(top + height + subFrameTopOffset), top: Math.round(top + height + subFrameTopOffset),
@ -1535,7 +1620,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* @param sender - The sender of the extension message * @param sender - The sender of the extension message
*/ */
private setFocusedFieldData( private setFocusedFieldData(
{ focusedFieldData }: OverlayBackgroundExtensionMessage, { focusedFieldData, allFieldsRect }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
) { ) {
if ( if (
@ -1552,6 +1637,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const previousFocusedFieldData = this.focusedFieldData; const previousFocusedFieldData = this.focusedFieldData;
this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId }; this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId };
this.allFieldData = allFieldsRect;
this.isFieldCurrentlyFocused = true; this.isFieldCurrentlyFocused = true;
if (this.shouldUpdatePasswordGeneratorMenuOnFieldFocus()) { 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 // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { AutofillFieldQualifierType } from "../enums/autofill-field.enums"; import { AutofillFieldQualifierType } from "../enums/autofill-field.enums";
@ -124,4 +125,9 @@ export default class AutofillField {
fieldQualifier?: AutofillFieldQualifierType; fieldQualifier?: AutofillFieldQualifierType;
accountCreationFieldType?: InlineMenuAccountCreationFieldTypes; accountCreationFieldType?: InlineMenuAccountCreationFieldTypes;
/**
* used for totp multiline calculations
*/
fieldRect?: FieldRect;
} }

View File

@ -957,8 +957,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
accountCreationFieldType: autofillFieldData?.accountCreationFieldType, 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", { await this.sendExtensionMessage("updateFocusedFieldData", {
focusedFieldData: this.focusedFieldData, focusedFieldData: this.focusedFieldData,
allFieldsRect,
}); });
} }

View File

@ -1,5 +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 { FieldRect } from "../background/abstractions/overlay.background";
import { AutofillPort } from "../enums/autofill-port.enum"; import { AutofillPort } from "../enums/autofill-port.enum";
import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types";
@ -545,6 +546,17 @@ export const specialCharacterToKeyMap: Record<string, string> = {
"/": "forwardSlashCharacterDescriptor", "/": "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. * 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. * If no keys are specified, checks all keys in the object.