Skip to content

Commit

Permalink
feat: Add Search Field component (#892)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Smedinga <[email protected]>
  • Loading branch information
alimpens and VincentSmedinga authored Dec 8, 2023
1 parent 9afe482 commit dd1a437
Show file tree
Hide file tree
Showing 16 changed files with 532 additions and 4 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 "./search-field/search-field";
@import "./logo/logo";
@import "./dialog/dialog";
@import "./image/image";
Expand Down
33 changes: 33 additions & 0 deletions packages/css/src/components/search-field/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Search Field

Met een Search Field (in het Nederlands: zoekveld) kunnen gebruikers snel relevante inhoud vinden. Een gebruiker voert een (deel van een) woord of zin in om daarmee inhoud te doorzoeken.

## Richtlijnen

- Je kunt een zoekactie starten door de zoekknop of de Enter-toets te gebruiken.
- Als er geen zoekterm is ingevuld kan geen zoekactie gestart worden.

## Autofocus

Je kunt `autofocus` gebruiken om de focus gelijk in een zoekveld te plaatsen als de gebruiker op een pagina komt.
Wees hier wel voorzichtig mee, dit kan negatieve gevolgen hebben:

- Voor gebruikers van een schermlezer kan dit betekenen dat ze belangrijk inhoud boven het zoekveld overslaan.
- Op kleinere apparaten kan het gebruik van `autofocus` er voor zorgen dat de pagina automatisch naar het zoekveld scrollt, waardoor je eerdere inhoud kan missen.
- Op apparaten met touchscreen kan dit ervoor zorgen dat het toetsenbord gelijk wordt getoond, waardoor belangrijke inhoud verborgen wordt.

Gebruik `autofocus` alleen als het zoekveld aan het begin van een pagina staat, en er geen andere elementen op een pagina staan waar een gebruiker misschien eerst gebruik van wil maken.

Voor meer informatie: [Accessibility Tips: Be Cautious When Using Autofocus](https://www.boia.org/blog/accessibility-tips-be-cautious-when-using-autofocus)

## Autocomplete en spellcheck

`autocomplete` en `spellcheck` staan standaard uit. Deze functies kunnen vervelend zijn voor een gebruiker die zoekt op een deel van een woord, en `autocomplete` kan in de weg zitten van een Autosuggest component.

## Relevante WCAG eisen

- [WCAG 1.3.1](https://www.w3.org/TR/WCAG22/#info-and-relationships): `role="search"` wordt gebruikt voor de search landmark role.
- [WCAG 1.3.5](https://www.w3.org/TR/WCAG22/#identify-input-purpose): het is zowel voor een gebruiker als programmatisch duidelijk wat het doel van een formulierveld is.
- [WCAG 2.4.6](https://www.w3.org/TR/WCAG22/#headings-and-labels): er is een label dat het doel van de input beschrijft.

Search Field is een interactief element, hier gelden [de algemene eisen en richtlijnen voor interactieve elementen](https://amsterdam.github.io/design-system/?path=/docs/docs-designrichtlijnen-interactieve-elementen--docs) voor.
81 changes: 81 additions & 0 deletions packages/css/src/components/search-field/search-field.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license EUPL-1.2+
* Copyright (c) 2023 Gemeente Amsterdam
*/

.amsterdam-search-field {
display: flex;
isolation: isolate; // create new stacking context, so the input's z-index doesn't escape the component
}

@mixin reset {
box-sizing: border-box;
margin-block: 0;
-webkit-text-size-adjust: 100%;
}

.amsterdam-search-field__input {
border: none;
box-shadow: var(--amsterdam-search-field-input-box-shadow);
color: var(--amsterdam-search-field-input-color);
font-family: var(--amsterdam-search-field-input-font-family);
font-size: var(--amsterdam-search-field-input-spacious-font-size);
font-weight: var(--amsterdam-search-field-input-font-weight);
line-height: var(--amsterdam-search-field-input-spacious-line-height);
outline-offset: var(--amsterdam-search-field-input-outline-offset);
padding-block: 0.5rem;
padding-inline: 1rem;
touch-action: manipulation;
width: 100%;

@include reset;

.amsterdam-theme--compact & {
font-size: var(--amsterdam-search-field-input-compact-font-size);
line-height: var(--amsterdam-search-field-input-compact-line-height);
}

&:hover {
box-shadow: var(--amsterdam-search-field-input-hover-box-shadow);
}

&:focus {
z-index: 1; // Make sure the focus outline isn't cut off by the adjacent button
}
}

.amsterdam-search-field__input::placeholder {
color: var(--amsterdam-search-field-input-placeholder-color);
}

.amsterdam-search-field__input::-webkit-search-cancel-button {
appearance: none;
background-image: var(--amsterdam-search-field-input-cancel-button-background-image);
cursor: pointer;
height: var(--amsterdam-search-field-input-cancel-button-height);
margin-inline-start: 0.5rem;
width: var(--amsterdam-search-field-input-cancel-button-width);
}

.amsterdam-search-field__button {
background-color: var(--amsterdam-search-field-button-background-color);
border: 0;
color: var(--amsterdam-search-field-button-color);
cursor: pointer;
outline-offset: var(--amsterdam-search-field-button-outline-offset);
padding-block: 0.5rem;
padding-inline: 0.5rem;
touch-action: manipulation;

span {
display: flex;
justify-content: center;

// use icon tokens to set width equal to height
width: calc(var(--amsterdam-icon-spacious-size-6-font-size) * var(--amsterdam-icon-spacious-size-6-line-height));
}

&:hover {
background-color: var(--amsterdam-search-field-button-hover-background-color);
}
}
3 changes: 3 additions & 0 deletions packages/react/src/SearchField/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# React Search Field component

[Search Field documentation](../../../css/src/search-field/README.md)
61 changes: 61 additions & 0 deletions packages/react/src/SearchField/SearchField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { SearchField } from './SearchField'
import '@testing-library/jest-dom'

describe('Search field', () => {
it('renders the outer container', () => {
render(<SearchField />)

const component = screen.getByRole('search')

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

it('renders the input', () => {
render(<SearchField.Input />)

const component = screen.getByRole('searchbox')

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

it('renders the button', () => {
render(<SearchField.Button />)

const component = screen.getByRole('button')

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

it('renders the outer container design system BEM class name', () => {
render(<SearchField />)

const component = screen.getByRole('search')

expect(component).toHaveClass('amsterdam-search-field')
})

it('renders an additional class name', () => {
render(<SearchField className="extra" />)

const component = screen.getByRole('search')

expect(component).toHaveClass('extra')

expect(component).toHaveClass('amsterdam-search-field')
})

it('supports ForwardRef in React', () => {
const ref = createRef<HTMLFormElement>()

render(<SearchField ref={ref} />)

const component = screen.getByRole('search')

expect(ref.current).toBe(component)
})
})
40 changes: 40 additions & 0 deletions packages/react/src/SearchField/SearchField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license EUPL-1.2+
* Copyright (c) 2023 Gemeente Amsterdam
*/

import clsx from 'clsx'
import {
ForwardedRef,
forwardRef,
ForwardRefExoticComponent,
HTMLAttributes,
PropsWithChildren,
RefAttributes,
} from 'react'
import { SearchFieldButton } from './SearchFieldButton'
import { SearchFieldInput } from './SearchFieldInput'

export interface SearchFieldProps extends PropsWithChildren<HTMLAttributes<HTMLFormElement>> {}

export interface SearchFieldComponent
extends ForwardRefExoticComponent<SearchFieldProps & RefAttributes<HTMLFormElement>> {
Input: typeof SearchFieldInput
Button: typeof SearchFieldButton
}

export const SearchField = forwardRef(
({ children, className, ...restProps }: SearchFieldProps, ref: ForwardedRef<HTMLFormElement>) => {
return (
<form role="search" {...restProps} ref={ref} className={clsx('amsterdam-search-field', className)}>
{children}
</form>
)
},
) as SearchFieldComponent

SearchField.Input = SearchFieldInput
SearchField.Button = SearchFieldButton
SearchField.displayName = 'SearchField'
SearchField.Input.displayName = 'SearchField.Input'
SearchField.Button.displayName = 'SearchField.Button'
24 changes: 24 additions & 0 deletions packages/react/src/SearchField/SearchFieldButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* @license EUPL-1.2+
* Copyright (c) 2023 Gemeente Amsterdam
*/

import { SearchIcon } from '@amsterdam/design-system-react-icons'
import clsx from 'clsx'
import { ForwardedRef, forwardRef, HTMLAttributes } from 'react'
import { Icon } from '../Icon'
import { VisuallyHidden } from '../VisuallyHidden'

interface SearchFieldButtonProps extends HTMLAttributes<HTMLButtonElement> {}

// TODO: replace this with IconButton when that's done
export const SearchFieldButton = forwardRef(
({ className, ...restProps }: SearchFieldButtonProps, ref: ForwardedRef<HTMLButtonElement>) => (
<button {...restProps} ref={ref} className={clsx('amsterdam-search-field__button', className)}>
<VisuallyHidden>Zoeken</VisuallyHidden>
<Icon svg={SearchIcon} size="level-6" />
</button>
),
)

SearchFieldButton.displayName = 'SearchFieldButton'
74 changes: 74 additions & 0 deletions packages/react/src/SearchField/SearchFieldInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef, useState } from 'react'
import { SearchFieldInput } from './SearchFieldInput'
import '@testing-library/jest-dom'

describe('Search field input', () => {
it('renders', () => {
render(<SearchFieldInput />)

const component = screen.getByRole('searchbox', { name: 'Zoeken' })

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

it('renders a design system BEM class name', () => {
render(<SearchFieldInput />)

const component = screen.getByRole('searchbox', { name: 'Zoeken' })

expect(component).toHaveClass('amsterdam-search-field__input')
})

it('renders an additional class name', () => {
render(<SearchFieldInput className="extra" />)

const component = screen.getByRole('searchbox', { name: 'Zoeken' })

expect(component).toHaveClass('extra')

expect(component).toHaveClass('amsterdam-search-field__input')
})

it('supports a custom label', () => {
render(<SearchFieldInput label="Test label" />)

const component = screen.getByRole('searchbox', { name: 'Test label' })

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

it('should be working in a controlled state', async () => {
function ControlledComponent() {
const [value, setValue] = useState('Hello')

return <SearchFieldInput value={value} onChange={(e) => setValue(e.target.value)} />
}

render(<ControlledComponent />)

const componentText = screen.getByDisplayValue('Hello')
expect(componentText).toBeInTheDocument()

const component = screen.getByRole('searchbox', { name: 'Zoeken' })
if (component) {
await userEvent.type(component, ', World!')
}

const newComponentText = screen.getByDisplayValue('Hello, World!')
expect(newComponentText).toBeInTheDocument()
})

it('supports ForwardRef in React', () => {
const ref = createRef<HTMLInputElement>()

render(<SearchFieldInput ref={ref} />)

const component = screen.getByRole('searchbox', { name: 'Zoeken' })

expect(ref.current).toBe(component)
})
})
38 changes: 38 additions & 0 deletions packages/react/src/SearchField/SearchFieldInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @license EUPL-1.2+
* Copyright (c) 2023 Gemeente Amsterdam
*/

import clsx from 'clsx'
import { ForwardedRef, forwardRef, InputHTMLAttributes, useId } from 'react'
import { VisuallyHidden } from '../VisuallyHidden'

interface SearchFieldInputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
}

export const SearchFieldInput = forwardRef(
({ className, label = 'Zoeken', ...restProps }: SearchFieldInputProps, ref: ForwardedRef<HTMLInputElement>) => {
const id = useId()

return (
<>
<label htmlFor={id}>
<VisuallyHidden>{label}</VisuallyHidden>
</label>
<input
{...restProps}
autoComplete="off"
className={clsx('amsterdam-search-field__input', className)}
enterKeyHint="search"
id={id}
ref={ref}
spellCheck="false"
type="search"
/>
</>
)
},
)

SearchFieldInput.displayName = 'SearchFieldInput'
2 changes: 2 additions & 0 deletions packages/react/src/SearchField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SearchField } from './SearchField'
export type { SearchFieldProps } from './SearchField'
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 './SearchField'
export * from './Logo'
export * from './Dialog'
export * from './Image'
Expand Down
Loading

0 comments on commit dd1a437

Please sign in to comment.