1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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";
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`
<button
type="button"
title=${buttonText}
class=${actionButtonStyles({ disabled, theme })}
title=${buttonText}
type="button"
@click=${handleButtonClick}
>
${buttonText}

View File

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

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};
`;
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"]};

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 { 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`

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 { 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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

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

View File

@@ -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`

View File

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

View File

@@ -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`
<svg

View File

@@ -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 PencilSquare({
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
export function PencilSquare({ color, disabled, theme }: IconProps) {
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
return html`

View File

@@ -1,11 +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 Shield({ color, theme }: { color?: string; theme: Theme }) {
export function Shield({ color, theme }: IconProps) {
const shapeColor = color || themes[theme].brandLogo;
return html`

View File

@@ -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 User({
color,
disabled,
theme,
}: {
color?: string;
disabled?: boolean;
theme: Theme;
}) {
export function User({ 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 24" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
<path
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>
`;

View File

@@ -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";
<CipherInfoIndicatorIcons
isBusinessOrg={true}
isFamilyOrg={false}
showBusinessIcon={true}
showFamilyIcon={false}
theme={ThemeTypes.Dark}
/>;
```
@@ -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.

View File

@@ -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: {

View File

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

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 { 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<ButtonRowProps>;
const Component = (args: ButtonRowProps) => ButtonRow({ ...args });
export const Light: StoryObj<ButtonRowProps> = {
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<Args>;
const Template = (args: Args) => ButtonRow({ ...args });
export const Default: StoryObj<Args> = {
render: Template,
parameters: {
backgrounds: {
values: [{ name: "Light", value: themes.light.background.alt }],
default: "Light",
},
},
};
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,
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`
<div class=${notificationFooterStyles({ theme })}>
${!isChangeNotification
? ButtonRow({ theme, buttonAction: handleSaveAction, buttonText })
? NotificationButtonRow({
folders,
organizations,
primaryButton: {
handlePrimaryButtonClick: handleSaveAction,
text: primaryButtonText,
},
theme,
})
: nothing}
</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 { 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`
<div class=${buttonRowStyles}>
${[
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,
})}
<div class=${optionSelectionsStyles}>
${selectButtons?.map(
({ id, label, options, handleSelectionUpdate }) =>
html`
<option-selection
key=${id}
theme=${theme}
.label=${label}
.options=${options}
.handleSelectionUpdate=${handleSelectionUpdate}
></option-selection>
` || nothing,
)}
</div>
</div>
`;
}
function DropdownContainer({ children }: { children: TemplateResult[] }) {
return html` <div class=${dropdownContainerStyles}>${children}</div> `;
}
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;
}
`;