1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

[PM-21693] Unlock notification should have disabled/loading state while decrypting vault (#15128)

* add animated spinner icon

* add loading state to action button component

* render loading state on vault unlock
This commit is contained in:
Jonathan Prusik
2025-06-12 10:14:58 -04:00
committed by GitHub
parent 0e608639cc
commit 708737f99f
11 changed files with 136 additions and 36 deletions

View File

@@ -4,10 +4,12 @@ import { html, TemplateResult } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { border, themes, typography, spacing } from "../constants/styles";
import { Spinner } from "../icons";
export type ActionButtonProps = {
buttonText: string | TemplateResult;
disabled?: boolean;
isLoading?: boolean;
theme: Theme;
handleClick: (e: Event) => void;
fullWidth?: boolean;
@@ -16,40 +18,46 @@ export type ActionButtonProps = {
export function ActionButton({
buttonText,
disabled = false,
isLoading = false,
theme,
handleClick,
fullWidth = true,
}: ActionButtonProps) {
const handleButtonClick = (event: Event) => {
if (!disabled) {
if (!disabled && !isLoading) {
handleClick(event);
}
};
return html`
<button
class=${actionButtonStyles({ disabled, theme, fullWidth })}
class=${actionButtonStyles({ disabled, fullWidth, isLoading, theme })}
title=${buttonText}
type="button"
@click=${handleButtonClick}
>
${buttonText}
${isLoading ? Spinner({ theme, color: themes[theme].text.muted }) : buttonText}
</button>
`;
}
const actionButtonStyles = ({
disabled,
theme,
fullWidth,
isLoading,
theme,
}: {
disabled: boolean;
theme: Theme;
fullWidth: boolean;
isLoading: boolean;
theme: Theme;
}) => css`
${typography.body2}
user-select: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: ${border.radius.full};
padding: ${spacing["1"]} ${spacing["3"]};
@@ -59,7 +67,7 @@ const actionButtonStyles = ({
text-overflow: ellipsis;
font-weight: 700;
${disabled
${disabled || isLoading
? `
background-color: ${themes[theme].secondary["300"]};
color: ${themes[theme].text.muted};
@@ -81,7 +89,8 @@ const actionButtonStyles = ({
`}
svg {
width: fit-content;
padding: 2px 0; /* Match line-height of button body2 typography */
width: auto;
height: 16px;
}
`;

View File

@@ -174,6 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil
${rule}: ${color};
`;
export const animations = {
spin: `
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
`,
};
export function scrollbarStyles(theme: Theme, color?: { thumb?: string; track?: string }) {
const thumbColor = color?.thumb || themes[theme].secondary["500"];
const trackColor = color?.track || themes[theme].background.alt;

View File

@@ -11,4 +11,5 @@ export { Folder } from "./folder";
export { Globe } from "./globe";
export { PencilSquare } from "./pencil-square";
export { Shield } from "./shield";
export { Spinner } from "./spinner";
export { User } from "./user";

View File

@@ -0,0 +1,34 @@
import { css, keyframes } from "@emotion/css";
import { html } from "lit";
import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes, animations } from "../constants/styles";
export function Spinner({
ariaHidden = true,
color,
disabled,
theme,
disableSpin = false,
}: IconProps & { disableSpin?: boolean }) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`
<svg
class=${disableSpin ? "" : animation}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
aria-hidden="${ariaHidden}"
>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M9.5 1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM14.5 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3ZM11.536 11.536a1.5 1.5 0 1 1 2.12 2.12 1.5 1.5 0 0 1-2.12-2.12ZM9.5 14.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM0 8a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0ZM4.464 13.657a1.5 1.5 0 1 1-2.12-2.121 1.5 1.5 0 0 1 2.12 2.12ZM2.343 2.343a1.5 1.5 0 1 1 2.121 2.121 1.5 1.5 0 0 1-2.12-2.12Z"
/>
</svg>
`;
}
const animation = css`
animation: ${keyframes(animations.spin)} 2s infinite linear;
`;

View File

@@ -1,9 +1,12 @@
import { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import { ActionButton, ActionButtonProps } from "../../buttons/action-button";
type ComponentAndControls = ActionButtonProps & { width: number };
export default {
title: "Components/Buttons/Action Button",
argTypes: {
@@ -11,12 +14,15 @@ export default {
disabled: { control: "boolean" },
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
handleClick: { control: false },
width: { control: "number", min: 10, max: 100, step: 1 },
},
args: {
buttonText: "Click Me",
disabled: false,
isLoading: false,
theme: ThemeTypes.Light,
handleClick: () => alert("Clicked"),
width: 150,
},
parameters: {
design: {
@@ -24,10 +30,18 @@ export default {
url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=487-14755&t=2O7uCAkwRZCcjumm-4",
},
},
} as Meta<ActionButtonProps>;
} as Meta<ComponentAndControls>;
const Template = (args: ActionButtonProps) => ActionButton({ ...args });
const Template = (args: ComponentAndControls) => {
const { width, ...componentProps } = args;
return html`<div style="width: ${width}px;">${ActionButton({ ...componentProps })}</div>`;
};
export const Default: StoryObj<ComponentAndControls> = {
args: {
isLoading: true,
theme: "dark",
},
export const Default: StoryObj<ActionButtonProps> = {
render: Template,
};

View File

@@ -6,9 +6,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import { IconProps } from "../../common-types";
import * as Icons from "../../icons";
const { Spinner, ...StaticIcons } = Icons;
type Args = IconProps & {
size: number;
iconLink: URL;
};
export default {
@@ -26,7 +27,10 @@ export default {
},
} as Meta<Args>;
const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType<typeof html>) => html`
const Template = (
args: Args,
IconComponent: (props: IconProps & { disableSpin?: boolean }) => ReturnType<typeof html>,
) => html`
<div
style="width: ${args.size}px; height: ${args.size}px; display: flex; align-items: center; justify-content: center;"
>
@@ -34,18 +38,26 @@ const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType<ty
</div>
`;
const createIconStory = (iconName: keyof typeof Icons): StoryObj<Args> => {
const createIconStory = (
iconName: keyof typeof StaticIcons,
): StoryObj<Args & { disableSpin?: boolean }> => {
const story = {
render: (args) => Template(args, Icons[iconName]),
render: (args) => Template(args, StaticIcons[iconName]),
} as StoryObj<Args>;
story.argTypes = {
iconLink: { table: { disable: true } },
};
return story;
};
const SpinnerIconStory: StoryObj<Args & { disableSpin: boolean }> = {
render: (args) => Template(args, Spinner),
argTypes: {
disableSpin: { control: "boolean" },
},
args: {
disableSpin: false,
},
};
export const AngleDownIcon = createIconStory("AngleDown");
export const AngleUpIcon = createIconStory("AngleUp");
export const BusinessIcon = createIconStory("Business");
@@ -58,4 +70,5 @@ export const FolderIcon = createIconStory("Folder");
export const GlobeIcon = createIconStory("Globe");
export const PencilSquareIcon = createIconStory("PencilSquare");
export const ShieldIcon = createIconStory("Shield");
export const SpinnerIcon = SpinnerIconStory;
export const UserIcon = createIconStory("User");

View File

@@ -34,6 +34,7 @@ export type NotificationButtonRowProps = {
organizations?: OrgView[];
primaryButton: {
text: string;
isLoading?: boolean;
handlePrimaryButtonClick: (args: any) => void;
};
personalVaultIsAllowed: boolean;

View File

@@ -29,6 +29,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & {
folders?: FolderView[];
headerMessage?: string;
i18n: I18n;
isLoading?: boolean;
organizations?: OrgView[];
personalVaultIsAllowed?: boolean;
notificationTestId: string;
@@ -44,6 +45,7 @@ export function NotificationContainer({
folders,
headerMessage,
i18n,
isLoading,
organizations,
personalVaultIsAllowed = true,
notificationTestId,
@@ -74,6 +76,7 @@ export function NotificationContainer({
collections,
folders,
i18n,
isLoading,
notificationType: type,
organizations,
personalVaultIsAllowed,

View File

@@ -16,6 +16,7 @@ export type NotificationFooterProps = {
collections?: CollectionView[];
folders?: FolderView[];
i18n: I18n;
isLoading?: boolean;
notificationType?: NotificationType;
organizations?: OrgView[];
personalVaultIsAllowed: boolean;
@@ -27,6 +28,7 @@ export function NotificationFooter({
collections,
folders,
i18n,
isLoading,
notificationType,
organizations,
personalVaultIsAllowed,
@@ -52,6 +54,7 @@ export function NotificationFooter({
i18n,
primaryButton: {
handlePrimaryButtonClick: handleSaveAction,
isLoading,
text: primaryButtonText,
},
personalVaultIsAllowed,

View File

@@ -12,6 +12,7 @@ export type ButtonRowProps = {
theme: Theme;
primaryButton: {
text: string;
isLoading?: boolean;
handlePrimaryButtonClick: (args: any) => void;
};
selectButtons?: {
@@ -29,6 +30,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
${ActionButton({
handleClick: primaryButton.handlePrimaryButtonClick,
buttonText: primaryButton.text,
isLoading: primaryButton.isLoading,
theme,
})}
<div class=${optionSelectionsStyles}>

View File

@@ -249,25 +249,34 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
if (isVaultLocked) {
return render(
NotificationContainer({
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
notificationTestId,
theme: resolvedTheme,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleSaveAction: (e) => {
sendSaveCipherMessage(true);
const notificationConfig = {
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
notificationTestId,
theme: resolvedTheme,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleEditOrUpdateAction,
i18n,
};
// @TODO can't close before vault has finished decrypting, but can't leave open during long decrypt because it looks like the experience has failed
},
handleEditOrUpdateAction,
i18n,
}),
document.body,
);
const handleSaveAction = () => {
sendSaveCipherMessage(true);
render(
NotificationContainer({
...notificationConfig,
handleSaveAction: () => {},
isLoading: true,
}),
document.body,
);
};
const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction });
return render(UnlockNotification, document.body);
}
// Handle AtRiskPasswordNotification render