Skip to content

Commit

Permalink
feat: PasswordStrength component (#301)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Dec 21, 2024
1 parent dc730c3 commit 8414564
Show file tree
Hide file tree
Showing 13 changed files with 251 additions and 81 deletions.
2 changes: 1 addition & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-themes"],
addons: ["@storybook/addon-themes", "@storybook/addon-controls"],
framework: {
name: "@storybook/react-vite",
options: {},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.10",
"@edge-runtime/vm": "^5.0.0",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.49.0",
"@storybook/addon-controls": "^8.4.7",
"@storybook/addon-themes": "^8.4.7",
"@storybook/manager-api": "^8.4.7",
"@storybook/react": "^8.4.7",
Expand Down
17 changes: 16 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions src/components/app/PasswordStrength/PasswordStrength.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Meta } from "@storybook/react";
import { PasswordStrength } from ".";

const meta: Meta<typeof PasswordStrength> = {
component: PasswordStrength,
argTypes: {
value: {
options: [0, 1, 2, 3, 4],
control: { type: "radio" },
},
},
};

export default meta;

export const VeryWeak = (args: any) => <PasswordStrength {...args} />;

VeryWeak.args = {
value: 0,
warning: "This is a top-10 common password.",
suggestions: ["Add another word or two"],
};

export const Weak = (args: any) => <PasswordStrength {...args} />;

Weak.args = {
value: 1,
};

export const Okay = (args: any) => <PasswordStrength {...args} />;

Okay.args = {
value: 2,
};

export const Good = (args: any) => <PasswordStrength {...args} />;

Good.args = {
value: 3,
};

export const Great = (args: any) => <PasswordStrength {...args} />;

Great.args = {
value: 4,
};
65 changes: 65 additions & 0 deletions src/components/app/PasswordStrength/PasswordStrength.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PasswordStrength, strengthConfig } from "./PasswordStrength";

describe("PasswordStrength", () => {
const strengthConfigs = Object.entries(strengthConfig).map(
([value, config]) => ({
value: Number(value),
label: config.label,
textClass: config.color.text,
bgClass: config.color.bg,
}),
);

it.each(strengthConfigs)(
"renders label $label and styles for value $value",
({ value, label, textClass, bgClass }) => {
render(<PasswordStrength value={value} />);

// Check strength label
const strengthLabel = screen.getByText(label);
expect(strengthLabel).toBeInTheDocument();
expect(strengthLabel).toHaveClass(textClass);

// Check meter background
const progressBar = screen.getByTestId("meter-fill");
expect(progressBar).toHaveClass(bgClass);
},
);

it("displays warning banner when warning prop is provided", () => {
const warningMessage = "Password is too common";
render(<PasswordStrength value={2} warning={warningMessage} />);

const warningBanner = screen.getByText(warningMessage);
expect(warningBanner).toBeInTheDocument();
});

it("displays suggestions banner when suggestions are provided", () => {
const suggestions = ["Add a number", "Include a special character"];
render(<PasswordStrength value={2} suggestions={suggestions} />);

for (const suggestion of suggestions) {
const suggestionItem = screen.getByText(suggestion);
expect(suggestionItem).toBeInTheDocument();
}
});

it("throws error for invalid strength values", () => {
const invalidValues = [-1, 5];

for (const value of invalidValues) {
expect(() => {
render(<PasswordStrength value={value as any} />);
}).toThrow("Value must be between 0 and 4");
}
});

it("renders without warnings or suggestions when not provided", () => {
render(<PasswordStrength value={3} />);

const warningBanner = screen.queryByRole("alert");
expect(warningBanner).not.toBeInTheDocument();
});
});
118 changes: 118 additions & 0 deletions src/components/app/PasswordStrength/PasswordStrength.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Banner, Label } from "@/components/common";
import { composeTailwindRenderProps } from "@/components/utils";
import {
Meter as AriaMeter,
type MeterProps as AriaMeterProps,
} from "react-aria-components";

export interface MeterProps extends AriaMeterProps {
value: number;
warning?: string;
suggestions?: string[];
}

type StrengthConfig = {
label: string;
color: {
text: string;
bg: string;
};
};

export const strengthConfig: Record<number, StrengthConfig> = {
0: {
label: "Very weak",
color: {
text: "text-red-9 dark:text-reddark-9",
bg: "bg-transparent",
},
},
1: {
label: "Weak",
color: {
text: "text-red-9 dark:text-reddark-9",
bg: "bg-red-9 dark:bg-reddark-9",
},
},
2: {
label: "Okay",
color: {
text: "text-gray-normal",
bg: "bg-amber-9 dark:bg-amberdark-9",
},
},
3: {
label: "Good",
color: {
text: "text-gray-normal",
bg: "bg-grass-9 dark:bg-grassdark-9",
},
},
4: {
label: "Great!",
color: {
text: "text-green-10 dark:text-greendark-10",
bg: "bg-green-9 dark:bg-greendark-9",
},
},
};

export function PasswordStrength({
value,
warning,
suggestions,
...props
}: MeterProps) {
if (value < 0 || value > 4) {
throw new Error("Value must be between 0 and 4");
}

return (
<div className="flex flex-col gap-3">
<AriaMeter
{...props}
className={composeTailwindRenderProps(
props.className,
"flex flex-col gap-1",
)}
value={value}
minValue={0}
maxValue={4}
>
{({ percentage }) => (
<>
<div className="w-full min-w-64 h-2 rounded-full bg-gray-4 dark:bg-graydark-4 outline outline-1 -outline-offset-1 outline-transparent relative overflow-hidden">
<div
className={`absolute top-0 left-0 h-full w-full rounded-full ${strengthConfig[value].color.bg} transition-all forced-colors:bg-[Highlight]`}
style={{ translate: `-${100 - percentage}%` }}
data-testid="meter-fill"
/>
</div>
<div className="flex justify-between gap-2">
<Label className="text-gray-dim">Password Strength</Label>
<span
className={`text-sm font-medium ${strengthConfig[value].color.text}`}
>
{strengthConfig[value].label}
</span>
</div>
</>
)}
</AriaMeter>
{warning && <Banner variant="danger">{warning}</Banner>}
{suggestions && (
<Banner variant="info">
<p>
<span>To fix this:</span>
<br />
<ul className="list-disc list-inside">
{suggestions.map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</p>
</Banner>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/components/app/PasswordStrength/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./PasswordStrength";
1 change: 1 addition & 0 deletions src/components/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./AppContent";
export * from "./AppSidebar";
export * from "./Logo";
export * from "./PageHeader";
export * from "./PasswordStrength";
2 changes: 1 addition & 1 deletion src/components/common/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface BannerProps {
}

const bannerStyles = tv({
base: "flex gap-2 p-3 pr-4 items-start w-full text-sm rounded-lg bg-gray-3 dark:bg-graydark-3 text-gray-dim",
base: "flex gap-2 p-2.5 px-3 pr-4 items-start w-full text-sm rounded-lg bg-gray-3 dark:bg-graydark-3 text-gray-dim",
variants: {
variant: {
info: "bg-blue-3 dark:bg-bluedark-3 text-blue-normal [&_a]:text-blue-normal",
Expand Down
15 changes: 0 additions & 15 deletions src/components/common/Meter/Meter.stories.tsx

This file was deleted.

60 changes: 0 additions & 60 deletions src/components/common/Meter/Meter.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/components/common/Meter/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export * from "./GridList";
export * from "./Link";
export * from "./ListBox";
export * from "./Menu";
export * from "./Meter";
export * from "./Modal";
export * from "./Nav";
export * from "./NumberField";
Expand Down

0 comments on commit 8414564

Please sign in to comment.