Skip to content

sausin/faceted-search

Repository files navigation



faceted-search

A unified smart search bar where structured filters and free text coexist.
Type @ to summon column filters as chips — right inside the input.

React · Vue · Headless Core · API · Accessibility


The idea

Every app has a search bar. Most also need column filters. Today you build both separately — a text input and a filter panel with dropdowns. They don't talk to each other. Users context-switch between them.

faceted-search fuses them into one.

┌─────────────────────────────────────────────────────────────────┐
│ 🔍  @Department Engineering  @Status Active  search people…    │
│     ├── chip ──────────────┤ ├── chip ──────┤ ├── free text    │
└─────────────────────────────────────────────────────────────────┘
  • Type freely — instant substring search across all columns
  • Press @ — a guided dropdown appears with available fields
  • Pick a field — dropdown pivots to that field's values
  • Pick a value — a chip locks in, input stays focused, keep going
  • Stack filters — they AND together with free text
  • Backspace on empty removes the last chip
  • Escape cancels mid-flow
  • Full ARIA — combobox pattern, live regions, keyboard-navigable

The ingredients exist in GitHub, Linear, Slack, Notion. The recipe — all three fused into a single input — is new.

Architecture

@faceted-search/core     →  Framework-agnostic state machine (3kb gzip)
@faceted-search/react    →  Hook + prop-getters for React 18+
@faceted-search/vue      →  Composable + attribute-getters for Vue 3.3+

The core is a pure state machine with zero DOM dependencies. It manages filters, free text, dropdown state, keyboard navigation indices, and ARIA attributes. Framework bindings are thin wrappers that sync core state into React/Vue reactivity and provide prop-getter functions you spread onto your own elements.

You own the markup. We own the behavior. Like Downshift or Headless UI.


React

npm install @faceted-search/core @faceted-search/react
import { useFacetedSearch } from '@faceted-search/react'

const columns = [
  { key: 'department', label: 'Department' },
  { key: 'status',     label: 'Status' },
  { key: 'location',   label: 'Location' },
]

function PeopleSearch({ data }) {
  const {
    state, filteredData, dropdown,
    inputRef, rootRef,
    getRootProps, getInputProps,
    getListboxProps, getOptionProps,
    getChipProps, getChipRemoveProps,
    getClearProps, getLiveRegionProps,
  } = useFacetedSearch({ columns, data })

  return (
    <div>
      {/* Search bar */}
      <div ref={rootRef} {...getRootProps()}>
        {state.filters.map(f => (
          <span key={f.key} {...getChipProps(f)}>
            {f.key}: {f.value}
            <button {...getChipRemoveProps(f)}>×</button>
          </span>
        ))}
        <input ref={inputRef} {...getInputProps()} />
        {(state.filters.length > 0 || state.freeText) && (
          <button {...getClearProps()}>Clear</button>
        )}
      </div>

      {/* Dropdown */}
      {dropdown && (
        <div {...getListboxProps()}>
          {dropdown.items.map((item, i) => (
            <div key={item.key} {...getOptionProps(i)}>
              {item.label}
            </div>
          ))}
        </div>
      )}

      {/* Screen reader announcements */}
      <div {...getLiveRegionProps()} />

      {/* Results */}
      <ul>
        {filteredData.map(row => (
          <li key={row.id}>{row.name}</li>
        ))}
      </ul>
    </div>
  )
}

What useFacetedSearch returns

Property Type Description
state FacetedSearchState Full internal state (filters, input value, mode, etc.)
filteredData T[] Data rows matching all active filters + free text
dropdown DropdownState | null Current dropdown items, or null when closed
aria AriaAttributes Raw ARIA attributes (if you need manual control)
inputRef RefObject Attach to your <input> element
rootRef RefObject Attach to the wrapper element
getRootProps() prop-getter Spread on wrapper div
getInputProps() prop-getter Spread on input — wires onChange, onKeyDown, onFocus, onBlur
getListboxProps() prop-getter Spread on dropdown container
getOptionProps(i) prop-getter Spread on each dropdown item — wires onMouseDown
getChipProps(filter) prop-getter Spread on each chip
getChipRemoveProps(filter) prop-getter Spread on chip remove button
getClearProps() prop-getter Spread on clear-all button
getLiveRegionProps() prop-getter Spread on a visually-hidden div for screen reader announcements
removeFilter(key) action Remove a specific filter
clearAll() action Reset everything
setFilters(filters) action Controlled mode — set filters programmatically

Vue

npm install @faceted-search/core @faceted-search/vue
<script setup>
import { useFacetedSearch } from '@faceted-search/vue'

const columns = [
  { key: 'department', label: 'Department' },
  { key: 'status',     label: 'Status' },
]

const {
  state, filteredData, dropdown,
  inputRef, rootRef,
  getRootAttrs, getInputAttrs, getInputHandlers,
  getListboxAttrs, getOptionAttrs, getOptionHandlers,
  getChipAttrs, removeFilter, clearAll,
} = useFacetedSearch({ columns, data: props.data })
</script>

<template>
  <div ref="rootRef" v-bind="getRootAttrs()">
    <span
      v-for="f in state.filters"
      :key="f.key"
      v-bind="getChipAttrs(f)"
    >
      {{ f.key }}: {{ f.value }}
      <button @click="removeFilter(f.key)">×</button>
    </span>

    <input
      ref="inputRef"
      v-bind="getInputAttrs()"
      v-on="getInputHandlers()"
    />
  </div>

  <div v-if="dropdown" v-bind="getListboxAttrs()">
    <div
      v-for="(item, i) in dropdown.items"
      :key="item.key"
      v-bind="getOptionAttrs(i)"
      v-on="getOptionHandlers(i)"
    >
      {{ item.label }}
    </div>
  </div>

  <ul>
    <li v-for="row in filteredData" :key="row.id">
      {{ row.name }}
    </li>
  </ul>
</template>

Vue uses getInputHandlers() / getOptionHandlers() separately from attrs because Vue's v-on takes an object of event handlers.


Headless Core

For non-React/Vue contexts, or if you want to build your own framework binding:

npm install @faceted-search/core
import { createFacetedSearch, keyToAction } from '@faceted-search/core'

const store = createFacetedSearch({
  columns: [
    { key: 'status', label: 'Status' },
    { key: 'priority', label: 'Priority' },
  ],
  data: myDataArray,
  onFiltersChange: (filters) => console.log('Filters:', filters),
  onSearch: (text) => console.log('Search:', text),
})

// Subscribe to state changes
store.subscribe(() => {
  const state = store.getState()
  const results = store.getFilteredData()
  const dropdown = store.getDropdownState()
  const aria = store.getAriaAttributes()
  // Re-render your UI...
})

// Dispatch actions
store.dispatch({ type: 'INPUT_CHANGE', value: '@sta' })
store.dispatch({ type: 'SELECT_COLUMN', key: 'status' })
store.dispatch({ type: 'SELECT_VALUE', value: 'Active' })

// Map keyboard events
document.addEventListener('keydown', (e) => {
  const action = keyToAction(e.key, store.getState())
  if (action) {
    e.preventDefault()
    store.dispatch(action)
  }
})

API Reference

ColumnConfig

interface ColumnConfig {
  key: string          // Property key on data rows
  label: string        // Human-readable label
  icon?: string        // Emoji or string shown in dropdowns
  color?: string       // Accent background color for chips
  textColor?: string   // Text color paired with accent
  values?: string[] | ((data) => string[])  // Explicit values, or auto-derived
  match?: (rowValue, filterValue) => boolean // Custom match logic
}

FacetedSearchOptions

interface FacetedSearchOptions<T> {
  columns: ColumnConfig[]
  data: T[]
  initialFilters?: Filter[]
  trigger?: string              // Default: "@"
  placeholder?: string          // Default: "Search… type @ to add a field filter"
  activePlaceholder?: string    // Default: "Continue typing or @ for more filters…"
  onFiltersChange?: (filters: Filter[]) => void
  onSearch?: (freeText: string) => void
  onOpenChange?: (isOpen: boolean) => void
}

Filter

interface Filter {
  key: string    // Column key
  value: string  // Selected value
}

DropdownState

interface DropdownState {
  mode: 'columns' | 'values'
  items: DropdownItem[]
  highlightedIndex: number
  activeColumn?: ColumnConfig  // Set when mode is 'values'
}

Actions

Action Description
INPUT_CHANGE User typed in the input
SELECT_COLUMN Picked a column from the dropdown
SELECT_VALUE Picked a value — commits the filter
REMOVE_FILTER Remove a specific filter by key
REMOVE_LAST_FILTER Remove the most recently added filter
CLEAR_ALL Reset all filters and input
DISMISS Cancel the current dropdown (Escape)
FOCUS / BLUR Track input focus
HIGHLIGHT_NEXT / HIGHLIGHT_PREV Keyboard nav in dropdown
CONFIRM_HIGHLIGHTED Select the highlighted item (Enter)
SET_FILTERS Programmatically set filters (controlled mode)

Accessibility

The ARIA implementation follows the WAI-ARIA Combobox pattern:

  • Input: role="searchbox", aria-autocomplete="list", aria-controls, aria-activedescendant
  • Dropdown: role="listbox" with descriptive aria-label
  • Options: role="option" with aria-selected tracking
  • Chips: role="group" with aria-label describing the filter
  • Live region: aria-live="polite" announces filter changes and suggestion counts
  • Keyboard: Full arrow-key navigation, Enter to confirm, Escape to dismiss, Backspace to remove last chip

The prop-getters handle all of this automatically. Spread them and you're compliant.


Keyboard shortcuts

Key Context Action
@ While typing Opens field picker
Dropdown open Navigate options
Enter Dropdown open Select highlighted option
Escape Dropdown open Cancel and close
Backspace Empty input, filters exist Remove last chip

Advanced patterns

Custom trigger character

useFacetedSearch({ columns, data, trigger: '#' })

Custom match function

const columns = [{
  key: 'name',
  label: 'Name',
  match: (val, filter) => String(val).toLowerCase().includes(filter.toLowerCase()),
}]

Controlled filters

const { setFilters } = useFacetedSearch({ columns, data })

// Set filters from URL params, external state, etc.
useEffect(() => {
  setFilters(filtersFromURL)
}, [filtersFromURL])

Dynamic data

// React: just pass new data — the hook syncs automatically
const search = useFacetedSearch({ columns, data: liveData })
<!-- Vue: pass a ref and it reacts automatically -->
<script setup>
const data = ref([])
const search = useFacetedSearch(computed(() => ({ columns, data: data.value })))
</script>

Contributing

git clone https://github.com/saurabh/faceted-search
cd faceted-search
npm install
npm run build
npm test

The monorepo uses npm workspaces. packages/core is the brain, packages/react and packages/vue are thin bindings.

Publishing

Push a version tag to trigger automatic npm publishing:

# Bump versions in all package.json files, then:
git tag v0.1.0
git push origin v0.1.0

Requires NPM_TOKEN secret in GitHub repo settings.

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors