Headless tools for building advanced search experiences.
A token/chip search bar — filter, operator, value — with the behaviour, the keyboard handling
and every aria-* attribute living in a framework-agnostic core, handed out as prop getters.
A Vue composable and a React hook adapt it to their framework; you supply the markup, and either
the plain stylesheet that ships alongside it or none at all.
Pre-1.0. The public API (option names, getter shapes, the
apiablefactory) can still move between minor versions until1.0.0. Thedata-fs/fs-<n>-*/--fs-*/.fs-*styling contract described below is stable regardless — every getter and every stylesheet is written against it, and the test suites are the enforcement.
| Page | What it shows |
|---|---|
| Styled (Vue) | The Tailwind bar, src/components/SearchBuilder.vue. |
| Headless (Vue) | The same behaviour, no stylesheet at all, src/components/HeadlessSearch.vue. |
| React | The same behaviour again, in React, over the plain stylesheet — no Tailwind, no Vue, src/components/ReactSearch.tsx. |
npm install @pepebits/search-builder
Plus whichever peer the entry you use needs — all optional, so installing the core alone pulls in nothing else:
npm install vue # for @pepebits/search-builder/vue
npm install react react-dom # for @pepebits/search-builder/react
npm install flex-url # for @pepebits/search-builder/apiable
Requires Node 18+ at build time (ESM, exports map with subpaths).
<script setup>
import { computed } from 'vue'
import { useSearchBuilder } from '@pepebits/search-builder/vue'
import '@pepebits/search-builder/styles.css'
const props = defineProps({ filters: { type: Array, required: true } })
const tokens = defineModel({ type: Array, default: () => [] })
const s = useSearchBuilder({ tokens, filters: computed(() => props.filters), label: 'Search issues' })
</script>
<template>
<div v-bind="s.getRootProps()">
<label v-bind="s.getLabelProps()">Search issues</label>
<div v-bind="s.getFieldsetProps()">
<input v-bind="s.getInputProps()">
</div>
<div v-bind="s.getListboxProps()">
<div v-for="g in s.indexedGroups.value" v-bind="s.getGroupProps(g)" :key="g.id">
<b aria-hidden="true">{{ g.label }}</b>
<span v-for="o in g.options" v-bind="s.getOptionProps(o)" :key="o.id">{{ o.label }}</span>
</div>
</div>
<p v-bind="s.getHintProps()">Arrow keys browse, Enter selects.</p>
<p v-bind="s.getAppliedProps()">{{ s.appliedSummary.value }}</p>
<p v-bind="s.getLiveRegionProps()">{{ s.announcement.value }}</p>
</div>
</template>That's a trimmed HeadlessSearch.vue — 60 lines in full,
no stylesheet, and it passes the same accessibility suite as the Tailwind bar
(SearchBuilder.vue).
import { useSearchBuilder } from '@pepebits/search-builder/react'
import '@pepebits/search-builder/styles.css'
function Search ({ filters, tokens, onTokensChange }) {
const s = useSearchBuilder({ filters, tokens, onTokensChange, label: 'Search issues' })
return (
<div {...s.getRootProps()}>
<label {...s.getLabelProps()}>Search issues</label>
<div {...s.getFieldsetProps()}>
<input {...s.getInputProps()} />
</div>
<div {...s.getListboxProps()}>
{s.indexedGroups.map((g) => (
<div {...s.getGroupProps(g)} key={g.id}>
<b aria-hidden="true">{g.label}</b>
{g.options.map((o) => <span {...s.getOptionProps(o)} key={o.id}>{o.label}</span>)}
</div>
))}
</div>
<p {...s.getHintProps()}>Arrow keys browse, Enter selects.</p>
<p {...s.getAppliedProps()}>{s.appliedSummary}</p>
<p {...s.getLiveRegionProps()}>{s.announcement}</p>
</div>
)
}The full version, with operators, values and remove buttons, is
src/components/ReactSearch.tsx.
Both quick starts above import @pepebits/search-builder/styles.css — the --fs-* design tokens plus a
plain stylesheet, every selector an attribute selector. Drop that import for the fully unstyled
shape (the headless demo does exactly
that); @pepebits/search-builder/tokens.css alone gets you just the palette to re-theme.
Either way, nothing is keyed off a class name — the contract is these attributes, and the test suites are written against them, not against any markup:
Part (data-fs) |
State it carries |
|---|---|
root |
data-stage (filter / operator / value), data-open |
label hint applied live |
text; the last three are visually hidden |
bar |
the click target that focuses the field |
tokens token |
data-type, data-editing, data-negated, data-pending |
operator value remove |
a chip's parts; operator is a <button> only when there is a choice |
listbox |
hidden, aria-busy, aria-multiselectable, data-stage |
group option |
data-active (highlight), aria-selected (chosen), data-kind |
status |
data-kind: loading / no-matches / empty |
apply clear submit discard |
actions; render the ones you want |
Every id an instance mints is scoped under fs-<n>-* (or your own id option), and a person's
avatar tint reads --fs-tone-h off toneHue(). None of the four are renamed between now and
1.0.0.
interface FilterDef {
key: string // stable, unique per bar
label: string // "Milestone"
param?: string // wire attribute name, defaults to key
operators: Operator[]
values?: Value[] // a closed set, offered directly
fetchValues?: (query: string) => Promise<Value[]> // an open set, fetched as you type
specialValues?: Value[] // wildcards (special: true) and pinned values (pinned: true)
repeatable?: boolean // can appear as more than one token
freeValue?: boolean // the value stage also accepts typed text
kind?: string // presentation hint (e.g. "person")
}
interface Operator {
value: string // the wire key: "equal", "like", "in", ...
symbol?: string // "=", "~", "≥" — what a chip draws
description: string // "is", "contains" — what a screen reader reads
multiple?: boolean // the value stage accumulates values into one token
negated?: boolean // styles the chip's operator cell as excluding
}
interface Value {
value: string
label: string
color?: string
initials?: string
avatar?: string
sub?: string // secondary text beside the label; typing matches it too
special?: boolean // a wildcard (None / Any): its own group, hidden under multi-value operators
pinned?: boolean // a known member the list does not carry (Me): offered first, allowed everywhere
tone?: string // avatar tint seed when it should follow another identity
special?: boolean
}
type Token =
| { id: string; type: 'text'; operator: string; value: string }
| { id: string; type: string; operator: string; value: string | string[] }All of the above — plus Stage, Option, OptionGroup, SearchBuilderOptions,
SearchBuilderStore and the rest of the getter/action surface — are exported as types from
@pepebits/search-builder (the root entry), so @pepebits/search-builder/vue and @pepebits/search-builder/react never need a
separate @types package.
Tokens ↔ a flex-url query string — the Laravel Apiable
grammar. Nothing here hand-builds a URL, and this is the only entry that touches flex-url; the
core and the adapters know nothing about it.
import { createApiable } from '@pepebits/search-builder/apiable'
const apiable = createApiable({
filters: FILTERS, // FilterDef[]
path: '/api/v1/issues', // defaults to '/'
sorts: SORTS, // optional: { value, attribute, direction }[]
resource: 'issues' // defaults to the last path segment of `path`
})
apiable.tokensToUrl(tokens, { sort }) // -> FlexUrl (immutable)
apiable.urlToTokens(url) // -> { tokens, sort, url }
apiable.requestUri(tokens, { sort }) // -> "/api/v1/issues?filter[...]=..."
apiable.requestParams(tokens, { sort }) // -> the same request, as flex-url's nested object
// every one of the three also takes `path` to override the factory's default for one call
apiable.schema() // -> the EndpointSchema apiable's exporter would publishA token's operator is the apiable wire key, so there is no separate translation table:
| Token operator | Wire | Chip shows | Spoken |
|---|---|---|---|
equal |
filter[status][equal]=opened |
= |
"is" |
in |
filter[labels]=a11y,regression |
= |
"is any of" |
like |
filter[title][like]=combobox |
~ |
"contains" |
not_like |
filter[title][not_like]=combobox |
!~ |
"does not contain" |
gte / lt |
filter[updated_at][gte]=… |
≥ / < |
"on or after" / "before" |
| (free text) | q=hydration |
— | — |
in is the marker for apiable's plain, bracket-less entry (filter[attr]=a,b) — the only one that
takes a value list.
flex-url 3 added apiable's not_equal and not_like to the grammar. The backend has to register
the operator for the attribute like any other — an unregistered key is dropped, not applied — so
check the server side before offering it. An operator marked negated: true sets data-negated
on the chip, which tints only the operator cell (.fs-token--not-equivalent styling in
filtered-search.css), not the whole chip: painting the whole thing a warm colour reads as a
validation error, not as "this filter excludes".
flex-url 3 treats a comma as a list separator whether it arrives raw or percent-encoded (%2C),
because apiable explodes on it after decoding either way. A comma inside a single value is
therefore not representable — it comes back as two values, and like reads that as "contains
either".
| Key | Single value | Multi-value operator |
|---|---|---|
↓ ↑ |
Move through suggestions, across groups | same |
Enter |
Take the suggestion, advance a stage | Toggle the value, list stays open |
Tab |
Leave the bar | Apply the chosen values, then leave |
→ (empty field) |
— | Apply the chosen values, stay in the bar |
Esc |
Step back a stage | Clear chosen values, then step back |
Backspace |
On empty text: step back, or remove the last chip | same |
Home / End |
First / last suggestion | same |
Space |
On a chip's operator or value: re-open just that part | same |
- The input is the combobox (
role="combobox",aria-expanded,aria-controls); the suggestions are a siblingrole="listbox", never removed from the DOM (it closes withhidden), soaria-controlsalways resolves. Focus never leaves the input. - Highlighting sets
aria-activedescendantanddata-active. Under a multi-value operator the listbox isaria-multiselectableandaria-selectedmeans chosen, not highlighted. - Each suggestion section is a
role="group"with anaria-label— the visible heading is decorative, the group already carries the name. - Loading and empty rows sit outside the option set (
role="presentation"), so an empty list never announces "1 of 1"; async filters setaria-busyand announce "Loading suggestions". aria-describedbyon the input carries the keyboard hint and a sentence listing the filters already applied.- Chips are
role="list"/role="listitem"explicitly, sincedisplay: contentsdrops those roles in several engines. - Every state change that matters is written to a
role="status"live region: adding, removing, clearing, toggling a value, discarding a draft, each optionally suffixed with a result count (theresultCountoption).
The behaviour, the ARIA and the prop getters live in a framework-agnostic src/core/; src/vue/
and src/react/ are thin adapters over it, and src/apiable/ is the only module that touches
flex-url. The full design history — the extraction plan, what changed between phases, and why —
is in docs/plan/headless-package.md and the decisions in
docs/adr/; docs/headless-filtered-search.md is
the original, self-contained implementation write-up (contract, every bug it hit, and a retrofit
checklist) from before the framework-agnostic split.
git clone https://github.com/Pepebits/search-builder.git
cd search-builder
npm install
npm run dev # the three demo pages, at http://localhost:5173
npm test # typecheck + every suite, including the packed-tarball proof
| Script | What it does |
|---|---|
npm run dev / npm run build / npm run preview |
The demo site (three pages) — vite.config.js, outputs to dist/. |
npm run build:lib |
The publishable package — declarations, the four JS entries and the CSS, into dist-lib/. Runs automatically before npm pack/npm publish (prepack). |
npm run typecheck |
vue-tsc --noEmit over src and tests. |
npm test |
Every suite in order: test:url, test:core, test:logic (jsdom), test:browser (Chromium, styled), test:headless (Chromium, unstyled), test:react (Chromium), test:pack (packs the tarball, installs it into a scratch Vue app and a scratch React app, builds and drives both with Playwright). |
npm run test:pack |
Just the packed-tarball proof, on its own. |
npm run build (the Pages workflow) and npm run build:lib (the npm package) are independent:
one always outputs dist/, the other always outputs dist-lib/, and neither script touches the
other's directory.
The first Chromium-based run on a machine needs the browser installed once:
npx playwright install chromium. tests/core.mjs imports the TypeScript core directly, which
needs Node 22.6 or newer (type stripping); consumers of the published package only need Node 18.
Publishing is staged, never direct: CI can put a version on the registry, only a maintainer with 2FA can make it live.
-
Bump
versioninpackage.json, add theCHANGELOG.mdentry, commit. -
Tag and push:
git tag -a vX.Y.Z -m vX.Y.Z && git push origin main vX.Y.Z. -
The
releaseworkflow runs the whole suite and, if it passes,npm stage publish --provenancethrough npm trusted publishing. Green means staged, not published. -
Approve it (prompts for your one-time password), or reject it:
npm stage list @pepebits/search-builder npm stage download <stage-id> # optional: inspect the exact tarball first npm stage approve <stage-id>The same buttons exist on the package page at npmjs.com.
-
Announce it on Telegram: once the version is live on npm, run Actions → announce with the tag. It refuses to go on while
npm viewcannot see the version, builds the message from the tag'sCHANGELOG.mdentry, and defaults to a dry run on the test chat; switchchattoannouncementsand turndry_runoff to post it. Needs theTELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDsecrets, set for the org or this repository (see Pepebits/release-announce).
A tag whose version is already live is a no-op run. A tag whose version is already staged fails at the staging step until that stage is approved or rejected.
