diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index a9b4742b44..f0642d4233 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -6,27 +6,27 @@ import { Theme } from "@bitwarden/common/platform/enums"; import { border, themes, typography, spacing } from "../constants/styles"; export function ActionButton({ - buttonAction, buttonText, disabled = false, theme, + handleClick, }: { - buttonAction: (e: Event) => void; buttonText: string; disabled?: boolean; theme: Theme; + handleClick: (e: Event) => void; }) { const handleButtonClick = (event: Event) => { if (!disabled) { - buttonAction(event); + handleClick(event); } }; return html` + `; +} + +const iconSize = "15px"; + +const selectionButtonStyles = ({ + disabled, + toggledOn, + theme, +}: { + disabled: boolean; + toggledOn: boolean; + theme: Theme; +}) => css` + ${typography.body2} + + gap: ${spacing["1.5"]}; + user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + columns: ${iconSize} max-content ${iconSize}; + border-radius: ${border.radius.full}; + padding: ${spacing["1"]} ${spacing["2"]}; + max-height: fit-content; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + font-weight: 400; + + ${disabled + ? ` + border: 1px solid ${themes[theme].secondary["300"]}; + background-color: ${themes[theme].secondary["300"]}; + cursor: not-allowed; + color: ${themes[theme].text.muted}; + ` + : ` + border: 1px solid ${themes[theme].text.muted}; + background-color: ${toggledOn ? themes[theme].secondary["100"] : "transparent"}; + cursor: pointer; + color: ${themes[theme].text.muted}; + + :hover { + border-color: ${themes[theme].secondary["700"]}; + background-color: ${themes[theme].secondary["100"]}; + } + `} + + > svg { + max-width: ${iconSize}; + height: fit-content; + } +`; + +const dropdownButtonTextStyles = css` + overflow-x: hidden; + text-overflow: ellipsis; +`; diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts index 38b4292f8e..39d4dd28f2 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts @@ -9,17 +9,17 @@ import { Business, Family } from "../../../content/components/icons"; // @TODO connect data source to icon checks // @TODO support other indicator types (attachments, etc) export function CipherInfoIndicatorIcons({ - isBusinessOrg, - isFamilyOrg, + showBusinessIcon, + showFamilyIcon, theme, }: { - isBusinessOrg?: boolean; - isFamilyOrg?: boolean; + showBusinessIcon?: boolean; + showFamilyIcon?: boolean; theme: Theme; }) { const indicatorIcons = [ - ...(isBusinessOrg ? [Business({ color: themes[theme].text.muted, theme })] : []), - ...(isFamilyOrg ? [Family({ color: themes[theme].text.muted, theme })] : []), + ...(showBusinessIcon ? [Business({ color: themes[theme].text.muted, theme })] : []), + ...(showFamilyIcon ? [Family({ color: themes[theme].text.muted, theme })] : []), ]; return indicatorIcons.length diff --git a/apps/browser/src/autofill/content/components/common-types.ts b/apps/browser/src/autofill/content/components/common-types.ts new file mode 100644 index 0000000000..df11e140d7 --- /dev/null +++ b/apps/browser/src/autofill/content/components/common-types.ts @@ -0,0 +1,28 @@ +import { TemplateResult } from "lit"; + +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { Theme } from "@bitwarden/common/platform/enums"; + +export type IconProps = { + color?: string; + disabled?: boolean; + theme: Theme; +}; + +export type Option = { + default?: boolean; + icon?: (props: IconProps) => TemplateResult; + text?: string; + value: any; +}; + +export type FolderView = { + id: string; + name: string; +}; + +export type OrgView = { + id: string; + name: string; + productTierType?: ProductTierType; +}; diff --git a/apps/browser/src/autofill/content/components/constants/styles.ts b/apps/browser/src/autofill/content/components/constants/styles.ts index cdf8f1ead5..f7c9ffd4d9 100644 --- a/apps/browser/src/autofill/content/components/constants/styles.ts +++ b/apps/browser/src/autofill/content/components/constants/styles.ts @@ -174,14 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil ${rule}: ${color}; `; -export function scrollbarStyles(theme: Theme) { +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; + return { + /* FireFox & Chrome support */ default: ` - /* FireFox & Chrome support */ - scrollbar-color: ${themes[theme].secondary["500"]} ${themes[theme].background.alt}; + scrollbar-color: ${thumbColor} ${trackColor}; `, + /* Safari Support */ safari: ` - /* Safari Support */ ::-webkit-scrollbar { overflow: auto; } @@ -191,10 +194,10 @@ export function scrollbarStyles(theme: Theme) { border-radius: 0.5rem; border-color: transparent; background-clip: content-box; - background-color: ${themes[theme].secondary["500"]}; + background-color: ${thumbColor}; } ::-webkit-scrollbar-track { - ${themes[theme].background.alt}; + ${trackColor}; } ::-webkit-scrollbar-thumb:hover { ${themes[theme].secondary["600"]}; diff --git a/apps/browser/src/autofill/content/components/dropdown-menu.ts b/apps/browser/src/autofill/content/components/dropdown-menu.ts deleted file mode 100644 index 3e3874b37d..0000000000 --- a/apps/browser/src/autofill/content/components/dropdown-menu.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { css } from "@emotion/css"; -import { html, TemplateResult } from "lit"; - -import { Theme } from "@bitwarden/common/platform/enums"; - -import { border, themes, typography, spacing } from "./constants/styles"; -import { AngleDown } from "./icons"; - -export function DropdownMenu({ - buttonText, - icon, - disabled = false, - selectAction, - theme, -}: { - selectAction?: (e: Event) => void; - buttonText: string; - icon?: TemplateResult; - disabled?: boolean; - theme: Theme; -}) { - // @TODO placeholder/will not work; make stateful - const showDropdown = false; - const handleButtonClick = (event: Event) => { - // if (!disabled) { - // // show dropdown - // showDropdown = !showDropdown; - // this.requestUpdate(); - // } - }; - - const dropdownMenuItems: TemplateResult[] = []; - - return html` -
- - ${showDropdown - ? html`
${dropdownMenuItems}
` - : null} -
- `; -} - -const iconSize = "15px"; - -const dropdownContainerStyles = css` - display: flex; - - > div, - > button { - width: 100%; - } -`; - -const dropdownButtonStyles = ({ disabled, theme }: { disabled: boolean; theme: Theme }) => css` - ${typography.body2} - - font-weight: 400; - gap: ${spacing["1.5"]}; - user-select: none; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - justify-content: space-between; - border-radius: ${border.radius.full}; - padding: ${spacing["1"]} ${spacing["2"]}; - max-height: fit-content; - overflow: hidden; - text-align: center; - text-overflow: ellipsis; - - > svg { - max-width: ${iconSize}; - height: fit-content; - } - - ${disabled - ? ` - border: 1px solid ${themes[theme].secondary["300"]}; - background-color: ${themes[theme].secondary["300"]}; - color: ${themes[theme].text.muted}; - ` - : ` - border: 1px solid ${themes[theme].text.muted}; - background-color: transparent; - cursor: pointer; - color: ${themes[theme].text.muted}; - - :hover { - border-color: ${themes[theme].secondary["700"]}; - background-color: ${themes[theme].secondary["100"]}; - } - `} -`; - -const dropdownButtonTextStyles = css` - max-width: calc(100% - ${iconSize} - ${iconSize}); - overflow-x: hidden; - text-overflow: ellipsis; -`; - -const dropdownMenuStyles = ({ theme }: { theme: Theme }) => css` - color: ${themes[theme].text.main}; - border: 1px solid ${themes[theme].secondary["500"]}; - border-radius: 0.5rem; - background-clip: padding-box; - background-color: ${themes[theme].background.DEFAULT}; - padding: 0.25rem 0.75rem; - position: absolute; - overflow-y: auto; -`; diff --git a/apps/browser/src/autofill/content/components/icons/angle-down.ts b/apps/browser/src/autofill/content/components/icons/angle-down.ts index 4b85319c18..db5275aafa 100644 --- a/apps/browser/src/autofill/content/components/icons/angle-down.ts +++ b/apps/browser/src/autofill/content/components/icons/angle-down.ts @@ -1,19 +1,10 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function AngleDown({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function AngleDown({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` diff --git a/apps/browser/src/autofill/content/components/icons/angle-up.ts b/apps/browser/src/autofill/content/components/icons/angle-up.ts new file mode 100644 index 0000000000..7344123d5a --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/angle-up.ts @@ -0,0 +1,23 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { IconProps } from "../common-types"; +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function AngleUp({ color, disabled, theme }: IconProps) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/business.ts b/apps/browser/src/autofill/content/components/icons/business.ts index 547ee82b54..ef8e082c21 100644 --- a/apps/browser/src/autofill/content/components/icons/business.ts +++ b/apps/browser/src/autofill/content/components/icons/business.ts @@ -1,19 +1,10 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function Business({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function Business({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` diff --git a/apps/browser/src/autofill/content/components/icons/close.ts b/apps/browser/src/autofill/content/components/icons/close.ts index c94a4b20a6..c9d9286ca3 100644 --- a/apps/browser/src/autofill/content/components/icons/close.ts +++ b/apps/browser/src/autofill/content/components/icons/close.ts @@ -1,19 +1,10 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function Close({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function Close({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` diff --git a/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts b/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts index bcc7b3d543..d87d5621e3 100644 --- a/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts +++ b/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts @@ -1,19 +1,10 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function ExclamationTriangle({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function ExclamationTriangle({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` diff --git a/apps/browser/src/autofill/content/components/icons/family.ts b/apps/browser/src/autofill/content/components/icons/family.ts index 33e2e422ce..9870c5d37c 100644 --- a/apps/browser/src/autofill/content/components/icons/family.ts +++ b/apps/browser/src/autofill/content/components/icons/family.ts @@ -1,19 +1,10 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function Family({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function Family({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` diff --git a/apps/browser/src/autofill/content/components/icons/folder.ts b/apps/browser/src/autofill/content/components/icons/folder.ts index 7e1f8f197f..84577aef82 100644 --- a/apps/browser/src/autofill/content/components/icons/folder.ts +++ b/apps/browser/src/autofill/content/components/icons/folder.ts @@ -1,26 +1,17 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function Folder({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function Folder({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/globe.ts b/apps/browser/src/autofill/content/components/icons/globe.ts index 6697fa93b7..fc0a975284 100644 --- a/apps/browser/src/autofill/content/components/icons/globe.ts +++ b/apps/browser/src/autofill/content/components/icons/globe.ts @@ -1,19 +1,10 @@ import { css } from "@emotion/css"; import { html } from "lit"; -import { Theme } from "@bitwarden/common/platform/enums"; - +import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function Globe({ - color, - disabled, - theme, -}: { - color?: string; - disabled?: boolean; - theme: Theme; -}) { +export function Globe({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` diff --git a/apps/browser/src/autofill/content/components/icons/index.ts b/apps/browser/src/autofill/content/components/icons/index.ts index 6cc56e079d..c4769a0e69 100644 --- a/apps/browser/src/autofill/content/components/icons/index.ts +++ b/apps/browser/src/autofill/content/components/icons/index.ts @@ -1,4 +1,5 @@ export { AngleDown } from "./angle-down"; +export { AngleUp } from "./angle-up"; export { BrandIconContainer } from "./brand-icon-container"; export { Business } from "./business"; export { Close } from "./close"; diff --git a/apps/browser/src/autofill/content/components/icons/party-horn.ts b/apps/browser/src/autofill/content/components/icons/party-horn.ts index e807df1d86..439d60a79d 100644 --- a/apps/browser/src/autofill/content/components/icons/party-horn.ts +++ b/apps/browser/src/autofill/content/components/icons/party-horn.ts @@ -1,9 +1,11 @@ import { html } from "lit"; -import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { IconProps } from "../common-types"; // This icon has static multi-colors for each theme -export function PartyHorn({ theme }: { theme: Theme }) { +export function PartyHorn({ theme }: IconProps) { if (theme === ThemeTypes.Dark) { return html` + `; diff --git a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/cipher-indicator-icon.mdx b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/cipher-indicator-icon.mdx index 9bac07afe2..6c338276c0 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/cipher-indicator-icon.mdx +++ b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/cipher-indicator-icon.mdx @@ -15,11 +15,11 @@ dynamically based on the provided theme. ## Props -| **Prop** | **Type** | **Required** | **Description** | -| --------------- | --------- | ------------ | ----------------------------------------------------------------------- | -| `isBusinessOrg` | `boolean` | No | Displays the business organization icon when set to `true`. | -| `isFamilyOrg` | `boolean` | No | Displays the family organization icon when set to `true`. | -| `theme` | `Theme` | Yes | Defines the theme used to style the icons. Must match the `Theme` enum. | +| **Prop** | **Type** | **Required** | **Description** | +| ------------------ | --------- | ------------ | ----------------------------------------------------------------------- | +| `showBusinessIcon` | `boolean` | No | Displays the business organization icon when set to `true`. | +| `showFamilyIcon` | `boolean` | No | Displays the family organization icon when set to `true`. | +| `theme` | `Theme` | Yes | Defines the theme used to style the icons. Must match the `Theme` enum. | ## Installation and Setup @@ -29,8 +29,8 @@ dynamically based on the provided theme. - `@emotion/css`: Used for styling. 2. Pass the required props when using the component: - - `isBusinessOrg`: A boolean that, when `true`, displays the business icon. - - `isFamilyOrg`: A boolean that, when `true`, displays the family icon. + - `showBusinessIcon`: A boolean that, when `true`, displays the business icon. + - `showFamilyIcon`: A boolean that, when `true`, displays the family icon. - `theme`: Specifies the theme for styling the icons. ## Accessibility (WCAG) Compliance @@ -57,8 +57,8 @@ import { CipherInfoIndicatorIcons } from "./cipher-info-indicator-icons"; import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; ; ``` @@ -77,5 +77,5 @@ family organization icon. ### Notes -- If neither isBusinessOrg nor isFamilyOrg is set to true, the component renders nothing. This +- If neither showBusinessIcon nor showFamilyIcon is set to true, the component renders nothing. This behavior should be handled by the parent component. diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts index 4e0efd5955..2aa61c627b 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts @@ -8,7 +8,7 @@ type Args = { buttonText: string; disabled: boolean; theme: Theme; - buttonAction: (e: Event) => void; + handleClick: (e: Event) => void; }; export default { @@ -17,13 +17,13 @@ export default { buttonText: { control: "text" }, disabled: { control: "boolean" }, theme: { control: "select", options: [...Object.values(ThemeTypes)] }, - buttonAction: { control: false }, + handleClick: { control: false }, }, args: { buttonText: "Click Me", disabled: false, theme: ThemeTypes.Light, - buttonAction: () => alert("Clicked"), + handleClick: () => alert("Clicked"), }, parameters: { design: { diff --git a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts index 2d031fa3af..89c3ecbcb1 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts @@ -6,22 +6,22 @@ import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.e import { CipherInfoIndicatorIcons } from "../../cipher/cipher-indicator-icons"; type Args = { - isBusinessOrg?: boolean; - isFamilyOrg?: boolean; + showBusinessIcon?: boolean; + showFamilyIcon?: boolean; theme: Theme; }; export default { title: "Components/Ciphers/Cipher Indicator Icon", argTypes: { - isBusinessOrg: { control: "boolean" }, - isFamilyOrg: { control: "boolean" }, + showBusinessIcon: { control: "boolean" }, + showFamilyIcon: { control: "boolean" }, theme: { control: "select", options: [...Object.values(ThemeTypes)] }, }, args: { theme: ThemeTypes.Light, - isBusinessOrg: true, - isFamilyOrg: false, + showBusinessIcon: true, + showFamilyIcon: false, }, } as Meta; diff --git a/apps/browser/src/autofill/content/components/lit-stories/mock-data.ts b/apps/browser/src/autofill/content/components/lit-stories/mock-data.ts new file mode 100644 index 0000000000..024ac9e22b --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/mock-data.ts @@ -0,0 +1,68 @@ +import { ProductTierType } from "@bitwarden/common/billing/enums"; + +export const mockFolderData = [ + { + id: "unique-id1", + name: "A folder", + }, + { + id: "unique-id2", + name: "Another folder", + }, + { + id: "unique-id3", + name: "One more folder", + }, + { + id: "unique-id4", + name: "Definitely not a folder", + }, + { + id: "unique-id5", + name: "Yet another folder", + }, + { + id: "unique-id6", + name: "Something else entirely, with an essence being completely unfolder-like in all the unimportant ways and none of the important ones", + }, + { + id: "unique-id7", + name: 'A "folder"', + }, + { + id: "unique-id8", + name: "Two folders", + }, +]; + +export const mockOrganizationData = [ + { + id: "unique-id0", + name: "Another personal vault", + }, + { + id: "unique-id1", + name: "Acme, inc", + productTierType: ProductTierType.Teams, + }, + { + id: "unique-id2", + name: "A Really Long Business Name That Just Kinda Goes On For A Really Long Time", + productTierType: ProductTierType.TeamsStarter, + }, + { + id: "unique-id3", + name: "Family Vault", + productTierType: ProductTierType.Families, + }, + { + id: "unique-id4", + name: "Family Vault Trial", + productTierType: ProductTierType.Free, + }, + { + id: "unique-id5", + name: "Exciting Enterprises, LLC", + productTierType: ProductTierType.Enterprise, + }, +]; diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts index 5ad0a5a2ac..ea2bbdc2e1 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/notification/footer.lit-stories.ts @@ -1,33 +1,29 @@ import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; -import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; +import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; -import { NotificationType } from "../../../../notification/abstractions/notification-bar"; -import { NotificationFooter } from "../../notification/footer"; - -type Args = { - notificationType: NotificationType; - theme: Theme; - handleSaveAction: (e: Event) => void; - i18n: { [key: string]: string }; -}; +import { NotificationFooter, NotificationFooterProps } from "../../notification/footer"; +import { mockFolderData, mockOrganizationData } from "../mock-data"; export default { title: "Components/Notifications/Notification Footer", argTypes: { - theme: { control: "select", options: [...Object.values(ThemeTypes)] }, notificationType: { control: "select", options: ["add", "change", "unlock", "fileless-import"], }, + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, }, args: { - theme: ThemeTypes.Light, - notificationType: "add", + folders: mockFolderData, i18n: { - saveAsNewLoginAction: "Save as New Login", saveAction: "Save", + saveAsNewLoginAction: "Save as New Login", }, + notificationType: "add", + organizations: mockOrganizationData, + theme: ThemeTypes.Light, handleSaveAction: () => alert("Save action triggered"), }, parameters: { @@ -36,10 +32,11 @@ export default { url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=32-4949&m=dev", }, }, -} as Meta; +} as Meta; -const Template = (args: Args) => NotificationFooter({ ...args }); +const Template = (args: NotificationFooterProps) => + html`
${NotificationFooter({ ...args })}
`; -export const Default: StoryObj = { +export const Default: StoryObj = { render: Template, }; diff --git a/apps/browser/src/autofill/content/components/lit-stories/options-selection/option-selection.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/options-selection/option-selection.lit-stories.ts new file mode 100644 index 0000000000..2e8b165354 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/options-selection/option-selection.lit-stories.ts @@ -0,0 +1,81 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import { Option } from "../../common-types"; +import { themes } from "../../constants/styles"; +import { User, Business } from "../../icons"; +import "../../option-selection/option-selection"; +import { mockOrganizationData } from "../mock-data"; + +const mockOptions: Option[] = [ + { icon: User, text: "My Vault", value: "0" }, + ...mockOrganizationData.map(({ id, name }) => ({ icon: Business, text: name, value: id })), +]; + +type ComponentProps = { + disabled?: boolean; + label?: string; + options: Option[]; + theme: Theme; +}; + +export default { + title: "Components/Option Selection", + argTypes: { + disabled: { control: "boolean" }, + label: { control: "text" }, + options: { control: "object" }, + theme: { control: "select", options: [ThemeTypes.Light, ThemeTypes.Dark] }, + }, + args: { + disabled: false, + label: undefined, + options: mockOptions, + theme: ThemeTypes.Light, + }, +} as Meta; + +const BaseComponent = ({ disabled, label, options, theme }: ComponentProps) => { + return html` + + `; +}; + +export const Light: StoryObj = { + render: BaseComponent, + argTypes: { + theme: { control: "radio", options: [ThemeTypes.Light] }, + }, + args: { + theme: ThemeTypes.Light, + }, + parameters: { + backgrounds: { + values: [{ name: "Light", value: themes.light.background.alt }], + default: "Light", + }, + }, +}; + +export const Dark: StoryObj = { + render: BaseComponent, + argTypes: { + theme: { control: "radio", options: [ThemeTypes.Dark] }, + }, + args: { + theme: ThemeTypes.Dark, + }, + parameters: { + backgrounds: { + values: [{ name: "Dark", value: themes.dark.background.alt }], + default: "Dark", + }, + }, +}; diff --git a/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts index 7f833f2a1f..83b498df7c 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/rows/button-row.lit-stories.ts @@ -1,29 +1,53 @@ import { Meta, StoryObj } from "@storybook/web-components"; -import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; +import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; -import { ButtonRow } from "../../rows/button-row"; - -type Args = { - theme: Theme; - buttonAction: (e: Event) => void; - buttonText: string; -}; +import { themes } from "../../constants/styles"; +import { ButtonRow, ButtonRowProps } from "../../rows/button-row"; export default { title: "Components/Rows/Button Row", + argTypes: {}, + args: { + primaryButton: { + text: "Action", + handlePrimaryButtonClick: (e: Event) => { + window.alert("Button clicked!"); + }, + }, + }, +} as Meta; + +const Component = (args: ButtonRowProps) => ButtonRow({ ...args }); + +export const Light: StoryObj = { + render: Component, argTypes: { - theme: { control: "select", options: [...Object.values(ThemeTypes)] }, - buttonText: { control: "text" }, + theme: { control: "radio", options: [ThemeTypes.Light] }, }, args: { theme: ThemeTypes.Light, - buttonText: "Action", }, -} as Meta; - -const Template = (args: Args) => ButtonRow({ ...args }); - -export const Default: StoryObj = { - render: Template, + parameters: { + backgrounds: { + values: [{ name: "Light", value: themes.light.background.alt }], + default: "Light", + }, + }, +}; + +export const Dark: StoryObj = { + render: Component, + argTypes: { + theme: { control: "radio", options: [ThemeTypes.Dark] }, + }, + args: { + theme: ThemeTypes.Dark, + }, + parameters: { + backgrounds: { + values: [{ name: "Dark", value: themes.dark.background.alt }], + default: "Dark", + }, + }, }; diff --git a/apps/browser/src/autofill/content/components/notification/button-row.ts b/apps/browser/src/autofill/content/components/notification/button-row.ts new file mode 100644 index 0000000000..1eb0a4ac5f --- /dev/null +++ b/apps/browser/src/autofill/content/components/notification/button-row.ts @@ -0,0 +1,109 @@ +import { html } from "lit"; + +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { Theme } from "@bitwarden/common/platform/enums"; + +import { Option, OrgView, FolderView } from "../common-types"; +import { Business, Family, Folder, User } from "../icons"; +import { ButtonRow } from "../rows/button-row"; + +function getVaultIconByProductTier(productTierType?: ProductTierType): Option["icon"] { + switch (productTierType) { + case ProductTierType.Free: + case ProductTierType.Families: + return Family; + case ProductTierType.Teams: + case ProductTierType.Enterprise: + case ProductTierType.TeamsStarter: + return Business; + default: + return User; + } +} + +export type NotificationButtonRowProps = { + theme: Theme; + primaryButton: { + text: string; + handlePrimaryButtonClick: (args: any) => void; + }; + folders?: FolderView[]; + organizations?: OrgView[]; +}; + +export function NotificationButtonRow({ + folders, + organizations, + primaryButton, + theme, +}: NotificationButtonRowProps) { + const currentUserVaultOption: Option = { + icon: User, + default: true, + text: "My vault", // @TODO localize + value: "0", + }; + const organizationOptions: Option[] = organizations?.length + ? organizations.reduce( + (options, { id, name, productTierType }: OrgView) => { + const icon = getVaultIconByProductTier(productTierType); + return [ + ...options, + { + icon, + text: name, + value: id, + }, + ]; + }, + [currentUserVaultOption], + ) + : ([] as Option[]); + + const noFolderOption: Option = { + default: true, + icon: Folder, + text: "No folder", // @TODO localize + value: "0", + }; + const folderOptions: Option[] = folders?.length + ? folders.reduce( + (options, { id, name }: FolderView) => [ + ...options, + { + icon: Folder, + text: name, + value: id, + }, + ], + [noFolderOption], + ) + : []; + + return html` + ${ButtonRow({ + theme, + primaryButton, + selectButtons: [ + ...(organizationOptions.length > 1 + ? [ + { + id: "organization", + label: "Vault", // @TODO localize + options: organizationOptions, + }, + ] + : []), + ...(folderOptions.length > 1 + ? [ + { + id: "folder", + label: "Folder", // @TODO localize + options: folderOptions, + }, + ] + : []), + ], + })} + `; +} diff --git a/apps/browser/src/autofill/content/components/notification/footer.ts b/apps/browser/src/autofill/content/components/notification/footer.ts index c604fcd196..8ed69a96ad 100644 --- a/apps/browser/src/autofill/content/components/notification/footer.ts +++ b/apps/browser/src/autofill/content/components/notification/footer.ts @@ -7,27 +7,43 @@ import { NotificationType, NotificationTypes, } from "../../../notification/abstractions/notification-bar"; +import { OrgView, FolderView } from "../common-types"; import { spacing, themes } from "../constants/styles"; -import { ButtonRow } from "../rows/button-row"; -export function NotificationFooter({ - handleSaveAction, - notificationType, - theme, - i18n, -}: { - handleSaveAction: (e: Event) => void; +import { NotificationButtonRow } from "./button-row"; + +export type NotificationFooterProps = { + folders?: FolderView[]; i18n: { [key: string]: string }; notificationType?: NotificationType; + organizations?: OrgView[]; theme: Theme; -}) { + handleSaveAction: (e: Event) => void; +}; + +export function NotificationFooter({ + folders, + i18n, + notificationType, + organizations, + theme, + handleSaveAction, +}: NotificationFooterProps) { const isChangeNotification = notificationType === NotificationTypes.Change; - const buttonText = i18n.saveAction; + const primaryButtonText = i18n.saveAction; return html`
${!isChangeNotification - ? ButtonRow({ theme, buttonAction: handleSaveAction, buttonText }) + ? NotificationButtonRow({ + folders, + organizations, + primaryButton: { + handlePrimaryButtonClick: handleSaveAction, + text: primaryButtonText, + }, + theme, + }) : nothing}
`; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-item.ts b/apps/browser/src/autofill/content/components/option-selection/option-item.ts new file mode 100644 index 0000000000..619d77e63d --- /dev/null +++ b/apps/browser/src/autofill/content/components/option-selection/option-item.ts @@ -0,0 +1,81 @@ +import createEmotion from "@emotion/css/create-instance"; +import { html, nothing } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { IconProps, Option } from "../common-types"; +import { themes, spacing } from "../constants/styles"; + +export const optionItemTagName = "option-item"; + +const { css } = createEmotion({ + key: optionItemTagName, +}); + +export function OptionItem({ + icon, + text, + value, + theme, + handleSelection, +}: Option & { + theme: Theme; + handleSelection: () => void; +}) { + const handleSelectionKeyUpProxy = (event: KeyboardEvent) => { + const listenedForKeys = new Set(["Enter", "Space"]); + if (listenedForKeys.has(event.code) && event.target instanceof Element) { + handleSelection(); + } + + return; + }; + + const iconProps: IconProps = { color: themes[theme].text.main, theme }; + const itemIcon = icon?.(iconProps); + + return html`
+ ${itemIcon ? html`
${itemIcon}
` : nothing} + ${text || value} +
`; +} + +export const optionItemIconWidth = 16; +const optionItemGap = spacing["2"]; + +const optionItemStyles = css` + gap: ${optionItemGap}; + user-select: none; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + cursor: pointer; +`; + +const optionItemIconContainerStyles = css` + flex-grow: 1; + flex-shrink: 1; + width: ${optionItemIconWidth}px; + height: ${optionItemIconWidth}px; + + > svg { + width: 100%; + height: fit-content; + } +`; + +const optionItemTextStyles = css` + flex: 1 1 calc(100% - ${optionItemIconWidth}px - ${optionItemGap}); + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts new file mode 100644 index 0000000000..b87eea2a3a --- /dev/null +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -0,0 +1,90 @@ +import createEmotion from "@emotion/css/create-instance"; +import { html, nothing } from "lit"; + +import { Theme } from "@bitwarden/common/platform/enums"; + +import { Option } from "../common-types"; +import { themes, typography, scrollbarStyles, spacing } from "../constants/styles"; + +import { OptionItem, optionItemTagName } from "./option-item"; + +export const optionItemsTagName = "option-items"; + +const { css } = createEmotion({ + key: optionItemsTagName, +}); + +export function OptionItems({ + theme, + topOffset, + label, + options, + handleOptionSelection, +}: { + theme: Theme; + topOffset: number; + label?: string; + options: Option[]; + handleOptionSelection: (selectedOption: Option) => void; +}) { + // @TODO get client vendor from context + const isSafari = false; + + return html` +
+ ${label ? html`
${label}
` : nothing} +
+ ${options.map((option) => + OptionItem({ ...option, theme, handleSelection: () => handleOptionSelection(option) }), + )} +
+
+ `; +} + +const optionsStyles = ({ theme, topOffset }: { theme: Theme; topOffset: number }) => css` + ${typography.body1} + + -webkit-font-smoothing: antialiased; + position: absolute; + /* top offset + line-height of dropdown button + top and bottom padding of button + border-width */ + top: calc(${topOffset}px + 20px + ${spacing["1"]} + ${spacing["1"]} + 1px); + border: 1px solid ${themes[theme].secondary["500"]}; + border-radius: 0.5rem; + background-clip: padding-box; + background-color: ${themes[theme].background.DEFAULT}; + padding: 0.25rem 0; + max-width: fit-content; + overflow-y: hidden; + color: ${themes[theme].text.main}; +`; + +const optionsLabelStyles = ({ theme }: { theme: Theme }) => css` + ${typography.helperMedium} + + user-select: none; + padding: 0.375rem ${spacing["3"]}; + color: ${themes[theme].text.muted}; + font-weight: 600; +`; + +export const optionsMenuItemMaxWidth = 260; +export const optionsMenuItemsMaxHeight = 114; + +const optionsWrapper = ({ isSafari, theme }: { isSafari: boolean; theme: Theme }) => css` + max-height: ${optionsMenuItemsMaxHeight}px; + overflow-y: auto; + + > [class*="${optionItemTagName}-"] { + padding: ${spacing["1.5"]} ${spacing["3"]}; + max-width: ${optionsMenuItemMaxWidth}px; + + :hover { + background-color: ${themes[theme].primary["100"]}; + } + } + + ${isSafari + ? scrollbarStyles(theme, { track: "transparent" }).safari + : scrollbarStyles(theme, { track: "transparent" }).default} +`; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts new file mode 100644 index 0000000000..5f43e7a025 --- /dev/null +++ b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts @@ -0,0 +1,138 @@ +import createEmotion from "@emotion/css/create-instance"; +import { html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; + +import { OptionSelectionButton } from "../buttons/option-selection-button"; +import { Option } from "../common-types"; + +import { optionItemIconWidth } from "./option-item"; +import { OptionItems, optionsMenuItemMaxWidth } from "./option-items"; + +export const optionSelectionTagName = "option-selection"; + +const { css } = createEmotion({ + key: optionSelectionTagName, +}); + +export class OptionSelection extends LitElement { + @property() + disabled: boolean = false; + + @property() + label?: string; + + @property({ type: Array }) + options: Option[] = []; + + @property() + theme: Theme = ThemeTypes.Light; + + @property({ type: (selectedOption: Option["value"]) => selectedOption }) + handleSelectionUpdate?: (args: any) => void; + + @state() + private showMenu = false; + + @state() + private menuTopOffset: number = 0; + + // Determines if the opened menu should be "anchored" to the right or left side of the opening button + @state() + private menuIsEndJustified: boolean = false; + + @state() + private selection?: Option; + + private handleButtonClick = (event: Event) => { + if (!this.disabled) { + // Menu is about to be shown + if (!this.showMenu) { + this.menuTopOffset = this.offsetTop; + + // Distance from right edge of button to left edge of the viewport + // Assumes no enclosing frames between the intended host frame and the component + const boundingClientRect = this.getBoundingClientRect(); + + // Width of the client (minus scrollbar) + const documentWidth = document.documentElement.clientWidth; + + // Distance between left edge of the button and right edge of the viewport + // (e.g. the max space the menu can use when left-aligned) + const distanceFromViewportRightEdge = documentWidth - boundingClientRect.left; + + // The full width the option menu can take up + // (base + icon + border + gap + padding) + const maxDifferenceThreshold = + optionsMenuItemMaxWidth + optionItemIconWidth + 2 + 8 + 12 * 2; + + this.menuIsEndJustified = distanceFromViewportRightEdge < maxDifferenceThreshold; + } + + this.showMenu = !this.showMenu; + } + }; + + private handleOptionSelection = (selectedOption: Option) => { + this.showMenu = false; + this.selection = selectedOption; + + // Any side-effects that should occur from the selection + this.handleSelectionUpdate?.(selectedOption.value); + }; + + protected createRenderRoot() { + return this; + } + + render() { + if (!this.selection) { + this.selection = getDefaultOption(this.options); + } + + return html` +
+ ${OptionSelectionButton({ + disabled: this.disabled, + icon: this.selection?.icon, + text: this.selection?.text, + theme: this.theme, + toggledOn: this.showMenu, + handleButtonClick: this.handleButtonClick, + })} + ${this.showMenu + ? OptionItems({ + label: this.label, + options: this.options, + theme: this.theme, + topOffset: this.menuTopOffset, + handleOptionSelection: this.handleOptionSelection, + }) + : nothing} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [optionSelectionTagName]: OptionSelection; + } +} + +export default customElements.define(optionSelectionTagName, OptionSelection); + +function getDefaultOption(options: Option[] = []) { + return options.find((option: Option) => option.default) || options[0]; +} + +const optionSelectionStyles = ({ menuIsEndJustified }: { menuIsEndJustified: boolean }) => css` + display: flex; + justify-content: ${menuIsEndJustified ? "flex-end" : "flex-start"}; + + > div, + > button { + width: 100%; + } +`; diff --git a/apps/browser/src/autofill/content/components/rows/button-row.ts b/apps/browser/src/autofill/content/components/rows/button-row.ts index ed0ed5aac7..80dcd0de12 100644 --- a/apps/browser/src/autofill/content/components/rows/button-row.ts +++ b/apps/browser/src/autofill/content/components/rows/button-row.ts @@ -1,54 +1,53 @@ import { css } from "@emotion/css"; -import { html, TemplateResult } from "lit"; +import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; import { ActionButton } from "../../../content/components/buttons/action-button"; -import { spacing, themes } from "../../../content/components/constants/styles"; -import { Folder, User } from "../../../content/components/icons"; -import { DropdownMenu } from "../dropdown-menu"; +import { spacing } from "../../../content/components/constants/styles"; +import { Option } from "../common-types"; +import { optionSelectionTagName } from "../option-selection/option-selection"; -export function ButtonRow({ - theme, - buttonAction, - buttonText, -}: { +export type ButtonRowProps = { theme: Theme; - buttonAction: (e: Event) => void; - buttonText: string; -}) { + primaryButton: { + text: string; + handlePrimaryButtonClick: (args: any) => void; + }; + selectButtons?: { + id: string; + label?: string; + options: Option[]; + handleSelectionUpdate?: (args: any) => void; + }[]; +}; + +export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProps) { return html`
- ${[ - ActionButton({ - buttonAction: buttonAction, - buttonText, - theme, - }), - DropdownContainer({ - children: [ - DropdownMenu({ - buttonText: "You", - icon: User({ color: themes[theme].text.muted, theme }), - theme, - }), - DropdownMenu({ - buttonText: "Folder", - icon: Folder({ color: themes[theme].text.muted, theme }), - disabled: true, - theme, - }), - ], - }), - ]} + ${ActionButton({ + handleClick: primaryButton.handlePrimaryButtonClick, + buttonText: primaryButton.text, + theme, + })} +
+ ${selectButtons?.map( + ({ id, label, options, handleSelectionUpdate }) => + html` + + ` || nothing, + )} +
`; } -function DropdownContainer({ children }: { children: TemplateResult[] }) { - return html`
${children}
`; -} - const buttonRowStyles = css` gap: 16px; display: flex; @@ -69,14 +68,16 @@ const buttonRowStyles = css` } `; -const dropdownContainerStyles = css` - gap: 8px; +const optionSelectionsStyles = css` + gap: ${spacing["2"]}; display: flex; align-items: center; justify-content: flex-end; overflow: hidden; - > div { - min-width: calc(50% - ${spacing["1.5"]}); + > ${optionSelectionTagName} { + /* assumes two option selections */ + max-width: calc(50% - ${spacing["1.5"]}); + min-width: 120px; } `;