diff --git a/applications/pass-extension/manifest-chrome.json b/applications/pass-extension/manifest-chrome.json index f79bda556ff..97673958046 100644 --- a/applications/pass-extension/manifest-chrome.json +++ b/applications/pass-extension/manifest-chrome.json @@ -77,6 +77,12 @@ "default": "Ctrl+Shift+L" }, "description": "Open Proton Pass in a larger window" + }, + "autofill": { + "suggested_key": { + "default": "Ctrl+Shift+U" + }, + "description": "Autofill login credentials" } }, "icons": { diff --git a/applications/pass-extension/manifest-firefox.json b/applications/pass-extension/manifest-firefox.json index f7a66ac42c3..3b23d2e3761 100644 --- a/applications/pass-extension/manifest-firefox.json +++ b/applications/pass-extension/manifest-firefox.json @@ -76,6 +76,12 @@ "default": "Ctrl+Shift+L" }, "description": "Open Proton Pass in a larger window" + }, + "autofill": { + "suggested_key": { + "default": "Ctrl+Shift+U" + }, + "description": "Autofill login credentials" } }, "icons": { diff --git a/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts b/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts index f7633c7db93..efea1492876 100644 --- a/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts +++ b/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts @@ -21,6 +21,7 @@ import { first } from '@proton/pass/utils/array/first'; import { truthy } from '@proton/pass/utils/fp/predicates'; import { asyncLock } from '@proton/pass/utils/fp/promises'; import { safeCall } from '@proton/pass/utils/fp/safe-call'; +import { waitUntil } from '@proton/pass/utils/fp/wait-until'; import { serialize } from '@proton/pass/utils/object/serialize'; import { uniqueId } from '@proton/pass/utils/string/unique-id'; import { getEpoch } from '@proton/pass/utils/time/epoch'; @@ -77,6 +78,10 @@ const autofillCounter = (key: keyof AutofillCounters, state: AutofillCounters) = * preventing race conditions where focus-to-next-field logic interferes with autofill. */ const AUTOFILL_LOCK_TIME = BUILD_TARGET === 'safari' ? 250 : 50; +/** Maximum time to wait for the dropdown to become visible before moving keyboard + * focus into it when triggered via the autofill shortcut. */ +const DROPDOWN_AUTOFOCUS_TIMEOUT = 1_000; + export const createAutofillService = ({ controller }: ContentScriptContextFactoryOptions) => { const state: AutofillState = { processing: false }; @@ -372,7 +377,33 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor } ); + const onAutofillTrigger: FrameMessageHandler = withContext(async (ctx) => { + const dropdown = ctx?.service.inline.dropdown; + const fields = ctx?.service.formManager.getFields(); + const loginField = fields?.find((field) => field.action?.type === DropdownAction.AUTOFILL_LOGIN); + + if (!dropdown || !loginField) return; + + dropdown.toggle({ + type: 'field', + action: DropdownAction.AUTOFILL_LOGIN, + autofocused: false, + autofilled: loginField.autofilled !== null, + field: loginField, + }); + + /** Keyboard-only flow: once the dropdown is visible, move keyboard focus into it + * so the user can navigate the login suggestions with the arrow keys and select + * one with Enter — without touching the mouse. Unlike the focus-on-field flow, + * the shortcut opens the dropdown with `autofocused: false`, so nothing has moved + * focus into the iframe yet. */ + await waitUntil(() => dropdown.getState().then(({ visible }) => visible), 25, DROPDOWN_AUTOFOCUS_TIMEOUT) + .then(() => dropdown.requestFocus()) + .catch(noop); + }); + controller.channel.register(WorkerMessageType.AUTOFILL_SEQUENCE, onAutofillRequest); + controller.channel.register(WorkerMessageType.AUTOFILL_TRIGGER, onAutofillTrigger); return { get processing() { @@ -391,6 +422,7 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor sync, destroy: () => { controller.channel.unregister(WorkerMessageType.AUTOFILL_SEQUENCE, onAutofillRequest); + controller.channel.unregister(WorkerMessageType.AUTOFILL_TRIGGER, onAutofillTrigger); }, }; }; diff --git a/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx b/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx index 206f8cbe540..1d7eaaff50d 100644 --- a/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx +++ b/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useEffect, useMemo } from 'react'; +import { type FC, useCallback, useEffect, useMemo, useRef } from 'react'; import type { DropdownAction } from 'proton-pass-extension/app/content/constants.runtime'; import { DropdownHeader } from 'proton-pass-extension/app/content/services/inline/dropdown/app/components/DropdownHeader'; @@ -19,6 +19,9 @@ import { c } from 'ttag'; import { CircleLoader } from '@proton/atoms/CircleLoader/CircleLoader'; import Marks from '@proton/components/components/text/Marks'; +import useDropdownArrowNavigation from '@proton/components/hooks/useDropdownArrowNavigation'; +import type { HotkeyTuple } from '@proton/components/hooks/useHotkeys'; +import { useHotkeys } from '@proton/components/hooks/useHotkeys'; import { usePassCore } from '@proton/pass/components/Core/PassCoreProvider'; import { UpsellRef } from '@proton/pass/constants'; import { useMountedState } from '@proton/pass/hooks/useEnsureMounted'; @@ -148,6 +151,26 @@ export const AutofillLogin: FC = ({ startsWith, action, ...payload }) => [state, filter] ); + /** Keyboard navigation over the suggestions. When the dropdown is opened via the autofill + * shortcut, focus is moved into the iframe, so the listener is bound to the iframe `document` + * (focus lands on the body, outside `rootRef`). Arrow keys move focus across the rows inside + * `rootRef` (the header is excluded; the upgrade and empty-state rows stay reachable); Enter + * activates the focused item natively (each is a `