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:
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 |
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -158,6 +158,7 @@ export type MetabaseColorKey =
|
||||
| "feedback-warning-strong"
|
||||
| "illustration-secondary-brand"
|
||||
| "illustration-tertiary-brand"
|
||||
| "input-background"
|
||||
| "input-focus"
|
||||
| "modal-overlay"
|
||||
| "shadow-default"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { PasswordInput } from "@mantine/core";
|
||||
export type { PasswordInputProps } from "@mantine/core";
|
||||
export { passwordInputOverrides } from "./PasswordInput.config";
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user