Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 96 additions & 57 deletions src/tedi/components/form/search/search.stories.tsx
Comment thread
airikej marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -31,88 +31,89 @@ export default meta;
type Story = StoryObj<SearchProps>;

const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'];
const sizeArray: SearchProps['size'][] = ['small', 'default', 'large'];

interface TemplateStateProps extends SearchProps {
array: typeof stateArray;
}

const sizeArray: SearchProps['size'][] = ['small', 'default', 'large'];

interface TemplateMultipleProps<Type = SearchProps['size']> extends SearchProps {
array: Type[];
property: keyof SearchProps;
}

const TemplateColumn: StoryFn<TemplateMultipleProps> = (args) => {
const { array, property, ...textFieldProps } = args;
const { array, property, id = 'search', ...textFieldProps } = args;

return (
<div className="example-list">
{array.map((value, key) => (
<Row className={`${key === array.length - 1 ? '' : 'border-bottom'} padding-14-16`} key={key}>
<Col width={2}>
<Text modifiers="bold">{value ? value.charAt(0).toUpperCase() + value.slice(1) : ''}</Text>
</Col>
<Col>
<VerticalSpacing>
<Search {...textFieldProps} {...{ [property]: value }} />
<Search {...textFieldProps} button={{ icon: 'search', size: value }} {...{ [property]: value }} />
<Search
{...textFieldProps}
button={{ iconLeft: 'search', children: 'Otsi', size: value }}
{...{ [property]: value }}
/>
</VerticalSpacing>
</Col>
</Row>
))}
{array.map((value, key) => {
const baseId = `${id}-${property}-${value}`;

return (
<Row className={`${key === array.length - 1 ? '' : 'border-bottom'} padding-14-16`} key={key}>
<Col width={2}>
<Text modifiers="bold">{value ? value.charAt(0).toUpperCase() + value.slice(1) : ''}</Text>
</Col>
<Col>
<VerticalSpacing>
<Search {...textFieldProps} {...{ [property]: value }} id={`${baseId}-plain`} />
<Search
{...textFieldProps}
{...{ [property]: value }}
id={`${baseId}-icon`}
button={{ icon: 'search', size: value, 'aria-label': 'Search' }}
/>
<Search
{...textFieldProps}
{...{ [property]: value }}
id={`${baseId}-button`}
button={{ iconLeft: 'search', children: 'Search', size: value }}
/>
</VerticalSpacing>
</Col>
</Row>
);
})}
</div>
);
};

const TemplateColumnWithStates: StoryFn<TemplateStateProps> = (args) => {
const { array, ...textFieldProps } = args;
const { array, id = 'search', ...textFieldProps } = args;

return (
<div className="state-example">
{array.map((state, index) => (
<Row key={index} className="padding-14-16">
<Col lg={2} md={12} className="display-flex align-items-center">
<Text modifiers="bold">{state}</Text>
</Col>
<Col lg={10} md={12} className="display-flex align-items-center">
<Search disabled={state === 'Disabled'} {...textFieldProps} id={state} />
</Col>
</Row>
))}
{array.map((state, index) => {
const stateId = `${id}-${state.toLowerCase()}`;

return (
<Row key={index} className="padding-14-16">
<Col lg={2} md={12} className="display-flex align-items-center">
<Text modifiers="bold">{state}</Text>
</Col>
<Col lg={10} md={12} className="display-flex align-items-center">
<Search {...textFieldProps} id={stateId} disabled={state === 'Disabled'} />
</Col>
</Row>
);
})}

<Row className="padding-14-16">
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">Success</Text>
</Col>
<Col className="display-flex align-items-center">
<Search
{...textFieldProps}
id="success-search"
helper={{
text: 'Feedback text',
type: 'valid',
}}
/>
<Search {...textFieldProps} id={`${id}-success`} helper={{ text: 'Feedback text', type: 'valid' }} />
</Col>
</Row>

<Row className="padding-14-16">
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">Error</Text>
</Col>
<Col className="display-flex align-items-center">
<Search
{...textFieldProps}
id="error-search"
helper={{
text: 'Feedback text',
type: 'error',
}}
/>
<Search {...textFieldProps} id={`${id}-error`} helper={{ text: 'Feedback text', type: 'error' }} />
</Col>
</Row>
</div>
Expand All @@ -121,16 +122,16 @@ const TemplateColumnWithStates: StoryFn<TemplateStateProps> = (args) => {

export const Default: Story = {
args: {
id: 'example-1',
id: 'search-default',
label: 'Search',
placeholder: 'Search by name or keyword',
},
};

export const Sizes: StoryObj<TemplateMultipleProps> = {
render: TemplateColumn,

args: {
id: 'example-1',
id: 'search-sizes',
label: 'Search',
property: 'size',
array: sizeArray,
Expand All @@ -142,6 +143,7 @@ export const States: StoryObj<TemplateStateProps> = {
args: {
array: stateArray,
label: 'Search',
id: 'search-states',
},
parameters: {
pseudo: {
Expand All @@ -154,15 +156,15 @@ export const States: StoryObj<TemplateStateProps> = {

export const Placeholder: Story = {
args: {
id: 'example-1',
id: 'search-placeholder',
label: 'Search',
placeholder: 'Name',
placeholder: 'Type something...',
},
};

export const Clearable: Story = {
args: {
id: 'example-1',
id: 'search-clearable',
label: 'Search',
isClearable: true,
value: 'Lorem ipsum',
Expand All @@ -171,18 +173,55 @@ export const Clearable: Story = {

export const ClearableButton: Story = {
args: {
id: 'example-1',
id: 'search-clearable-button',
label: 'Search',
isClearable: true,
value: 'Lorem ipsum',
button: { iconLeft: 'search', children: 'Otsi' },
button: { iconLeft: 'search', children: 'Search' },
},
};

export const WithHint: Story = {
args: {
id: 'example-1',
id: 'search-with-hint',
label: 'Search',
helper: { text: 'Hint text' },
},
};

export const Estonian: Story = {
args: {
id: 'search-et',
label: 'Otsing',
placeholder: 'Otsi tooteid, artikleid või abiinfot...',
ariaLabel: 'Otsi kogu saidilt',
button: { iconLeft: 'search', children: 'Otsi' },
},
};

export const AccessibilityFocused: Story = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would actually rather encourage to always use label element but add "sr-only" class when it must be visually hidden

name: 'Accessibility: No Visible Label',
args: {
id: 'search-accessible',
placeholder: 'Otsi tooteid või teenuseid...',
ariaLabel: 'Otsi tooteid või teenuseid',
},
parameters: {
a11y: {
config: {
rules: {
label: { enabled: false },
},
},
},
docs: {
description: {
story: `
Always prefer a native \`<label>\` element for form controls.
If the label must not be visible in the UI, hide it visually using an \`sr-only\` (or equivalent) class rather than removing it. This preserves correct semantics and provides the most reliable experience for screen reader users.
Use \`ariaLabel\` only as a fallback when a real \`<label>\` cannot be rendered. This follows WCAG 2.1 and EN 301 549 9.2.5.3.
`,
},
},
},
};
21 changes: 16 additions & 5 deletions src/tedi/components/form/search/search.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cn from 'classnames';
import React, { forwardRef } from 'react';

import { useLabels } from '../../../providers/label-provider';
import { IconWithoutBackgroundProps } from '../../base/icon/icon';
import { Button, ButtonProps } from '../../buttons/button/button';
import { TextField, TextFieldForwardRef, TextFieldProps } from '../textfield/textfield';
Expand All @@ -19,14 +20,20 @@ export interface SearchProps extends Omit<TextFieldProps, 'isTextArea' | 'icon'
* Optional button properties.
*/
button?: Partial<ButtonProps>;
/**
* For accessibility: search field name (accessible name). Recommended to always set.
* E.g., "Search products" or "Search site".
*/
ariaLabel?: string;
}

export const Search = forwardRef<TextFieldForwardRef, SearchProps>(
(
{ placeholder, isClearable = true, searchIcon = 'search', onSearch, onChange, button, ...rest },
{ placeholder, isClearable = true, searchIcon = 'search', onSearch, onChange, button, ariaLabel, ...rest },
ref
): JSX.Element => {
const handleKeyPress: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
const { getLabel } = useLabels();
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'Enter') {
onSearch?.(rest.value as string);
}
Expand All @@ -42,21 +49,25 @@ export const Search = forwardRef<TextFieldForwardRef, SearchProps>(
inputClassName: cn(styles['tedi-search__input'], button && styles['tedi-search__input--has-button']),
placeholder,
isClearable,
onKeyPress: handleKeyPress,
onKeyDown: handleKeyDown,
onChange,
...(button ? {} : { icon: searchIcon }),
};

const defaultAriaLabel = placeholder || getLabel('search');
const searchAriaLabel = ariaLabel ?? defaultAriaLabel;

return (
<div className={cn(styles['tedi-search__wrapper'], rest.className)}>
<div className={cn(styles['tedi-search__wrapper'], rest.className)} role="search" aria-label={searchAriaLabel}>
<TextField {...textFieldProps} />
{button && (
<Button
{...button}
onClick={handleButtonClick}
className={cn(styles['tedi-search__button'], button.className)}
aria-label={button.children ? undefined : getLabel('search')}
>
{button.children}
{button.children ?? getLabel('search')}
</Button>
)}
</div>
Expand Down
Loading