Default

Loading interactive example. Full source is available below.

View example source
Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { SearchBoxProps } from "@coreai-ds/react-components";
import type { JSXElement } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';

export const Default = (props: SearchBoxProps): JSXElement => {
    return (<Field label="Sample SearchBox">
      <SearchBox {...props}/>
    </Field>);
};

export default Default;

Resources

Behavior and states

  • The focus animation appears once the searchbox receives focus.
  • The Close button is only present when there’s content in the searchbox.
  • Recent topics appear on focus, then suggested results replace them as a query is entered.
  • At smaller viewport sizes, the searchbox can be redrawn as a magnifying glass icon that activates a full-width searchbox.
  • The search bar must be flexible, allow for dismissal and filtering to provide the best experience.
  • Search results are populated in a combo box dropdown that changes and repopulates as the user types.
API referenceGenerated CoreAI prop and type summaries

API

Generated CoreAI prop summaries, not replacement type declarations. Most native DOM/event and ARIA props are omitted. ? means optional; JSDoc shows defaults, deprecations, and conditional requirements. Defaults assume the standard CoreAI provider.

SearchBox API

interface SearchBoxProps {
  /**
   * Controls the colors and borders of the input.
   * @default 'filled-darker'
   */
  appearance?: "outline" | "filled-darker" | "filled-lighter" | "underline" | "filled-darker-shadow" | "filled-lighter-shadow";

  as?: "input";

  /**
   * Input can't have children.
   */
  children?: undefined;

  className?: string;

  /**
   * Element after the input text, within the input border
   */
  contentAfter?: WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null | undefined;

  /**
   * Element before the input text, within the input border
   */
  contentBefore?: WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null | undefined;

  /**
   * Default value of the input. Provide this if the input should be an uncontrolled component
   * which tracks its current state internally; otherwise, use `value`.
   *
   * (This prop is mutually exclusive with `value`.)
   */
  defaultValue?: string;

  /**
   * Last element in the input, within the input border
   */
  dismiss?: WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null | undefined;

  /**
   * The actual `<input>` element. `type="text"` will be automatically applied unless overridden.
   *
   * This is the "primary" slot, so native props specified directly on the `<Input>` will go here
   * (except `className` and `style`, which go to the `root` slot). The top-level `ref` will
   * also go here.
   */
  input?: ({ as?: "input"; } & Omit<Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "ref"> & { ref?: ((instance: HTMLInputElement | null) => void) | RefObject<HTMLInputElement> | null; }, "children"> & { children?: undefined; }) | undefined;

  /**
   * Custom onChange callback.
   * Will be traditionally supplied with a React.ChangeEvent<HTMLInputElement> for usual character entry.
   * When the dismiss button is clicked, this will be called with an event of type React.MouseEvent<HTMLSpanElement>
   * and an empty string as the `value` property of the data parameter
   */
  onChange?: ((event: SearchBoxChangeEvent, data: InputOnChangeData) => void);

  ref?: Ref<HTMLInputElement>;

  /**
   * Wrapper element which visually appears to be the input and is used for borders, focus styling, etc.
   * (A wrapper is needed to properly position `contentBefore` and `contentAfter` relative to `input`.)
   *
   * The root slot receives the `className` and `style` specified directly on the `<Input>`.
   * All other top-level native props will be applied to the primary slot, `input`.
   */
  root?: NonNullable<WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null> | undefined;

  /**
   * Size of the input (changes the font size and spacing).
   * @default 'medium'
   */
  size?: "small" | "medium" | "large";

  style?: CSSProperties;

  /**
   * An input can have different text-based [types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#input_types)
   * based on the type of value the user will enter.
   *
   * Note that no custom styling is currently applied for alternative types, and some types may
   * activate browser-default styling which does not match the Fluent design language.
   *
   * (For non-text-based types such as `button` or `checkbox`, use the appropriate component or an
   * `<input>` element instead.)
   * @default 'text'
   */
  type?: "number" | "search" | "time" | "text" | "tel" | "url" | "email" | "date" | "datetime-local" | "month" | "password" | "week";

  /**
   * Current value of the input. Provide this if the input is a controlled component where you
   * are maintaining its current state; otherwise, use `defaultValue`.
   *
   * (This prop is mutually exclusive with `defaultValue`.)
   */
  value?: string;
}

Implementation

CoreAI 0.12.0; Fluent 9.74.9.

Setup and dependencies.

Import components from @coreai-ds/react-components. CoreAI guidance above takes precedence over API demonstration examples.

Appearance

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import { mergeClasses } from "@coreai-ds/react-components";
import * as React from 'react';

const useStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
    },
    filledLighter: {
        backgroundColor: tokens.colorNeutralBackgroundInverted,
    },
    filledLighterLabel: {
        color: tokens.colorNeutralForegroundInverted2,
    },
    filledDarker: {
        backgroundColor: tokens.colorNeutralBackgroundInverted,
    },
    filledDarkerLabel: {
        color: tokens.colorNeutralForegroundInverted2,
    },
    fieldWrapper: {
        padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalMNudge}`,
    },
});

export const Appearance = (): JSXElement => {
    const styles = useStyles();
    return (<div className={styles.base}>
      <Field className={styles.fieldWrapper} label="Outline appearance (default)">
        <SearchBox appearance="outline"/>
      </Field>

      <Field className={styles.fieldWrapper} label="Underline appearance">
        <SearchBox appearance="underline"/>
      </Field>

      <Field className={mergeClasses(styles.fieldWrapper, styles.filledLighter)} label={{ children: 'Filled lighter appearance', className: styles.filledLighterLabel }}>
        <SearchBox appearance="filled-lighter"/>
      </Field>

      <Field className={mergeClasses(styles.fieldWrapper, styles.filledDarker)} label={{ children: 'Filled darker appearance', className: styles.filledDarkerLabel }}>
        <SearchBox appearance="filled-darker"/>
      </Field>
    </div>);
};

export default Appearance;

Content before/after

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import { PersonRegular } from "@fluentui/react-icons";
import * as React from 'react';
import type { ButtonProps } from "@coreai-ds/react-components";
import { Button } from "@coreai-ds/react-components";
import { MicRegular } from "@fluentui/react-icons";
import { Text } from "@coreai-ds/react-components";

const useStyles = makeStyles({
    root: {
        display: 'flex',
        flexDirection: 'column',
    },
    fieldWrapper: {
        padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalMNudge}`,
    },
});

const MicButton: React.FC<ButtonProps> = props => {
    return <Button {...props} appearance="transparent" icon={<MicRegular />} size="small"/>;
};

export const ContentBeforeAfter = (): JSXElement => {
    const styles = useStyles();
    return (<div className={styles.root}>
      <Field className={styles.fieldWrapper} label="Search by name" hint={<>
            A SearchBox with a custom icon in the <code>contentBefore</code> slot.
          </>}>
        <SearchBox contentBefore={<PersonRegular />}/>
      </Field>

      <Field className={styles.fieldWrapper} label="Search by voice" hint={<>
            A SearchBox with a button in the <code>contentAfter</code> slot.
          </>}>
        <SearchBox contentAfter={<MicButton aria-label="Search by voice"/>}/>
      </Field>

      <Field className={styles.fieldWrapper} label="Search with filter" hint={<>
            A SearchBox with a presentational value in the <code>contentBefore</code> slot and another presentational
            value in the <code>contentAfter</code> slot.
          </>}>
        <SearchBox contentBefore={<Text size={400}>Search:</Text>} contentAfter={<Text size={400}>Filter</Text>}/>
      </Field>
    </div>);
};

export default ContentBeforeAfter;

Controlled

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import * as React from 'react';
import type { SearchBoxChangeEvent } from "@coreai-ds/react-components";
import type { InputOnChangeData } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";

export const Controlled = (): JSXElement => {
    const [value, setValue] = React.useState('initial value');
    const [valid, setValid] = React.useState(true);
    const onChange: (ev: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) => {
        if (data.value.length <= 20) {
            setValue(data.value);
            setValid(true);
        }
        else {
            setValid(false);
        }
    };
    return (<Field label="Controlled SearchBox limiting the value to 20 characters" validationState={valid ? 'none' : 'warning'} validationMessage={valid ? '' : 'Input is limited to 20 characters.'}>
      <SearchBox value={value} onChange={onChange}/>
    </Field>);
};

export default Controlled;

Disabled

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';

export const Disabled = (): JSXElement => {
    return (<Field label="Disabled SearchBox">
      <SearchBox disabled defaultValue="disabled value"/>
    </Field>);
};

export default Disabled;

Placeholder

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';

export const Placeholder = (): JSXElement => {
    return (<Field label="SearchBox with a placeholder">
      <SearchBox placeholder="This is a placeholder"/>
    </Field>);
};

export default Placeholder;

Size

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';

const useStyles = makeStyles({
    root: {
        display: 'flex',
        flexDirection: 'column',
    },
    fieldWrapper: {
        padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalMNudge}`,
    },
});

export const Size = (): JSXElement => {
    const styles = useStyles();
    return (<div className={styles.root}>
      <Field className={styles.fieldWrapper} label="Small SearchBox">
        <SearchBox size="small"/>
      </Field>

      <Field className={styles.fieldWrapper} label="Medium SearchBox">
        <SearchBox size="medium"/>
      </Field>

      <Field className={styles.fieldWrapper} label="Large SearchBox">
        <SearchBox size="large"/>
      </Field>
    </div>);
};

export default Size;

Typeahead

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import * as React from 'react';
import { useId } from "@coreai-ds/react-components";
import { useTypingAnnounce } from "@coreai-ds/react-components";
import type { SearchBoxChangeEvent } from "@coreai-ds/react-components";
import type { InputOnChangeData } from "@coreai-ds/react-components";
import { AriaLiveAnnouncer } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import { mergeClasses } from "@coreai-ds/react-components";
import { Spinner } from "@coreai-ds/react-components";

const useStyles = makeStyles({
    root: {
        position: 'relative',
        maxWidth: '400px',
    },
    listbox: {
        backgroundColor: tokens.colorNeutralBackground1,
        border: `1px solid ${tokens.colorNeutralStroke1}`,
        borderRadius: tokens.borderRadiusMedium,
        boxShadow: tokens.shadow16,
        boxSizing: 'border-box',
        listStyleType: 'none',
        margin: 0,
        padding: `${tokens.spacingVerticalXS} 0`,
        position: 'absolute',
        width: '100%',
        zIndex: 1000,
    },
    listboxHidden: {
        display: 'none',
    },
    option: {
        alignItems: 'center',
        cursor: 'pointer',
        display: 'flex',
        gap: tokens.spacingHorizontalS,
        padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
        ':hover': {
            backgroundColor: tokens.colorNeutralBackground1Hover,
        },
    },
    optionFocused: {
        backgroundColor: tokens.colorNeutralBackground1Selected,
        outline: 'none',
    },
    spinnerWrapper: {
        alignItems: 'center',
        display: 'flex',
        gap: tokens.spacingHorizontalS,
        padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
        color: tokens.colorNeutralForeground3,
    },
    noResults: {
        color: tokens.colorNeutralForeground3,
        padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
    },
});

type SearchResult = {
    id: string;
    label: string;
};

// Simulated async search function
const fetchResults = (query: string): Promise<SearchResult[]> => {
    const allResults: SearchResult[] = [
        { id: '1', label: 'Accessibility in Fluent UI' },
        { id: '2', label: 'Button component' },
        { id: '3', label: 'Combobox with filtering' },
        { id: '4', label: 'Dark mode theming' },
        { id: '5', label: 'Fluent UI v9 migration guide' },
        { id: '6', label: 'Form validation patterns' },
        { id: '7', label: 'Grid layout examples' },
        { id: '8', label: 'High contrast support' },
        { id: '9', label: 'Icon library overview' },
        { id: '10', label: 'Jest testing utilities' },
    ];
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(allResults.filter(r => r.label.toLowerCase().includes(query.toLowerCase())));
        }, 500);
    });
};

const DEBOUNCE_MS = 300;

export const Typeahead = (): JSXElement => {
    const styles = useStyles();
    const [query, setQuery] = React.useState('');
    const [results, setResults] = React.useState<SearchResult[]>([]);
    const [isLoading, setIsLoading] = React.useState(false);
    const [isOpen, setIsOpen] = React.useState(false);
    const [focusedIndex, setFocusedIndex] = React.useState(-1);
    const [hideActiveDescendant, setHideActiveDescendant] = React.useState(false);
    const [selectedId, setSelectedId] = React.useState<string | null>(null);
    const listboxId = useId();
    const announceId = useId('typeahead');
    const { typingAnnounce, inputRef } = useTypingAnnounce<HTMLInputElement>();
    const optionRefs = React.useRef<(HTMLLIElement | null)[]>([]);
    const cancelRef = React.useRef<(() => void) | null>(null);
    // Debounced search when query changes
    React.useEffect(() => {
        if (!query) {
            setResults([]);
            setIsOpen(false);
            setIsLoading(false);
            return;
        }
        setIsLoading(true);
        setIsOpen(true);
        setFocusedIndex(-1);
        const debounceTimer = setTimeout(() => {
            let cancelled = false;
            fetchResults(query).then(data => {
                if (!cancelled) {
                    setResults(data);
                    optionRefs.current = new Array(data.length).fill(null);
                    setIsLoading(false);
                }
            });
            // Store cancel in the ref so the cleanup can access it
            cancelRef.current = () => {
                cancelled = true;
            };
        }, DEBOUNCE_MS);
        return () => {
            clearTimeout(debounceTimer);
            cancelRef.current?.();
            cancelRef.current = null;
        };
    }, [query]);
    React.useEffect(() => {
        if (!isOpen || isLoading) {
            return;
        }
        if (results.length > 0) {
            typingAnnounce(`${results.length} result${results.length !== 1 ? 's' : ''} available`, {
                batchId: announceId,
            });
        }
        else if (query.length > 0) {
            typingAnnounce('No results found', { batchId: announceId });
        }
    }, [isLoading, isOpen, results.length, query.length, typingAnnounce, announceId]);
    // Keep focused option scrolled into view
    React.useEffect(() => {
        if (focusedIndex >= 0) {
            optionRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
        }
    }, [focusedIndex]);
    const handleChange = (_: SearchBoxChangeEvent, data: InputOnChangeData) => {
        setQuery(data.value);
        setSelectedId(null);
        if (!data.value) {
            return;
        }
        // useTypingAnnounce waits until the user pauses typing before announcing,
        // preventing interference with screen reader keyboard echo.
        typingAnnounce('Searching…', { batchId: announceId });
    };
    const handleSelect = (result: SearchResult) => {
        setSelectedId(result.id);
        setQuery(result.label);
        setIsOpen(false);
        setFocusedIndex(-1);
        inputRef.current?.focus();
    };
    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
        if (!isOpen) {
            return;
        }
        if (e.key === 'ArrowDown') {
            e.preventDefault();
            setHideActiveDescendant(false);
            setFocusedIndex(prev => Math.min(prev + 1, results.length - 1));
        }
        else if (e.key === 'ArrowUp') {
            e.preventDefault();
            setHideActiveDescendant(false);
            setFocusedIndex(prev => Math.max(prev - 1, -1));
        }
        else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
            setHideActiveDescendant(true);
        }
        else if (e.key === 'Enter' && focusedIndex >= 0) {
            e.preventDefault();
            handleSelect(results[focusedIndex]);
        }
        else if (e.key === 'Escape') {
            setIsOpen(false);
            setFocusedIndex(-1);
            setHideActiveDescendant(false);
        }
    };
    const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
        // Close if focus leaves both the input and the listbox
        const relatedTarget = e.relatedTarget as Node | null;
        const listboxEl = document.getElementById(listboxId);
        if (!e.currentTarget.contains(relatedTarget) && !listboxEl?.contains(relatedTarget)) {
            setIsOpen(false);
            setFocusedIndex(-1);
        }
    };
    const showDropdown = isOpen && (isLoading || results.length > 0);
    const noResults = isOpen && !isLoading && query.length > 0 && results.length === 0;
    const activedescendant = focusedIndex >= 0 && !hideActiveDescendant ? `${listboxId}-option-${focusedIndex}` : undefined;
    return (<AriaLiveAnnouncer>
      <div className={styles.root}>
        <SearchBox ref={inputRef} aria-autocomplete="list" aria-controls={listboxId} aria-expanded={showDropdown || noResults} aria-activedescendant={activedescendant} placeholder="Search..." value={query} onChange={handleChange} onKeyDown={handleKeyDown} root={{ onBlur: handleBlur }}/>

        <ul id={listboxId} role="listbox" aria-label="Search results" className={mergeClasses(styles.listbox, !showDropdown && !noResults && styles.listboxHidden)}>
          {isLoading ? (<li className={styles.spinnerWrapper}>
              <Spinner size="tiny" label="Loading results…" labelPosition="after"/>
            </li>) : results.length > 0 ? (results.map((result, index) => (<li key={result.id} id={`${listboxId}-option-${index}`} ref={el => {
                optionRefs.current[index] = el;
            }} role="option" aria-selected={selectedId === result.id} className={mergeClasses(styles.option, focusedIndex === index && styles.optionFocused)} onMouseDown={e => {
                // Prevent input blur before click registers
                e.preventDefault();
            }} onClick={() => handleSelect(result)}>
                {result.label}
              </li>))) : (<li className={styles.noResults}>No results found</li>)}
        </ul>
      </div>
    </AriaLiveAnnouncer>);
};

export default Typeahead;