From fea15f7206448404b92ddf5c90b333be1e413a55 Mon Sep 17 00:00:00 2001 From: AtilaU19 Date: Thu, 11 Jun 2026 18:31:45 +0000 Subject: [PATCH] feat(BBBSearch): add search component --- README.md | 1 + src/components/Search/README.md | 73 ++++++++++ src/components/Search/component.stories.tsx | 87 +++++++++++ src/components/Search/component.tsx | 151 ++++++++++++++++++++ src/components/Search/index.ts | 1 + src/components/Search/styles.ts | 128 +++++++++++++++++ src/components/Search/types.ts | 52 +++++++ src/components/index.ts | 1 + 8 files changed, 494 insertions(+) create mode 100644 src/components/Search/README.md create mode 100644 src/components/Search/component.stories.tsx create mode 100644 src/components/Search/component.tsx create mode 100644 src/components/Search/index.ts create mode 100644 src/components/Search/styles.ts create mode 100644 src/components/Search/types.ts diff --git a/README.md b/README.md index 47632b4..4db185d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/components/Search/README.md b/src/components/Search/README.md new file mode 100644 index 0000000..a81b399 --- /dev/null +++ b/src/components/Search/README.md @@ -0,0 +1,73 @@ +# BBBSearch + +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'; + + filterParticipants(term)} +/> +``` + +### With custom debounce + +```tsx + searchMessages(term)} +/> +``` + +### Controlled + +```tsx +const [query, setQuery] = useState(''); + + setQuery('')} + placeholder="Filter..." +/> +``` + +### With loading state + +```tsx + +``` + +## 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` | — | Ref forwarded to the `` 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 `` uses `type="search"` for better browser and accessibility integration. diff --git a/src/components/Search/component.stories.tsx b/src/components/Search/component.stories.tsx new file mode 100644 index 0000000..3a45e30 --- /dev/null +++ b/src/components/Search/component.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +/** 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) => { + const [value, setValue] = useState(''); + return ( + setValue('')} + placeholder="Filter results..." + /> + ); +}; + +/** Controlled mode — parent manages the value. */ +export const Controlled: Story = { + render: (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), + }, +}; diff --git a/src/components/Search/component.tsx b/src/components/Search/component.tsx new file mode 100644 index 0000000..34aee22 --- /dev/null +++ b/src/components/Search/component.tsx @@ -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(null); + const resolvedRef = (inputRef as React.RefObject) ?? 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) => { + 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) => { + if (e.target === e.currentTarget) { + resolvedRef.current?.focus(); + } + }, + [resolvedRef], + ); + + return ( + + + + + + {isLoading && hasValue && ( + + + + )} + + {!isLoading && hasValue && ( + + + + )} + + ); +} + +export default BBBSearch; diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts new file mode 100644 index 0000000..0da0084 --- /dev/null +++ b/src/components/Search/index.ts @@ -0,0 +1 @@ +export { default as BBBSearch } from './component'; diff --git a/src/components/Search/styles.ts b/src/components/Search/styles.ts new file mode 100644 index 0000000..15889cb --- /dev/null +++ b/src/components/Search/styles.ts @@ -0,0 +1,128 @@ +import styled, { css } from 'styled-components'; +import { + colorBorderDefault, + colorBorderSelected, + colorTextDefault, + colorTextLight, + colorIconDefault, + colorIconBlue, + colorBrand1, +} from '../../stylesheets/pallete'; +import { + spacingSmall, + spacingMedium, + borderRadiusDefault, +} from '../../stylesheets/sizing'; +import { fontSizeSmall } from '../../stylesheets/typography'; + +interface ContainerProps { + $disabled?: boolean; +} + +export const Container = styled.div` + display: flex; + align-items: center; + position: relative; + width: 100%; + height: 2.5rem; + padding: ${spacingSmall} ${spacingMedium}; + gap: ${spacingSmall}; + border-radius: ${borderRadiusDefault}; + border: 1px solid ${colorBorderDefault}; + background-color: transparent; + box-sizing: border-box; + cursor: text; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:focus-within { + border-color: ${colorBorderSelected}; + box-shadow: 0 0 0 3px var(--color-border-focus-ring, rgba(29, 101, 212, 0.15)); + } + + ${({ $disabled }) => + $disabled && + css` + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + `} +`; + +export const SearchIconWrapper = styled.span` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${colorIconDefault}; + width: 1rem; + height: 1rem; +`; + +export const StyledInput = styled.input` + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + color: ${colorTextDefault}; + font-size: ${fontSizeSmall}; + line-height: 1.5; + padding: 0; + + &::placeholder { + color: ${colorTextLight}; + } + + &:disabled { + cursor: not-allowed; + } + + &::-webkit-search-decoration, + &::-webkit-search-cancel-button, + &::-webkit-search-results-button, + &::-webkit-search-results-decoration { + -webkit-appearance: none; + appearance: none; + display: none; + } + + [dir='rtl'] & { + text-align: right; + } +`; + +export const ActionWrapper = styled.span` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1rem; + height: 1rem; + color: ${colorBrand1}; +`; + +export const ClearButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + width: 1rem; + height: 1rem; + color: ${colorIconDefault}; + line-height: 1; + transition: color 0.1s ease; + + &:hover { + color: ${colorIconBlue}; + } + + &:focus-visible { + outline: 2px solid ${colorBorderSelected}; + outline-offset: 2px; + border-radius: 2px; + } +`; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts new file mode 100644 index 0000000..8e2152c --- /dev/null +++ b/src/components/Search/types.ts @@ -0,0 +1,52 @@ +export interface BBBSearchProps { + /** Placeholder text displayed inside the input field. */ + placeholder?: string; + + /** + * Controlled value for the search input. + * When provided together with `onChange`, the component operates in controlled mode. + */ + value?: string; + + /** + * Called on every keystroke with the current input value. + * Required when using controlled mode (`value` prop). + */ + onChange?: (value: string) => void; + + /** + * Debounced callback fired after the user stops typing. + * Receives the current search term as argument. + */ + onSearch?: (term: string) => void; + + /** + * Called when the clear button is clicked. + * If not provided, the component clears the internal state and calls `onSearch('')`. + */ + onClear?: () => void; + + /** + * Debounce delay in milliseconds applied to `onSearch`. + * @default 500 + */ + debounce?: number; + + /** + * When `true` and the input has a value, a loading spinner replaces the clear button. + * @default false + */ + isLoading?: boolean; + + /** Disables the search input. */ + disabled?: boolean; + + /** Focuses the input when the component mounts. */ + autoFocus?: boolean; + + /** Ref forwarded to the underlying `` element. */ + inputRef?: React.Ref; + + /** Accessible label for the search input (used as `aria-label`). */ + 'aria-label'?: string; +} diff --git a/src/components/index.ts b/src/components/index.ts index 722424e..0929280 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { BBBDivider } from './Divider'; export { BBBHint } from './Hint'; export { BBBModal } from './Modal'; export { BBBNavigation } from './Navigation'; +export { BBBSearch } from './Search'; export { BBBSelect } from './Select'; export { BBBSpinner } from './Spinner'; export { BBBTextAreaInput } from './TextAreaInput';