GDGT-2478 PasswordInput (#82117)

* test(ui): add PasswordInput Storybook story

* test(ui): add PasswordInput to Loki as baseline for restyle

* feat(ui): restyle PasswordInput from Figma and add input-background token

* test: cover PasswordInput visibility toggle in unit and e2e tests

* fix(PasswordInput): use highlight border color in focused error mode by overriding Input styles

* Update Loki Snapshots

* test(ui): add Loki reference for PasswordInput Overview story

* fix(PasswordInput): address review feedback

- keep border width constant and use an inset ring for focus/error to avoid layout shift
- tie right padding to the toggle button size so text clears it in lg
- restrict size prop to sm | md | lg
- order color keys alphabetically
- explicit visibility assertion in the e2e spec

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
This commit is contained in:
Anna Tomka
2026-09-11 14:18:41 +02:00
committed by GitHub
parent 38b7b8c0b7
commit ca17b89463
14 changed files with 374 additions and 22 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 374 KiB

View File

@@ -303,11 +303,25 @@ describe("scenarios > admin > people", () => {
cy.button("Reset password").click();
});
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText(`${normalUserName}'s password has been reset`);
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText(/^temporary password$/i);
clickButton("Done");
H.modal().within(() => {
cy.findByText(`${normalUserName}'s password has been reset`).should(
"be.visible",
);
cy.findByLabelText(/^temporary password$/i).should(
"have.attr",
"type",
"password",
);
cy.findByLabelText("Toggle password visibility").click();
cy.findByLabelText(/^temporary password$/i).should(
"have.attr",
"type",
"text",
);
clickButton("Done");
});
cy.location().should((loc) =>
expect(loc.pathname).to.eq("/admin/people"),
);

View File

@@ -21,14 +21,13 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
return (
<Input.Wrapper
label={t`Temporary password`}
labelProps={{ htmlFor: inputId }}
labelProps={{ htmlFor: inputId, mb: "xs" }}
>
<Flex gap="lg" align="center" wrap="nowrap">
<PasswordInput
id={inputId}
value={password}
readOnly
radius="lg"
size="lg"
flex={1}
/>

View File

@@ -1,3 +1,5 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import { PasswordReveal } from "metabase/common/components/PasswordReveal";
@@ -11,6 +13,21 @@ describe("password reveal", () => {
);
});
it("reveals the temporary password when clicking the eye icon", async () => {
renderWithProviders(<PasswordReveal password="password" />);
const input = screen.getByLabelText("Temporary password");
const toggle = screen.getByLabelText("Toggle password visibility");
expect(input).toHaveAttribute("type", "password");
await userEvent.click(toggle);
expect(input).toHaveAttribute("type", "text");
await userEvent.click(toggle);
expect(input).toHaveAttribute("type", "password");
});
it("renders a copy button", () => {
renderWithProviders(<PasswordReveal password="password" />);
expect(screen.getByTestId("copy-button")).toBeInTheDocument();

View File

@@ -167,6 +167,7 @@ const actualColors = {
"feedback-warning-strong": baseColors.dubloon[20],
"illustration-secondary-brand": baseColors.brand[80],
"illustration-tertiary-brand": baseColors.brand[90],
"input-background": baseColors.orionAlphaInverse[10],
"input-focus": baseColors.blue[70],
"modal-overlay": baseColors.orionAlpha[70],
"navbar-admin": baseColors.octopus[80],

View File

@@ -165,6 +165,7 @@ const actualColors = {
"feedback-warning-strong": baseColors.mango[60],
"illustration-secondary-brand": baseColors.brand[10],
"illustration-tertiary-brand": baseColors.brand[5],
"input-background": baseColors.orionAlpha[5],
"input-focus": baseColors.blue[20],
"modal-overlay": baseColors.orionAlpha[60],
"navbar-admin": baseColors.octopus[60],

View File

@@ -158,6 +158,7 @@ export type MetabaseColorKey =
| "feedback-warning-strong"
| "illustration-secondary-brand"
| "illustration-tertiary-brand"
| "input-background"
| "input-focus"
| "modal-overlay"
| "shadow-default"

View File

@@ -1,7 +1,26 @@
import { PasswordInput } from "@mantine/core";
import { PasswordInput, rem } from "@mantine/core";
import { t } from "ttag";
import { Icon } from "../../icons";
import Styles from "./PasswordInput.module.css";
const ICON_SIZE = rem(12);
// Button = icon + horizontal inset on both sides, so the eye lands at the
// same distance from the edge as the text (6 / 8 / 12px).
const TOGGLE_BUTTON_SIZE: Record<string, string> = {
sm: rem(24),
md: rem(28),
lg: rem(36),
};
const VisibilityToggleIcon = ({ reveal }: { reveal: boolean }) => (
<Icon
name={reveal ? "eye_crossed_out" : "eye"}
style={{ width: "var(--psi-icon-size)", height: "var(--psi-icon-size)" }}
/>
);
export const passwordInputOverrides = {
PasswordInput: PasswordInput.extend({
defaultProps: {
@@ -10,11 +29,28 @@ export const passwordInputOverrides = {
errorProps: {
role: "alert",
},
visibilityToggleIcon: VisibilityToggleIcon,
visibilityToggleButtonProps: {
// eslint-disable-next-line ttag/no-module-declaration
"aria-label": t`Toggle password visibility`,
variant: "transparent",
},
},
classNames: {
root: Styles.root,
wrapper: Styles.wrapper,
label: Styles.label,
input: Styles.input,
innerInput: Styles.innerInput,
section: Styles.section,
visibilityToggle: Styles.visibilityToggle,
error: Styles.error,
},
vars: (_theme, { size = "md" }) => ({
root: {
"--psi-icon-size": ICON_SIZE,
"--psi-button-size": TOGGLE_BUTTON_SIZE[size] ?? TOGGLE_BUTTON_SIZE.md,
},
}),
}),
};

View File

@@ -1,17 +1,122 @@
.input {
color: var(--mb-color-text-primary);
background: var(--mb-color-background_page-primary);
border-color: var(--mb-color-border-neutral);
}
.root {
/*
* 1. Shared input styles
*
* Override the base Input styles (Input.module.css) with the new
* design-system values. Once TextInput (GDGT-2479) restyles the base, move
* them there and drop whatever became redundant (see #82117).
*/
.label {
margin-bottom: var(--mantine-spacing-xs);
}
.innerInput {
&::placeholder {
&[data-size="sm"] .label {
font-size: var(--mantine-font-size-sm);
line-height: var(--mantine-line-height-sm);
}
&:has(.innerInput:disabled) .label {
color: var(--mb-color-text-disabled);
}
}
.error {
&::first-letter {
text-transform: capitalize;
.wrapper {
--input-height-sm: rem(24px);
--input-height-md: rem(32px);
--input-height-lg: rem(40px);
--input-radius: var(--mantine-radius-xs);
}
&[data-size="lg"] .wrapper {
--input-radius: var(--mantine-radius-sm);
}
.input {
--input-padding-inline-start: var(--mantine-spacing-sm);
--input-padding-inline-end: var(--mantine-spacing-sm);
--input-border-color: var(--mb-color-border-neutral-strongest);
--input-ring: 0 0 0 0 transparent;
border: 0.5px solid var(--input-border-color);
border-radius: var(--input-radius);
color: var(--mb-color-text-primary);
background-color: var(--mb-color-input-background);
font-size: var(--mantine-font-size-md);
box-shadow: var(--input-ring);
&:focus-within {
--input-border-color: var(--mb-color-border-highlight);
--input-ring: inset 0 0 0 0.5px var(--input-border-color);
background-color: var(--mb-color-background_surface-primary);
}
&[data-error] {
--input-border-color: var(--mb-color-feedback-negative-strong);
--input-ring: inset 0 0 0 0.5px var(--input-border-color);
&:focus-within {
--input-border-color: var(--mb-color-border-highlight);
}
}
&[data-disabled] {
--input-border-color: transparent;
color: var(--mb-color-text-disabled);
background-color: var(--mb-color-background_surface-disabled);
opacity: 1; /* otherwise Mantine fades disabled inputs to 0.6 */
}
}
&[data-size="sm"] .input {
--input-padding-inline-start: var(--mantine-spacing-xs);
--input-padding-inline-end: var(--mantine-spacing-xs);
}
&[data-size="lg"] .input {
--input-padding-inline-start: var(--mantine-spacing-md);
}
/* The base Input sets border-color directly for hover and [data-error];
re-assert our state variable with higher specificity. */
.wrapper .input[data-variant="default"],
.wrapper .input[data-variant="default"]:hover {
border-color: var(--input-border-color);
}
.innerInput {
line-height: rem(20px);
&::placeholder {
color: var(--mb-color-text-disabled);
}
}
.error {
color: var(--mb-color-feedback-negative-strong);
margin-top: var(--mantine-spacing-xs);
&::first-letter {
text-transform: capitalize;
}
}
/* PasswordInput specific styles */
.wrapper[data-with-right-section] .input {
--input-padding-inline-end: var(--psi-button-size);
}
.visibilityToggle {
color: var(--mb-color-icon-primary);
&:disabled {
display: inline-flex;
color: var(--mb-color-icon-disabled);
pointer-events: none;
}
}
.section[data-position="right"] {
width: var(--psi-button-size);
}
}

View File

@@ -0,0 +1,163 @@
import type { StoryFn } from "@storybook/react";
import { Fragment } from "react";
import { Box, PasswordInput, type PasswordInputProps, Text } from "metabase/ui";
import { StoryJsx, StoryShowcase } from "metabase/ui/stories/showcase";
const args = {
label: "Password",
placeholder: "Enter your password",
description: undefined,
error: undefined,
disabled: false,
};
const argTypes = {
label: {
control: { type: "text" },
},
placeholder: {
control: { type: "text" },
},
description: {
control: { type: "text" },
},
error: {
control: { type: "text" },
},
disabled: {
control: { type: "boolean" },
},
size: {
options: ["sm", "md", "lg"],
control: { type: "inline-radio" },
},
};
export default {
title: "Components/Inputs/PasswordInput",
component: PasswordInput,
args,
argTypes,
};
const Template: StoryFn<PasswordInputProps> = (args) => (
<PasswordInput {...args} />
);
export const Default = {
render: Template,
};
const SIZES = ["sm", "md", "lg"] as const;
const ERROR_MESSAGE = "Required field";
const FILLED_VALUE = "hunter2";
const STATES = [
{ id: "default-empty", label: "Default, empty", props: {} },
{
id: "default-filled",
label: "Default, filled",
props: { defaultValue: FILLED_VALUE },
},
{ id: "focused-empty", label: "Focused, empty", props: {}, focus: true },
{
id: "focused-filled",
label: "Focused, filled",
props: { defaultValue: FILLED_VALUE },
focus: true,
},
{
id: "error-empty",
label: "Error, empty",
props: { error: ERROR_MESSAGE },
},
{
id: "error-filled",
label: "Error, filled",
props: { error: ERROR_MESSAGE, defaultValue: FILLED_VALUE },
},
{
id: "error-focused-empty",
label: "Error + Focused, empty",
props: { error: ERROR_MESSAGE },
focus: true,
},
{
id: "error-focused-filled",
label: "Error + Focused, filled",
props: { error: ERROR_MESSAGE, defaultValue: FILLED_VALUE },
focus: true,
},
{
id: "disabled-empty",
label: "Disabled, empty",
props: { disabled: true },
},
{
id: "disabled-filled",
label: "Disabled, filled",
props: { disabled: true, defaultValue: FILLED_VALUE },
},
] satisfies {
id: string;
label: string;
props: Partial<PasswordInputProps>;
focus?: boolean;
}[];
// Forces the "Focused" rows via storybook-addon-pseudo-states. Mantine forwards
// data-state-row to the inner <input>, but the focus styles live on the .input
// div around it, so the selector targets that parent and the story uses
// `focusWithin` to match the component's :focus-within rules.
const focusSelector = (id: string) => `:has(> [data-state-row="${id}"])`;
const OverviewTemplate: StoryFn<PasswordInputProps> = () => (
<StoryShowcase title="PasswordInput">
<Box
style={{
display: "grid",
gridTemplateColumns: `14rem repeat(${SIZES.length}, max-content)`,
columnGap: "2rem",
rowGap: "1rem",
alignItems: "center",
}}
>
<div />
{SIZES.map((size) => (
<StoryJsx key={size}>{`<PasswordInput size="${size}" />`}</StoryJsx>
))}
{STATES.map((state) => (
<Fragment key={state.id}>
<Text size="sm" c="text-secondary">
{state.label}
</Text>
{SIZES.map((size) => (
<Box key={`${state.id}-${size}`} w={256}>
<PasswordInput
data-state-row={state.id}
label="Password"
size={size}
placeholder="Password"
{...state.props}
/>
</Box>
))}
</Fragment>
))}
</Box>
</StoryShowcase>
);
export const Overview = {
render: OverviewTemplate,
parameters: {
pseudo: {
focusWithin: STATES.filter((state) => state.focus).map((state) =>
focusSelector(state.id),
),
},
controls: { include: ["theme"] },
},
};

View File

@@ -1,3 +0,0 @@
export { PasswordInput } from "@mantine/core";
export type { PasswordInputProps } from "@mantine/core";
export { passwordInputOverrides } from "./PasswordInput.config";

View File

@@ -0,0 +1,17 @@
import {
PasswordInput as MantinePasswordInput,
type PasswordInputProps as MantinePasswordInputProps,
} from "@mantine/core";
import { forwardRef } from "react";
export type PasswordInputProps = Omit<MantinePasswordInputProps, "size"> & {
size?: "sm" | "md" | "lg";
};
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput(props, ref) {
return <MantinePasswordInput {...props} ref={ref} />;
},
);
export { passwordInputOverrides } from "./PasswordInput.config";

View File

@@ -29,6 +29,7 @@ module.exports = {
"^Components/Navigation/NavLink Overview",
"^Components/Data display/KeyboardShortcut Overview",
"^Components/Table",
"^Components/Inputs/PasswordInput Overview",
"^App/Palette",
"^viz/GridMapPdfExport",
"ParameterValueWidget",