1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-18785] Build dropdown button selection components (#13692)

* create standalone option selection button component

* create option selection components

* replace dropdown-menu component with option selection in button row

* create folder and vault selection components

* add story for option-selection and update button row component

* update options selection component behaviour and styling

* add shared icon typing

* move Options to common types

* refactor option selection component to handle options better

* rework notification footer and button row components to handle expected data props

* add optional selection option menu label

* set max-height with scroll handling on option selection menu

* fix menu item spacing

* avoid displaying the dropdown menu horizontally outside of the viewport

* update dropdown menu style

* update button content spacing

* allow overriding default scrollbar colors

* update options items menu typography

* fix eslint exception

* refine some prop names
This commit is contained in:
Jonathan Prusik
2025-03-19 10:49:15 -04:00
committed by GitHub
parent 7c0af6c8fb
commit 5c27918609
33 changed files with 918 additions and 355 deletions

View File

@@ -6,27 +6,27 @@ import { Theme } from "@bitwarden/common/platform/enums";
import { border, themes, typography, spacing } from "../constants/styles"; import { border, themes, typography, spacing } from "../constants/styles";
export function ActionButton({ export function ActionButton({
buttonAction,
buttonText, buttonText,
disabled = false, disabled = false,
theme, theme,
handleClick,
}: { }: {
buttonAction: (e: Event) => void;
buttonText: string; buttonText: string;
disabled?: boolean; disabled?: boolean;
theme: Theme; theme: Theme;
handleClick: (e: Event) => void;
}) { }) {
const handleButtonClick = (event: Event) => { const handleButtonClick = (event: Event) => {
if (!disabled) { if (!disabled) {
buttonAction(event); handleClick(event);
} }
}; };
return html` return html`
<button <button
type="button"
title=${buttonText}
class=${actionButtonStyles({ disabled, theme })} class=${actionButtonStyles({ disabled, theme })}
title=${buttonText}
type="button"
@click=${handleButtonClick} @click=${handleButtonClick}
> >
${buttonText} ${buttonText}

View File

@@ -23,9 +23,9 @@ export function EditButton({
title=${buttonText} title=${buttonText}
class=${editButtonStyles({ disabled, theme })} class=${editButtonStyles({ disabled, theme })}
@click=${(event: Event) => { @click=${(event: Event) => {
// FIXME: Remove when updating file. Eslint update if (!disabled) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions buttonAction(event);
!disabled && buttonAction(event); }
}} }}
> >
${PencilSquare({ disabled, theme })} ${PencilSquare({ disabled, theme })}

View File

@@ -0,0 +1,104 @@
import { css } from "@emotion/css";
import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { IconProps, Option } from "../common-types";
import { border, spacing, themes, typography } from "../constants/styles";
import { AngleUp, AngleDown } from "../icons";
export type OptionSelectionButtonProps = {
disabled: boolean;
icon?: Option["icon"];
text?: string;
theme: Theme;
toggledOn: boolean;
handleButtonClick: (e: Event) => void;
};
export function OptionSelectionButton({
disabled,
icon,
text,
theme,
toggledOn,
handleButtonClick,
}: OptionSelectionButtonProps) {
const selectedOptionIconProps: IconProps = { color: themes[theme].text.muted, theme };
const buttonIcon = icon?.(selectedOptionIconProps);
return html`
<button
class=${selectionButtonStyles({ disabled, toggledOn, theme })}
title=${text}
type="button"
@click=${handleButtonClick}
>
${buttonIcon ?? nothing}
${text ? html`<span class=${dropdownButtonTextStyles}>${text}</span>` : nothing}
${toggledOn
? AngleUp({ color: themes[theme].text.muted, theme })
: AngleDown({ color: themes[theme].text.muted, theme })}
</button>
`;
}
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;
`;

View File

@@ -9,17 +9,17 @@ import { Business, Family } from "../../../content/components/icons";
// @TODO connect data source to icon checks // @TODO connect data source to icon checks
// @TODO support other indicator types (attachments, etc) // @TODO support other indicator types (attachments, etc)
export function CipherInfoIndicatorIcons({ export function CipherInfoIndicatorIcons({
isBusinessOrg, showBusinessIcon,
isFamilyOrg, showFamilyIcon,
theme, theme,
}: { }: {
isBusinessOrg?: boolean; showBusinessIcon?: boolean;
isFamilyOrg?: boolean; showFamilyIcon?: boolean;
theme: Theme; theme: Theme;
}) { }) {
const indicatorIcons = [ const indicatorIcons = [
...(isBusinessOrg ? [Business({ color: themes[theme].text.muted, theme })] : []), ...(showBusinessIcon ? [Business({ color: themes[theme].text.muted, theme })] : []),
...(isFamilyOrg ? [Family({ color: themes[theme].text.muted, theme })] : []), ...(showFamilyIcon ? [Family({ color: themes[theme].text.muted, theme })] : []),
]; ];
return indicatorIcons.length return indicatorIcons.length

View File

@@ -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;
};

View File

@@ -174,14 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil
${rule}: ${color}; ${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 { return {
default: `
/* FireFox & Chrome support */ /* FireFox & Chrome support */
scrollbar-color: ${themes[theme].secondary["500"]} ${themes[theme].background.alt}; default: `
scrollbar-color: ${thumbColor} ${trackColor};
`, `,
safari: `
/* Safari Support */ /* Safari Support */
safari: `
::-webkit-scrollbar { ::-webkit-scrollbar {
overflow: auto; overflow: auto;
} }
@@ -191,10 +194,10 @@ export function scrollbarStyles(theme: Theme) {
border-radius: 0.5rem; border-radius: 0.5rem;
border-color: transparent; border-color: transparent;
background-clip: content-box; background-clip: content-box;
background-color: ${themes[theme].secondary["500"]}; background-color: ${thumbColor};
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
${themes[theme].background.alt}; ${trackColor};
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
${themes[theme].secondary["600"]}; ${themes[theme].secondary["600"]};

View File

@@ -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`
<div class=${dropdownContainerStyles}>
<button
type="button"
title=${buttonText}
class=${dropdownButtonStyles({ disabled, theme })}
@click=${handleButtonClick}
>
${icon ?? null}
<span class=${dropdownButtonTextStyles}>${buttonText}</span>
${AngleDown({ color: themes[theme].text.muted, theme })}
</button>
${showDropdown
? html` <div class=${dropdownMenuStyles({ theme })}>${dropdownMenuItems}</div> `
: null}
</div>
`;
}
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;
`;

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function AngleDown({ export function AngleDown({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -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`
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 12"
fill="none"
style="transform: scaleY(-1);"
>
<path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M12.004 11.244a2.705 2.705 0 0 1-1.75-.644L.266 2.154a.76.76 0 0 1-.263-.51.75.75 0 0 1 1.233-.637l9.99 8.445a1.186 1.186 0 0 0 1.565 0l10-8.54a.751.751 0 0 1 .973 1.141l-10 8.538a2.703 2.703 0 0 1-1.76.653Z"
/>
</svg>
`;
}

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function Business({ export function Business({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function Close({ export function Close({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function ExclamationTriangle({ export function ExclamationTriangle({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function Family({ export function Family({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -1,26 +1,17 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function Folder({ export function Folder({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 22" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<path <path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))} class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M21.78 3.823h-7.97a.823.823 0 0 1-.584-.265.872.872 0 0 1-.23-.61v-.396A2.321 2.321 0 0 0 12.348.93a2.214 2.214 0 0 0-1.577-.681H2.22A2.214 2.214 0 0 0 .645.93 2.321 2.321 0 0 0 0 2.552v16.88c-.003.608.23 1.191.647 1.624.417.432.984.677 1.576.681l19.554.016c.288 0 .574-.058.84-.171.267-.113.51-.278.714-.487a2.31 2.31 0 0 0 .497-.756c.115-.284.174-.588.172-.894V6.129c0-.606-.233-1.189-.648-1.62a2.223 2.223 0 0 0-1.572-.686ZM2.223 1.678h8.552c.22.006.43.101.582.265a.865.865 0 0 1 .23.61v.396c0 .606.234 1.189.65 1.62.416.432.983.677 1.576.684h7.97c.22.006.43.1.582.264a.86.86 0 0 1 .23.607v1.707a.56.56 0 0 1-.16.389.535.535 0 0 1-.38.159H1.951a.531.531 0 0 1-.381-.16.558.558 0 0 1-.16-.389V2.551a.867.867 0 0 1 .23-.609.82.82 0 0 1 .582-.265ZM22.34 20.08a.779.779 0 0 1-.558.238l-19.558-.014a.823.823 0 0 1-.582-.264.864.864 0 0 1-.23-.608v-9.065a.566.566 0 0 1 .16-.39.547.547 0 0 1 .38-.16h20.104c.143-.001.28.057.382.16a.561.561 0 0 1 .16.39v9.083a.903.903 0 0 1-.259.63h.001Z" d="M12.705 2.813h-4.65a.48.48 0 0 1-.34-.155.509.509 0 0 1-.134-.355v-.231a1.354 1.354 0 0 0-.378-.947 1.291 1.291 0 0 0-.92-.397H1.296a1.291 1.291 0 0 0-.919.398A1.354 1.354 0 0 0 0 2.072v9.847c-.002.354.134.694.377.947.244.252.574.394.92.397l11.406.01a1.255 1.255 0 0 0 .907-.384 1.35 1.35 0 0 0 .39-.963V4.158c0-.353-.136-.693-.378-.945a1.296 1.296 0 0 0-.917-.4ZM1.297 1.562h4.988c.13.004.251.06.34.155a.504.504 0 0 1 .134.355v.231c0 .354.136.694.38.946.242.251.573.394.919.398h4.649c.128.004.25.059.34.154.089.096.137.223.134.355v.995a.326.326 0 0 1-.196.296.308.308 0 0 1-.12.024H1.139a.31.31 0 0 1-.223-.093.326.326 0 0 1-.092-.227V2.07a.506.506 0 0 1 .133-.355.479.479 0 0 1 .34-.155Zm11.734 10.735a.456.456 0 0 1-.325.139l-11.409-.008a.48.48 0 0 1-.34-.154.504.504 0 0 1-.133-.355V6.63a.33.33 0 0 1 .092-.227.32.32 0 0 1 .222-.094h11.727a.31.31 0 0 1 .223.094c.06.06.093.142.093.227v5.299a.527.527 0 0 1-.15.367Z"
/> />
</svg> </svg>
`; `;

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function Globe({ export function Globe({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -1,4 +1,5 @@
export { AngleDown } from "./angle-down"; export { AngleDown } from "./angle-down";
export { AngleUp } from "./angle-up";
export { BrandIconContainer } from "./brand-icon-container"; export { BrandIconContainer } from "./brand-icon-container";
export { Business } from "./business"; export { Business } from "./business";
export { Close } from "./close"; export { Close } from "./close";

View File

@@ -1,9 +1,11 @@
import { html } from "lit"; 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 // This icon has static multi-colors for each theme
export function PartyHorn({ theme }: { theme: Theme }) { export function PartyHorn({ theme }: IconProps) {
if (theme === ThemeTypes.Dark) { if (theme === ThemeTypes.Dark) {
return html` return html`
<svg <svg

View File

@@ -1,19 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function PencilSquare({ export function PencilSquare({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`

View File

@@ -1,11 +1,10 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function Shield({ color, theme }: { color?: string; theme: Theme }) { export function Shield({ color, theme }: IconProps) {
const shapeColor = color || themes[theme].brandLogo; const shapeColor = color || themes[theme].brandLogo;
return html` return html`

View File

@@ -1,26 +1,17 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html } from "lit"; import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types";
import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
export function User({ export function User({ color, disabled, theme }: IconProps) {
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html` return html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<path <path
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))} class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
d="M23.16 20.895a11.343 11.343 0 0 0-7.756-8.425 6.624 6.624 0 0 0 3.374-5.74 6.73 6.73 0 1 0-13.46 0 6.624 6.624 0 0 0 3.343 5.722 11.334 11.334 0 0 0-7.82 8.443A2.57 2.57 0 0 0 3.362 24h17.274a2.573 2.573 0 0 0 2.523-3.106v.001ZM6.933 6.73a5.12 5.12 0 0 1 3.12-4.766 5.115 5.115 0 0 1 4.845 8.962 5.114 5.114 0 0 1-2.848.866A5.097 5.097 0 0 1 6.933 6.73v.001ZM21.38 22.053a.94.94 0 0 1-.748.35H3.363a.938.938 0 0 1-.74-.35.986.986 0 0 1-.204-.833A9.812 9.812 0 0 1 12 13.516a9.807 9.807 0 0 1 9.581 7.704.98.98 0 0 1-.202.833Z" d="M13.51 12.189a6.616 6.616 0 0 0-4.524-4.915 3.87 3.87 0 0 0 1.968-3.348 3.926 3.926 0 1 0-7.852 0 3.864 3.864 0 0 0 1.95 3.338A6.61 6.61 0 0 0 .49 12.189 1.499 1.499 0 0 0 1.962 14h10.077a1.503 1.503 0 0 0 1.471-1.812ZM4.044 3.926A2.987 2.987 0 0 1 7.592.963a2.985 2.985 0 0 1-.563 5.916 2.973 2.973 0 0 1-2.985-2.953Zm8.427 8.938a.548.548 0 0 1-.436.204H1.962a.548.548 0 0 1-.432-.204.576.576 0 0 1-.119-.486 5.724 5.724 0 0 1 9.175-3.23 5.723 5.723 0 0 1 2.003 3.23.571.571 0 0 1-.118.486Z"
/> />
</svg> </svg>
`; `;

View File

@@ -16,9 +16,9 @@ dynamically based on the provided theme.
## Props ## Props
| **Prop** | **Type** | **Required** | **Description** | | **Prop** | **Type** | **Required** | **Description** |
| --------------- | --------- | ------------ | ----------------------------------------------------------------------- | | ------------------ | --------- | ------------ | ----------------------------------------------------------------------- |
| `isBusinessOrg` | `boolean` | No | Displays the business organization icon when set to `true`. | | `showBusinessIcon` | `boolean` | No | Displays the business organization icon when set to `true`. |
| `isFamilyOrg` | `boolean` | No | Displays the family 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. | | `theme` | `Theme` | Yes | Defines the theme used to style the icons. Must match the `Theme` enum. |
## Installation and Setup ## Installation and Setup
@@ -29,8 +29,8 @@ dynamically based on the provided theme.
- `@emotion/css`: Used for styling. - `@emotion/css`: Used for styling.
2. Pass the required props when using the component: 2. Pass the required props when using the component:
- `isBusinessOrg`: A boolean that, when `true`, displays the business icon. - `showBusinessIcon`: A boolean that, when `true`, displays the business icon.
- `isFamilyOrg`: A boolean that, when `true`, displays the family icon. - `showFamilyIcon`: A boolean that, when `true`, displays the family icon.
- `theme`: Specifies the theme for styling the icons. - `theme`: Specifies the theme for styling the icons.
## Accessibility (WCAG) Compliance ## Accessibility (WCAG) Compliance
@@ -57,8 +57,8 @@ import { CipherInfoIndicatorIcons } from "./cipher-info-indicator-icons";
import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
<CipherInfoIndicatorIcons <CipherInfoIndicatorIcons
isBusinessOrg={true} showBusinessIcon={true}
isFamilyOrg={false} showFamilyIcon={false}
theme={ThemeTypes.Dark} theme={ThemeTypes.Dark}
/>; />;
``` ```
@@ -77,5 +77,5 @@ family organization icon.
### Notes ### 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. behavior should be handled by the parent component.

View File

@@ -8,7 +8,7 @@ type Args = {
buttonText: string; buttonText: string;
disabled: boolean; disabled: boolean;
theme: Theme; theme: Theme;
buttonAction: (e: Event) => void; handleClick: (e: Event) => void;
}; };
export default { export default {
@@ -17,13 +17,13 @@ export default {
buttonText: { control: "text" }, buttonText: { control: "text" },
disabled: { control: "boolean" }, disabled: { control: "boolean" },
theme: { control: "select", options: [...Object.values(ThemeTypes)] }, theme: { control: "select", options: [...Object.values(ThemeTypes)] },
buttonAction: { control: false }, handleClick: { control: false },
}, },
args: { args: {
buttonText: "Click Me", buttonText: "Click Me",
disabled: false, disabled: false,
theme: ThemeTypes.Light, theme: ThemeTypes.Light,
buttonAction: () => alert("Clicked"), handleClick: () => alert("Clicked"),
}, },
parameters: { parameters: {
design: { design: {

View File

@@ -6,22 +6,22 @@ import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.e
import { CipherInfoIndicatorIcons } from "../../cipher/cipher-indicator-icons"; import { CipherInfoIndicatorIcons } from "../../cipher/cipher-indicator-icons";
type Args = { type Args = {
isBusinessOrg?: boolean; showBusinessIcon?: boolean;
isFamilyOrg?: boolean; showFamilyIcon?: boolean;
theme: Theme; theme: Theme;
}; };
export default { export default {
title: "Components/Ciphers/Cipher Indicator Icon", title: "Components/Ciphers/Cipher Indicator Icon",
argTypes: { argTypes: {
isBusinessOrg: { control: "boolean" }, showBusinessIcon: { control: "boolean" },
isFamilyOrg: { control: "boolean" }, showFamilyIcon: { control: "boolean" },
theme: { control: "select", options: [...Object.values(ThemeTypes)] }, theme: { control: "select", options: [...Object.values(ThemeTypes)] },
}, },
args: { args: {
theme: ThemeTypes.Light, theme: ThemeTypes.Light,
isBusinessOrg: true, showBusinessIcon: true,
isFamilyOrg: false, showFamilyIcon: false,
}, },
} as Meta<Args>; } as Meta<Args>;

View File

@@ -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,
},
];

View File

@@ -1,33 +1,29 @@
import { Meta, StoryObj } from "@storybook/web-components"; 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, NotificationFooterProps } from "../../notification/footer";
import { NotificationFooter } from "../../notification/footer"; import { mockFolderData, mockOrganizationData } from "../mock-data";
type Args = {
notificationType: NotificationType;
theme: Theme;
handleSaveAction: (e: Event) => void;
i18n: { [key: string]: string };
};
export default { export default {
title: "Components/Notifications/Notification Footer", title: "Components/Notifications/Notification Footer",
argTypes: { argTypes: {
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
notificationType: { notificationType: {
control: "select", control: "select",
options: ["add", "change", "unlock", "fileless-import"], options: ["add", "change", "unlock", "fileless-import"],
}, },
theme: { control: "select", options: [...Object.values(ThemeTypes)] },
}, },
args: { args: {
theme: ThemeTypes.Light, folders: mockFolderData,
notificationType: "add",
i18n: { i18n: {
saveAsNewLoginAction: "Save as New Login",
saveAction: "Save", saveAction: "Save",
saveAsNewLoginAction: "Save as New Login",
}, },
notificationType: "add",
organizations: mockOrganizationData,
theme: ThemeTypes.Light,
handleSaveAction: () => alert("Save action triggered"), handleSaveAction: () => alert("Save action triggered"),
}, },
parameters: { parameters: {
@@ -36,10 +32,11 @@ export default {
url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=32-4949&m=dev", url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=32-4949&m=dev",
}, },
}, },
} as Meta<Args>; } as Meta<NotificationFooterProps>;
const Template = (args: Args) => NotificationFooter({ ...args }); const Template = (args: NotificationFooterProps) =>
html`<div style="max-width:400px;">${NotificationFooter({ ...args })}</div>`;
export const Default: StoryObj<Args> = { export const Default: StoryObj<NotificationFooterProps> = {
render: Template, render: Template,
}; };

View File

@@ -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<ComponentProps>;
const BaseComponent = ({ disabled, label, options, theme }: ComponentProps) => {
return html`
<option-selection
.disabled=${disabled}
.label="${label}"
.options=${options}
theme=${theme}
></option-selection>
`;
};
export const Light: StoryObj<ComponentProps> = {
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<ComponentProps> = {
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",
},
},
};

View File

@@ -1,29 +1,53 @@
import { Meta, StoryObj } from "@storybook/web-components"; 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"; import { themes } from "../../constants/styles";
import { ButtonRow, ButtonRowProps } from "../../rows/button-row";
type Args = {
theme: Theme;
buttonAction: (e: Event) => void;
buttonText: string;
};
export default { export default {
title: "Components/Rows/Button Row", title: "Components/Rows/Button Row",
argTypes: {},
args: {
primaryButton: {
text: "Action",
handlePrimaryButtonClick: (e: Event) => {
window.alert("Button clicked!");
},
},
},
} as Meta<ButtonRowProps>;
const Component = (args: ButtonRowProps) => ButtonRow({ ...args });
export const Light: StoryObj<ButtonRowProps> = {
render: Component,
argTypes: { argTypes: {
theme: { control: "select", options: [...Object.values(ThemeTypes)] }, theme: { control: "radio", options: [ThemeTypes.Light] },
buttonText: { control: "text" },
}, },
args: { args: {
theme: ThemeTypes.Light, theme: ThemeTypes.Light,
buttonText: "Action",
}, },
} as Meta<Args>; parameters: {
backgrounds: {
const Template = (args: Args) => ButtonRow({ ...args }); values: [{ name: "Light", value: themes.light.background.alt }],
default: "Light",
export const Default: StoryObj<Args> = { },
render: Template, },
};
export const Dark: StoryObj<ButtonRowProps> = {
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",
},
},
}; };

View File

@@ -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,
},
]
: []),
],
})}
`;
}

View File

@@ -7,27 +7,43 @@ import {
NotificationType, NotificationType,
NotificationTypes, NotificationTypes,
} from "../../../notification/abstractions/notification-bar"; } from "../../../notification/abstractions/notification-bar";
import { OrgView, FolderView } from "../common-types";
import { spacing, themes } from "../constants/styles"; import { spacing, themes } from "../constants/styles";
import { ButtonRow } from "../rows/button-row";
export function NotificationFooter({ import { NotificationButtonRow } from "./button-row";
handleSaveAction,
notificationType, export type NotificationFooterProps = {
theme, folders?: FolderView[];
i18n,
}: {
handleSaveAction: (e: Event) => void;
i18n: { [key: string]: string }; i18n: { [key: string]: string };
notificationType?: NotificationType; notificationType?: NotificationType;
organizations?: OrgView[];
theme: Theme; theme: Theme;
}) { handleSaveAction: (e: Event) => void;
};
export function NotificationFooter({
folders,
i18n,
notificationType,
organizations,
theme,
handleSaveAction,
}: NotificationFooterProps) {
const isChangeNotification = notificationType === NotificationTypes.Change; const isChangeNotification = notificationType === NotificationTypes.Change;
const buttonText = i18n.saveAction; const primaryButtonText = i18n.saveAction;
return html` return html`
<div class=${notificationFooterStyles({ theme })}> <div class=${notificationFooterStyles({ theme })}>
${!isChangeNotification ${!isChangeNotification
? ButtonRow({ theme, buttonAction: handleSaveAction, buttonText }) ? NotificationButtonRow({
folders,
organizations,
primaryButton: {
handlePrimaryButtonClick: handleSaveAction,
text: primaryButtonText,
},
theme,
})
: nothing} : nothing}
</div> </div>
`; `;

View File

@@ -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`<div
class=${optionItemStyles}
key=${value}
tabindex="0"
title=${text}
@click=${handleSelection}
@keyup=${handleSelectionKeyUpProxy}
>
${itemIcon ? html`<div class=${optionItemIconContainerStyles}>${itemIcon}</div>` : nothing}
<span class=${optionItemTextStyles}>${text || value}</span>
</div>`;
}
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;
`;

View File

@@ -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`
<div class=${optionsStyles({ theme, topOffset })} key="container">
${label ? html`<div class=${optionsLabelStyles({ theme })}>${label}</div>` : nothing}
<div class=${optionsWrapper({ isSafari, theme })}>
${options.map((option) =>
OptionItem({ ...option, theme, handleSelection: () => handleOptionSelection(option) }),
)}
</div>
</div>
`;
}
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}
`;

View File

@@ -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`
<div class=${optionSelectionStyles({ menuIsEndJustified: this.menuIsEndJustified })}>
${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}
</div>
`;
}
}
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%;
}
`;

View File

@@ -1,54 +1,53 @@
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { html, TemplateResult } from "lit"; import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums"; import { Theme } from "@bitwarden/common/platform/enums";
import { ActionButton } from "../../../content/components/buttons/action-button"; import { ActionButton } from "../../../content/components/buttons/action-button";
import { spacing, themes } from "../../../content/components/constants/styles"; import { spacing } from "../../../content/components/constants/styles";
import { Folder, User } from "../../../content/components/icons"; import { Option } from "../common-types";
import { DropdownMenu } from "../dropdown-menu"; import { optionSelectionTagName } from "../option-selection/option-selection";
export function ButtonRow({ export type ButtonRowProps = {
theme,
buttonAction,
buttonText,
}: {
theme: Theme; theme: Theme;
buttonAction: (e: Event) => void; primaryButton: {
buttonText: string; 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` return html`
<div class=${buttonRowStyles}> <div class=${buttonRowStyles}>
${[ ${ActionButton({
ActionButton({ handleClick: primaryButton.handlePrimaryButtonClick,
buttonAction: buttonAction, buttonText: primaryButton.text,
buttonText,
theme, theme,
}), })}
DropdownContainer({ <div class=${optionSelectionsStyles}>
children: [ ${selectButtons?.map(
DropdownMenu({ ({ id, label, options, handleSelectionUpdate }) =>
buttonText: "You", html`
icon: User({ color: themes[theme].text.muted, theme }), <option-selection
theme, key=${id}
}), theme=${theme}
DropdownMenu({ .label=${label}
buttonText: "Folder", .options=${options}
icon: Folder({ color: themes[theme].text.muted, theme }), .handleSelectionUpdate=${handleSelectionUpdate}
disabled: true, ></option-selection>
theme, ` || nothing,
}), )}
], </div>
}),
]}
</div> </div>
`; `;
} }
function DropdownContainer({ children }: { children: TemplateResult[] }) {
return html` <div class=${dropdownContainerStyles}>${children}</div> `;
}
const buttonRowStyles = css` const buttonRowStyles = css`
gap: 16px; gap: 16px;
display: flex; display: flex;
@@ -69,14 +68,16 @@ const buttonRowStyles = css`
} }
`; `;
const dropdownContainerStyles = css` const optionSelectionsStyles = css`
gap: 8px; gap: ${spacing["2"]};
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
overflow: hidden; overflow: hidden;
> div { > ${optionSelectionTagName} {
min-width: calc(50% - ${spacing["1.5"]}); /* assumes two option selections */
max-width: calc(50% - ${spacing["1.5"]});
min-width: 120px;
} }
`; `;