Skip to content

Commit

Permalink
feat: Add initial Password Input component (#1449)
Browse files Browse the repository at this point in the history
  • Loading branch information
VincentSmedinga authored Jul 30, 2024
1 parent b7b5710 commit 3cc863e
Show file tree
Hide file tree
Showing 11 changed files with 333 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/css/src/components/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
29 changes: 29 additions & 0 deletions packages/css/src/components/password-input/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!-- @license CC0-1.0 -->

# 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.
59 changes: 59 additions & 0 deletions packages/css/src/components/password-input/password-input.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
129 changes: 129 additions & 0 deletions packages/react/src/PasswordInput/PasswordInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PasswordInput />)

const component = container.querySelector(':only-child')

expect(component).toBeInTheDocument()
expect(component).toBeVisible()
})

it('renders a design system BEM class name', () => {
const { container } = render(<PasswordInput />)

const component = container.querySelector(':only-child')

expect(component).toHaveClass('ams-password-input')
})

it('renders an additional class name', () => {
const { container } = render(<PasswordInput className="extra" />)

const component = container.querySelector(':only-child')

expect(component).toHaveClass('ams-password-input extra')
})

it('renders three attributes for privacy', () => {
const { container } = render(<PasswordInput />)

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 <PasswordInput value={value} onChange={(e) => setValue(e.target.value)} />
}

const { container } = render(<ControlledComponent />)

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(<PasswordInput disabled defaultValue="Hello" />)

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<HTMLInputElement>()

const { container } = render(<PasswordInput ref={ref} />)

const component = container.querySelector(':only-child')

expect(ref.current).toBe(component)
})

describe('Invalid state', () => {
it('is not invalid by default', () => {
const { container } = render(<PasswordInput />)

const component = container.querySelector(':only-child')

expect(component).not.toBeInvalid()
})

it('can have an invalid state', () => {
const { container } = render(<PasswordInput invalid />)

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(<PasswordInput invalid={false} />)

const component = container.querySelector(':only-child')

expect(component).not.toHaveAttribute('aria-invalid')
})
})

describe('Type', () => {
it('sets the ‘password’ type', () => {
render(
<>
<Label htmlFor="password-field">Password</Label>
<PasswordInput id="password-field" />
</>,
)

const component = screen.getByLabelText(/password/i)

expect(component).toHaveAttribute('type', 'password')
})
})
})
34 changes: 34 additions & 0 deletions packages/react/src/PasswordInput/PasswordInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>,
'aria-invalid' | 'autoCapitalize' | 'autoCorrect' | 'spellCheck' | 'type'
>

export const PasswordInput = forwardRef(
({ className, dir, invalid, ...restProps }: PasswordInputProps, ref: ForwardedRef<HTMLInputElement>) => (
<input
{...restProps}
aria-invalid={invalid || undefined}
autoCapitalize="none"
autoCorrect="off"
className={clsx('ams-password-input', className)}
dir={dir ?? 'auto'}
ref={ref}
spellCheck="false"
type="password"
/>
),
)

PasswordInput.displayName = 'PasswordInput'
5 changes: 5 additions & 0 deletions packages/react/src/PasswordInput/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- @license CC0-1.0 -->

# React Password Input component

[Password Input documentation](../../../css/src/components/password-input/README.md)
2 changes: 2 additions & 0 deletions packages/react/src/PasswordInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { PasswordInput } from './PasswordInput'
export type { PasswordInputProps } from './PasswordInput'
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
export * from './PasswordInput'
export * from './FormErrorList'
export * from './TableOfContents'
export * from './ErrorMessage'
Expand Down
33 changes: 33 additions & 0 deletions proprietary/tokens/src/components/ams/password-input.tokens.json
Original file line number Diff line number Diff line change
@@ -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}" }
}
}
}
}
13 changes: 13 additions & 0 deletions storybook/src/components/PasswordInput/PasswordInput.docs.mdx
Original file line number Diff line number Diff line change
@@ -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";

<Meta of={PasswordInputStories} />

<Markdown>{README}</Markdown>

<Primary />

<Controls />
27 changes: 27 additions & 0 deletions storybook/src/components/PasswordInput/PasswordInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof PasswordInput>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

0 comments on commit 3cc863e

Please sign in to comment.