mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +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 { Theme } from "@bitwarden/common/platform/enums";
|
||||||
|
|
||||||
import { border, themes, typography, spacing } from "../constants/styles";
|
import { border, themes, typography, spacing } from "../constants/styles";
|
||||||
|
import { Spinner } from "../icons";
|
||||||
|
|
||||||
export type ActionButtonProps = {
|
export type ActionButtonProps = {
|
||||||
buttonText: string | TemplateResult;
|
buttonText: string | TemplateResult;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
handleClick: (e: Event) => void;
|
handleClick: (e: Event) => void;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
@@ -16,40 +18,46 @@ export type ActionButtonProps = {
|
|||||||
export function ActionButton({
|
export function ActionButton({
|
||||||
buttonText,
|
buttonText,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
isLoading = false,
|
||||||
theme,
|
theme,
|
||||||
handleClick,
|
handleClick,
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
}: ActionButtonProps) {
|
}: ActionButtonProps) {
|
||||||
const handleButtonClick = (event: Event) => {
|
const handleButtonClick = (event: Event) => {
|
||||||
if (!disabled) {
|
if (!disabled && !isLoading) {
|
||||||
handleClick(event);
|
handleClick(event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class=${actionButtonStyles({ disabled, theme, fullWidth })}
|
class=${actionButtonStyles({ disabled, fullWidth, isLoading, theme })}
|
||||||
title=${buttonText}
|
title=${buttonText}
|
||||||
type="button"
|
type="button"
|
||||||
@click=${handleButtonClick}
|
@click=${handleButtonClick}
|
||||||
>
|
>
|
||||||
${buttonText}
|
${isLoading ? Spinner({ theme, color: themes[theme].text.muted }) : buttonText}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionButtonStyles = ({
|
const actionButtonStyles = ({
|
||||||
disabled,
|
disabled,
|
||||||
theme,
|
|
||||||
fullWidth,
|
fullWidth,
|
||||||
|
isLoading,
|
||||||
|
theme,
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
theme: Theme;
|
|
||||||
fullWidth: boolean;
|
fullWidth: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
theme: Theme;
|
||||||
}) => css`
|
}) => css`
|
||||||
${typography.body2}
|
${typography.body2}
|
||||||
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: ${border.radius.full};
|
border-radius: ${border.radius.full};
|
||||||
padding: ${spacing["1"]} ${spacing["3"]};
|
padding: ${spacing["1"]} ${spacing["3"]};
|
||||||
@@ -59,7 +67,7 @@ const actionButtonStyles = ({
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
${disabled
|
${disabled || isLoading
|
||||||
? `
|
? `
|
||||||
background-color: ${themes[theme].secondary["300"]};
|
background-color: ${themes[theme].secondary["300"]};
|
||||||
color: ${themes[theme].text.muted};
|
color: ${themes[theme].text.muted};
|
||||||
@@ -81,7 +89,8 @@ const actionButtonStyles = ({
|
|||||||
`}
|
`}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: fit-content;
|
padding: 2px 0; /* Match line-height of button body2 typography */
|
||||||
|
width: auto;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -174,6 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil
|
|||||||
${rule}: ${color};
|
${rule}: ${color};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const animations = {
|
||||||
|
spin: `
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(359deg);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
export function scrollbarStyles(theme: Theme, color?: { thumb?: string; track?: string }) {
|
export function scrollbarStyles(theme: Theme, color?: { thumb?: string; track?: string }) {
|
||||||
const thumbColor = color?.thumb || themes[theme].secondary["500"];
|
const thumbColor = color?.thumb || themes[theme].secondary["500"];
|
||||||
const trackColor = color?.track || themes[theme].background.alt;
|
const trackColor = color?.track || themes[theme].background.alt;
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export { Folder } from "./folder";
|
|||||||
export { Globe } from "./globe";
|
export { Globe } from "./globe";
|
||||||
export { PencilSquare } from "./pencil-square";
|
export { PencilSquare } from "./pencil-square";
|
||||||
export { Shield } from "./shield";
|
export { Shield } from "./shield";
|
||||||
|
export { Spinner } from "./spinner";
|
||||||
export { User } from "./user";
|
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 { Meta, StoryObj } from "@storybook/web-components";
|
||||||
|
import { html } from "lit";
|
||||||
|
|
||||||
import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
|
import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
|
||||||
|
|
||||||
import { ActionButton, ActionButtonProps } from "../../buttons/action-button";
|
import { ActionButton, ActionButtonProps } from "../../buttons/action-button";
|
||||||
|
|
||||||
|
type ComponentAndControls = ActionButtonProps & { width: number };
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Components/Buttons/Action Button",
|
title: "Components/Buttons/Action Button",
|
||||||
argTypes: {
|
argTypes: {
|
||||||
@@ -11,12 +14,15 @@ export default {
|
|||||||
disabled: { control: "boolean" },
|
disabled: { control: "boolean" },
|
||||||
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
|
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
|
||||||
handleClick: { control: false },
|
handleClick: { control: false },
|
||||||
|
width: { control: "number", min: 10, max: 100, step: 1 },
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
buttonText: "Click Me",
|
buttonText: "Click Me",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
isLoading: false,
|
||||||
theme: ThemeTypes.Light,
|
theme: ThemeTypes.Light,
|
||||||
handleClick: () => alert("Clicked"),
|
handleClick: () => alert("Clicked"),
|
||||||
|
width: 150,
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
design: {
|
design: {
|
||||||
@@ -24,10 +30,18 @@ export default {
|
|||||||
url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=487-14755&t=2O7uCAkwRZCcjumm-4",
|
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,
|
render: Template,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
|
|||||||
import { IconProps } from "../../common-types";
|
import { IconProps } from "../../common-types";
|
||||||
import * as Icons from "../../icons";
|
import * as Icons from "../../icons";
|
||||||
|
|
||||||
|
const { Spinner, ...StaticIcons } = Icons;
|
||||||
|
|
||||||
type Args = IconProps & {
|
type Args = IconProps & {
|
||||||
size: number;
|
size: number;
|
||||||
iconLink: URL;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -26,7 +27,10 @@ export default {
|
|||||||
},
|
},
|
||||||
} as Meta<Args>;
|
} 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
|
<div
|
||||||
style="width: ${args.size}px; height: ${args.size}px; display: flex; align-items: center; justify-content: center;"
|
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const createIconStory = (iconName: keyof typeof Icons): StoryObj<Args> => {
|
const createIconStory = (
|
||||||
|
iconName: keyof typeof StaticIcons,
|
||||||
|
): StoryObj<Args & { disableSpin?: boolean }> => {
|
||||||
const story = {
|
const story = {
|
||||||
render: (args) => Template(args, Icons[iconName]),
|
render: (args) => Template(args, StaticIcons[iconName]),
|
||||||
} as StoryObj<Args>;
|
} as StoryObj<Args>;
|
||||||
|
|
||||||
story.argTypes = {
|
|
||||||
iconLink: { table: { disable: true } },
|
|
||||||
};
|
|
||||||
|
|
||||||
return story;
|
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 AngleDownIcon = createIconStory("AngleDown");
|
||||||
export const AngleUpIcon = createIconStory("AngleUp");
|
export const AngleUpIcon = createIconStory("AngleUp");
|
||||||
export const BusinessIcon = createIconStory("Business");
|
export const BusinessIcon = createIconStory("Business");
|
||||||
@@ -58,4 +70,5 @@ export const FolderIcon = createIconStory("Folder");
|
|||||||
export const GlobeIcon = createIconStory("Globe");
|
export const GlobeIcon = createIconStory("Globe");
|
||||||
export const PencilSquareIcon = createIconStory("PencilSquare");
|
export const PencilSquareIcon = createIconStory("PencilSquare");
|
||||||
export const ShieldIcon = createIconStory("Shield");
|
export const ShieldIcon = createIconStory("Shield");
|
||||||
|
export const SpinnerIcon = SpinnerIconStory;
|
||||||
export const UserIcon = createIconStory("User");
|
export const UserIcon = createIconStory("User");
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type NotificationButtonRowProps = {
|
|||||||
organizations?: OrgView[];
|
organizations?: OrgView[];
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
text: string;
|
text: string;
|
||||||
|
isLoading?: boolean;
|
||||||
handlePrimaryButtonClick: (args: any) => void;
|
handlePrimaryButtonClick: (args: any) => void;
|
||||||
};
|
};
|
||||||
personalVaultIsAllowed: boolean;
|
personalVaultIsAllowed: boolean;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & {
|
|||||||
folders?: FolderView[];
|
folders?: FolderView[];
|
||||||
headerMessage?: string;
|
headerMessage?: string;
|
||||||
i18n: I18n;
|
i18n: I18n;
|
||||||
|
isLoading?: boolean;
|
||||||
organizations?: OrgView[];
|
organizations?: OrgView[];
|
||||||
personalVaultIsAllowed?: boolean;
|
personalVaultIsAllowed?: boolean;
|
||||||
notificationTestId: string;
|
notificationTestId: string;
|
||||||
@@ -44,6 +45,7 @@ export function NotificationContainer({
|
|||||||
folders,
|
folders,
|
||||||
headerMessage,
|
headerMessage,
|
||||||
i18n,
|
i18n,
|
||||||
|
isLoading,
|
||||||
organizations,
|
organizations,
|
||||||
personalVaultIsAllowed = true,
|
personalVaultIsAllowed = true,
|
||||||
notificationTestId,
|
notificationTestId,
|
||||||
@@ -74,6 +76,7 @@ export function NotificationContainer({
|
|||||||
collections,
|
collections,
|
||||||
folders,
|
folders,
|
||||||
i18n,
|
i18n,
|
||||||
|
isLoading,
|
||||||
notificationType: type,
|
notificationType: type,
|
||||||
organizations,
|
organizations,
|
||||||
personalVaultIsAllowed,
|
personalVaultIsAllowed,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type NotificationFooterProps = {
|
|||||||
collections?: CollectionView[];
|
collections?: CollectionView[];
|
||||||
folders?: FolderView[];
|
folders?: FolderView[];
|
||||||
i18n: I18n;
|
i18n: I18n;
|
||||||
|
isLoading?: boolean;
|
||||||
notificationType?: NotificationType;
|
notificationType?: NotificationType;
|
||||||
organizations?: OrgView[];
|
organizations?: OrgView[];
|
||||||
personalVaultIsAllowed: boolean;
|
personalVaultIsAllowed: boolean;
|
||||||
@@ -27,6 +28,7 @@ export function NotificationFooter({
|
|||||||
collections,
|
collections,
|
||||||
folders,
|
folders,
|
||||||
i18n,
|
i18n,
|
||||||
|
isLoading,
|
||||||
notificationType,
|
notificationType,
|
||||||
organizations,
|
organizations,
|
||||||
personalVaultIsAllowed,
|
personalVaultIsAllowed,
|
||||||
@@ -52,6 +54,7 @@ export function NotificationFooter({
|
|||||||
i18n,
|
i18n,
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
handlePrimaryButtonClick: handleSaveAction,
|
handlePrimaryButtonClick: handleSaveAction,
|
||||||
|
isLoading,
|
||||||
text: primaryButtonText,
|
text: primaryButtonText,
|
||||||
},
|
},
|
||||||
personalVaultIsAllowed,
|
personalVaultIsAllowed,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ButtonRowProps = {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
text: string;
|
text: string;
|
||||||
|
isLoading?: boolean;
|
||||||
handlePrimaryButtonClick: (args: any) => void;
|
handlePrimaryButtonClick: (args: any) => void;
|
||||||
};
|
};
|
||||||
selectButtons?: {
|
selectButtons?: {
|
||||||
@@ -29,6 +30,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
|
|||||||
${ActionButton({
|
${ActionButton({
|
||||||
handleClick: primaryButton.handlePrimaryButtonClick,
|
handleClick: primaryButton.handlePrimaryButtonClick,
|
||||||
buttonText: primaryButton.text,
|
buttonText: primaryButton.text,
|
||||||
|
isLoading: primaryButton.isLoading,
|
||||||
theme,
|
theme,
|
||||||
})}
|
})}
|
||||||
<div class=${optionSelectionsStyles}>
|
<div class=${optionSelectionsStyles}>
|
||||||
|
|||||||
@@ -249,25 +249,34 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
|
|||||||
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
|
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
|
||||||
|
|
||||||
if (isVaultLocked) {
|
if (isVaultLocked) {
|
||||||
return render(
|
const notificationConfig = {
|
||||||
NotificationContainer({
|
...notificationBarIframeInitData,
|
||||||
...notificationBarIframeInitData,
|
headerMessage,
|
||||||
headerMessage,
|
type: resolvedType,
|
||||||
type: resolvedType,
|
notificationTestId,
|
||||||
notificationTestId,
|
theme: resolvedTheme,
|
||||||
theme: resolvedTheme,
|
personalVaultIsAllowed: !personalVaultDisallowed,
|
||||||
personalVaultIsAllowed: !personalVaultDisallowed,
|
handleCloseNotification,
|
||||||
handleCloseNotification,
|
handleEditOrUpdateAction,
|
||||||
handleSaveAction: (e) => {
|
i18n,
|
||||||
sendSaveCipherMessage(true);
|
};
|
||||||
|
|
||||||
// @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
|
const handleSaveAction = () => {
|
||||||
},
|
sendSaveCipherMessage(true);
|
||||||
handleEditOrUpdateAction,
|
|
||||||
i18n,
|
render(
|
||||||
}),
|
NotificationContainer({
|
||||||
document.body,
|
...notificationConfig,
|
||||||
);
|
handleSaveAction: () => {},
|
||||||
|
isLoading: true,
|
||||||
|
}),
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction });
|
||||||
|
|
||||||
|
return render(UnlockNotification, document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle AtRiskPasswordNotification render
|
// Handle AtRiskPasswordNotification render
|
||||||
|
|||||||
Reference in New Issue
Block a user