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:
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -34,6 +34,7 @@ export type NotificationButtonRowProps = {
|
||||
organizations?: OrgView[];
|
||||
primaryButton: {
|
||||
text: string;
|
||||
isLoading?: boolean;
|
||||
handlePrimaryButtonClick: (args: any) => void;
|
||||
};
|
||||
personalVaultIsAllowed: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user