Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Below is a list of the components available in this library. Each component has
- [BBBHint](./src/components/Hint/README.md)
- [BBBModal](./src/components/Modal//README.md)
- [BBBNavigation](./src/components/Navigation/README.md)
- [BBBSearch](./src/components/Search/README.md)
- [BBBSelect](./src/components/Select/README.md)
- [BBBSpinner](./src/components/Spinner//README.md)
- [BBBTextAreaInput](./src/components/TextAreaInput/README.md)
Expand Down
73 changes: 73 additions & 0 deletions src/components/Search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# BBBSearch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add a mention to this component in the project README - under available components.


A search input component with built-in debounce, clear button, and optional loading state. Designed to replace ad-hoc search implementations across the application with a consistent, accessible component.

## Usage

### Uncontrolled (simplest)

```tsx
import { BBBSearch } from 'bbb-ui-components-react';

<BBBSearch
placeholder="Search participants..."
onSearch={(term) => filterParticipants(term)}
/>
```

### With custom debounce

```tsx
<BBBSearch
placeholder="Search messages..."
debounce={300}
onSearch={(term) => searchMessages(term)}
/>
```

### Controlled

```tsx
const [query, setQuery] = useState('');

<BBBSearch
value={query}
onChange={setQuery}
onClear={() => setQuery('')}
placeholder="Filter..."
/>
```

### With loading state

```tsx
<BBBSearch
placeholder="Search..."
isLoading={isSearching}
onSearch={handleSearch}
/>
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `placeholder` | `string` | — | Placeholder text inside the input |
| `value` | `string` | — | Controlled value (enables controlled mode) |
| `onChange` | `(value: string) => void` | — | Called on every keystroke (required in controlled mode) |
| `onSearch` | `(term: string) => void` | — | Debounced callback fired after the user stops typing |
| `onClear` | `() => void` | — | Called when the clear button is clicked (optional override) |
| `debounce` | `number` | `500` | Debounce delay in ms applied to `onSearch` |
| `isLoading` | `boolean` | `false` | Shows a spinner and hides the clear button |
| `disabled` | `boolean` | `false` | Disables the input |
| `autoFocus` | `boolean` | `false` | Focuses the input on mount |
| `inputRef` | `React.Ref<HTMLInputElement>` | — | Ref forwarded to the `<input>` element |
| `aria-label` | `string` | `placeholder` | Accessible label (falls back to `placeholder`) |

## Behavior Notes

- **Uncontrolled mode**: when `value` is not provided, the component manages its own state and fires `onSearch` after the debounce delay.
- **Controlled mode**: when `value` + `onChange` are both provided, the parent controls the input value entirely.
- **Clear button**: bypasses debounce — calls `onSearch('')` immediately (or `onClear` if provided).
- **Loading state**: `isLoading` hides the clear button and shows a spinner instead. Useful while an async search is in progress.
- The underlying `<input>` uses `type="search"` for better browser and accessibility integration.
87 changes: 87 additions & 0 deletions src/components/Search/component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import BBBSearch from './component';

const meta = {
title: 'BBBSearch',
component: BBBSearch,
tags: ['autodocs'],
argTypes: {
placeholder: {
control: 'text',
description: 'Placeholder text displayed inside the input.',
},
debounce: {
control: { type: 'number', min: 0, max: 2000, step: 100 },
description: 'Debounce delay in milliseconds applied to `onSearch`.',
},
isLoading: {
control: 'boolean',
description: 'Shows a spinner and hides the clear button when true.',
},
disabled: {
control: 'boolean',
description: 'Disables the input.',
},
autoFocus: {
control: 'boolean',
description: 'Focuses the input on mount.',
},
},
} satisfies Meta<typeof BBBSearch>;

export default meta;
type Story = StoryObj<typeof meta>;

/** Basic uncontrolled usage — simplest way to use BBBSearch. */
export const Default: Story = {
args: {
placeholder: 'Search participants...',
debounce: 500,
onSearch: (term: string) => console.log('onSearch:', term),
},
};

/** Shows the loading spinner while a search is in progress. */
export const WithLoading: Story = {
args: {
placeholder: 'Search messages...',
isLoading: true,
value: 'hello',
},
};

const ControlledSearch = (args: React.ComponentProps<typeof BBBSearch>) => {
const [value, setValue] = useState('');
return (
<BBBSearch
{...args}
value={value}
onChange={setValue}
onClear={() => setValue('')}
placeholder="Filter results..."
/>
);
};

/** Controlled mode — parent manages the value. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The controlled mode example displays 2 clear buttons:

Image

export const Controlled: Story = {
render: (args) => <ControlledSearch {...args} />,
};

/** Disabled state. */
export const Disabled: Story = {
args: {
placeholder: 'Search disabled...',
disabled: true,
},
};

/** Fast debounce for instant-search scenarios. */
export const WithFastDebounce: Story = {
args: {
placeholder: 'Search (100ms debounce)...',
debounce: 100,
onSearch: (term: string) => console.log('onSearch:', term),
},
};
151 changes: 151 additions & 0 deletions src/components/Search/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, { JSX, useState, useEffect, useCallback, useRef } from 'react';
import BBBSpinner from '../Spinner/component';
import * as Styled from './styles';
import { BBBSearchProps } from './types';

/**
* Search input with debounce, clear action, and optional loading state.
* It supports both uncontrolled usage and controlled mode via `value` + `onChange`.
*/
function BBBSearch({
placeholder,
value,
onChange,
onSearch,
onClear,
debounce = 500,
isLoading = false,
disabled = false,
autoFocus = false,
inputRef,
'aria-label': ariaLabel,
}: BBBSearchProps): JSX.Element {
const isControlled = value !== undefined;

const [internalValue, setInternalValue] = useState('');
const localInputRef = useRef<HTMLInputElement>(null);
const resolvedRef = (inputRef as React.RefObject<HTMLInputElement>) ?? localInputRef;

const currentValue = isControlled ? (value ?? '') : internalValue;
const hasValue = currentValue.length > 0;

// Debounced onSearch (uncontrolled mode)
useEffect(() => {
if (isControlled || !onSearch) return;

const timeoutId = setTimeout(() => {
onSearch(internalValue);
}, debounce);

return () => clearTimeout(timeoutId);
}, [internalValue, debounce, onSearch, isControlled]);

// Handle autoFocus on mount
useEffect(() => {
if (autoFocus && resolvedRef.current) {
resolvedRef.current.focus();
}
}, [autoFocus, resolvedRef]);

const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
if (isControlled) {
onChange?.(next);
} else {
setInternalValue(next);
}
},
[isControlled, onChange],
);

const handleClear = useCallback(() => {
if (onClear) {
onClear();
} else if (isControlled) {
onChange?.('');
} else {
// Clear is immune to debounce — fire onSearch immediately
setInternalValue('');
onSearch?.('');
}

resolvedRef.current?.focus();
}, [onClear, isControlled, onChange, onSearch, resolvedRef]);

const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
resolvedRef.current?.focus();
}
},
[resolvedRef],
);

return (
<Styled.Container $disabled={disabled} onClick={handleContainerClick}>
<Styled.SearchIconWrapper aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</Styled.SearchIconWrapper>

<Styled.StyledInput
ref={resolvedRef}
type="search"
role="searchbox"
placeholder={placeholder}
value={currentValue}
onChange={handleChange}
disabled={disabled}
aria-label={ariaLabel ?? placeholder}
/>

{isLoading && hasValue && (
<Styled.ActionWrapper>
<BBBSpinner size={16} strokeWidth={3} />
</Styled.ActionWrapper>
)}

{!isLoading && hasValue && (
<Styled.ClearButton
type="button"
onClick={handleClear}
aria-label="Clear search"
tabIndex={0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Styled.ClearButton>
)}
</Styled.Container>
);
}

export default BBBSearch;
1 change: 1 addition & 0 deletions src/components/Search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as BBBSearch } from './component';
Loading
Loading