diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index de2ceed869..0b5b2636bf 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./password-input/password-input"; @import "./form-error-list/form-error-list"; @import "./table-of-contents/table-of-contents"; @import "./error-message/error-message"; diff --git a/packages/css/src/components/password-input/README.md b/packages/css/src/components/password-input/README.md new file mode 100644 index 0000000000..afe1f37891 --- /dev/null +++ b/packages/css/src/components/password-input/README.md @@ -0,0 +1,29 @@ + + +# Password Input + +Helps users enter a password. + +## Guidelines + +- Use this component when the input requires sensitive information, like passwords or PINs. + It ensures that the input is not readable by others who might be looking at the screen. +- The characters entered are hidden, represented by squares. + +This component sets `autocapitalize="none"`, `autocorrect="off"` and `spellcheck="false"` to stop browsers automatically changing user input. +Passwords shouldn’t be checked for spelling or grammar. +This may also prevent posting the password to third-party plugins. +These props cannot be overridden. + +Consider setting the following attributes: + +1. Allow the user’s password manager to automatically fill the password through `autocomplete="current-password"`. + When asking for a new password, use `autocomplete="new-password"` instead. +2. Do not add a `minlength` attribute to ensure passwords meet a minimum length requirement. + This would prematurely indicate an error to the user – while they are still typing. +3. Do not add a `maxlength` attribute either. + Users will not get any feedback when their text input has been truncated, e.g. after pasting from a password manager. +4. If the password is a numeric PIN, add `inputmode="numeric"`. + Devices with virtual keyboards then switch to a numeric keypad layout which makes entering the password easier. + +Follow the [guidelines for asking for passwords](https://design-system.service.gov.uk/patterns/passwords/) of the GOV.UK Design System. diff --git a/packages/css/src/components/password-input/password-input.scss b/packages/css/src/components/password-input/password-input.scss new file mode 100644 index 0000000000..ae2db8daa0 --- /dev/null +++ b/packages/css/src/components/password-input/password-input.scss @@ -0,0 +1,59 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@import "../../common/text-rendering"; + +@mixin reset { + -webkit-appearance: none; // Reset appearance for Safari < 15.4 + appearance: none; // Reset native appearance, this causes issues on iOS and Android devices + border: 0; + border-radius: 0; // Reset rounded borders on iOS devices + box-sizing: border-box; + margin-block: 0; +} + +.ams-password-input { + background-color: var(--ams-password-input-background-color); + box-shadow: var(--ams-password-input-box-shadow); + color: var(--ams-password-input-color); + font-family: var(--ams-password-input-font-family); + font-size: var(--ams-password-input-font-size); + font-weight: var(--ams-password-input-font-weight); + inline-size: 100%; + line-height: var(--ams-password-input-line-height); + outline-offset: var(--ams-password-input-outline-offset); + padding-block: var(--ams-password-input-padding-block); + padding-inline: var(--ams-password-input-padding-inline); + touch-action: manipulation; + + @include text-rendering; + @include reset; + + &:hover { + box-shadow: var(--ams-password-input-hover-box-shadow); + } +} + +.ams-password-input::placeholder { + color: var(--ams-password-input-placeholder-color); + opacity: 100%; // This resets the lower opacity set by Firefox +} + +.ams-password-input:disabled { + background-color: var(--ams-password-input-disabled-background-color); + box-shadow: var(--ams-password-input-disabled-box-shadow); + color: var(--ams-password-input-disabled-color); + cursor: not-allowed; +} + +.ams-password-input:invalid, +.ams-password-input[aria-invalid="true"] { + box-shadow: var(--ams-password-input-invalid-box-shadow); + + &:hover { + // TODO: this should be the (currently non-existent) dark red hover color + box-shadow: var(--ams-password-input-invalid-hover-box-shadow); + } +} diff --git a/packages/react/src/PasswordInput/PasswordInput.test.tsx b/packages/react/src/PasswordInput/PasswordInput.test.tsx new file mode 100644 index 0000000000..d66760b0b0 --- /dev/null +++ b/packages/react/src/PasswordInput/PasswordInput.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createRef, useState } from 'react' +import { PasswordInput } from './PasswordInput' +import { Label } from '../Label' +import '@testing-library/jest-dom' + +describe('Password input', () => { + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-password-input') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-password-input extra') + }) + + it('renders three attributes for privacy', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveAttribute('autocapitalize', 'none') + expect(component).toHaveAttribute('autocorrect', 'off') + expect(component).toHaveAttribute('spellcheck', 'false') + }) + + it('should be working in a controlled state', async () => { + function ControlledComponent() { + const [value, setValue] = useState('Hello') + + return setValue(e.target.value)} /> + } + + const { container } = render() + + const componentText = screen.getByDisplayValue('Hello') + + expect(componentText).toBeInTheDocument() + + const component = container.querySelector(':only-child') + if (component) { + await userEvent.type(component, ', World!') + } + + const newComponentText = screen.getByDisplayValue('Hello, World!') + + expect(newComponentText).toBeInTheDocument() + }) + + it('should not update the value when disabled', async () => { + const { container } = render() + + const component = container.querySelector(':only-child') + if (component) { + await userEvent.type(component, ', World!') + } + + expect(component).toHaveValue('Hello') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) + + describe('Invalid state', () => { + it('is not invalid by default', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).not.toBeInvalid() + }) + + it('can have an invalid state', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveAttribute('aria-invalid') + expect(component).toBeInvalid() + }) + + it('omits non-essential invalid attributes when not invalid', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).not.toHaveAttribute('aria-invalid') + }) + }) + + describe('Type', () => { + it('sets the ‘password’ type', () => { + render( + <> + + + , + ) + + const component = screen.getByLabelText(/password/i) + + expect(component).toHaveAttribute('type', 'password') + }) + }) +}) diff --git a/packages/react/src/PasswordInput/PasswordInput.tsx b/packages/react/src/PasswordInput/PasswordInput.tsx new file mode 100644 index 0000000000..3df1d9095c --- /dev/null +++ b/packages/react/src/PasswordInput/PasswordInput.tsx @@ -0,0 +1,34 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, InputHTMLAttributes } from 'react' + +export type PasswordInputProps = { + /** Whether the value fails a validation rule. */ + invalid?: boolean +} & Omit< + InputHTMLAttributes, + 'aria-invalid' | 'autoCapitalize' | 'autoCorrect' | 'spellCheck' | 'type' +> + +export const PasswordInput = forwardRef( + ({ className, dir, invalid, ...restProps }: PasswordInputProps, ref: ForwardedRef) => ( + + ), +) + +PasswordInput.displayName = 'PasswordInput' diff --git a/packages/react/src/PasswordInput/README.md b/packages/react/src/PasswordInput/README.md new file mode 100644 index 0000000000..725d78502d --- /dev/null +++ b/packages/react/src/PasswordInput/README.md @@ -0,0 +1,5 @@ + + +# React Password Input component + +[Password Input documentation](../../../css/src/components/password-input/README.md) diff --git a/packages/react/src/PasswordInput/index.ts b/packages/react/src/PasswordInput/index.ts new file mode 100644 index 0000000000..1d4d6d8a40 --- /dev/null +++ b/packages/react/src/PasswordInput/index.ts @@ -0,0 +1,2 @@ +export { PasswordInput } from './PasswordInput' +export type { PasswordInputProps } from './PasswordInput' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d1e4be314f..f616032c4e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './PasswordInput' export * from './FormErrorList' export * from './TableOfContents' export * from './ErrorMessage' diff --git a/proprietary/tokens/src/components/ams/password-input.tokens.json b/proprietary/tokens/src/components/ams/password-input.tokens.json new file mode 100644 index 0000000000..1b659b4cfd --- /dev/null +++ b/proprietary/tokens/src/components/ams/password-input.tokens.json @@ -0,0 +1,33 @@ +{ + "ams": { + "password-input": { + "background-color": { "value": "{ams.color.primary-white}" }, + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.primary-black}" }, + "color": { "value": "{ams.color.primary-black}" }, + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.5.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "line-height": { "value": "{ams.text.level.5.line-height}" }, + "outline-offset": { "value": "{ams.focus.outline-offset}" }, + "padding-block": { "value": "{ams.space.sm}" }, + "padding-inline": { "value": "{ams.space.md}" }, + "disabled": { + "background-color": { "value": "{ams.color.primary-white}" }, + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.neutral-grey2}" }, + "color": { "value": "{ams.color.neutral-grey2}" } + }, + "hover": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-black}" } + }, + "invalid": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.primary-red}" }, + "hover": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-red}" } + } + }, + "placeholder": { + "color": { "value": "{ams.color.neutral-grey3}" } + } + } + } +} diff --git a/storybook/src/components/PasswordInput/PasswordInput.docs.mdx b/storybook/src/components/PasswordInput/PasswordInput.docs.mdx new file mode 100644 index 0000000000..b06271db43 --- /dev/null +++ b/storybook/src/components/PasswordInput/PasswordInput.docs.mdx @@ -0,0 +1,13 @@ +{/* @license CC0-1.0 */} + +import { Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as PasswordInputStories from "./PasswordInput.stories.tsx"; +import README from "../../../../packages/css/src/components/password-input/README.md?raw"; + + + +{README} + + + + diff --git a/storybook/src/components/PasswordInput/PasswordInput.stories.tsx b/storybook/src/components/PasswordInput/PasswordInput.stories.tsx new file mode 100644 index 0000000000..a63801c366 --- /dev/null +++ b/storybook/src/components/PasswordInput/PasswordInput.stories.tsx @@ -0,0 +1,27 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { PasswordInput } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Components/Forms/Password Input', + component: PasswordInput, + args: { + disabled: false, + invalid: false, + }, + argTypes: { + disabled: { + description: 'Prevents interaction. Avoid if possible.', + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {}