Filter Bar
Filter Bar coordinates the controls that shape a server-owned result. It keeps unfinished edits separate from the filters currently applied to the page, then uses one native form submission to commit the draft. Cancel is a native form reset. Active filters can be removed one at a time or cleared together without losing useful keyboard focus.
The application still writes every Input, Select, Combobox, Checkbox, Popover, Sheet, label, button, summary, and Tailwind class. Filter Bar does not ask for a column schema, filter registry, visual variant, route, or server query language.
ServiceFilters.vue
<script setup>
import { ref } from 'vue'
import FilterBar from '@/components/ui/filter-bar/FilterBar.vue'
const filters = ref({ status: 'running' })
</script>
<template>
<FilterBar v-model="filters" v-slot="filter">
<label for="status" class="sr-only">Status</label>
<select
id="status"
:value="filter.draft.status || ''"
@change="filter.update('status', $event.currentTarget.value)"
>
<option value="">Any status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
<button v-bind="filter.applyAttrs">Apply</button>
<button v-bind="filter.cancelAttrs">Cancel</button>
<button
v-for="[key, value] in filter.entries"
:key="key"
v-bind="filter.removeAttrs(key)"
>
{{ key }}: {{ value }} ×
</button>
</FilterBar>
</template>
Installation
The command detects Vue, React, or Svelte and copies the component plus its small deterministic URL helper into the conventional UI directory.
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add filter-barpnpm dlx klean-ui add filter-baryarn dlx klean-ui add filter-barbunx klean-ui add filter-bar- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no provider, initializer, configuration file, filter-definition format, or runtime Klean dependency. If the page already owns its URL serialization, use the component and ignore filterState.js.
Usage
Vue
<script setup>
import { ref } from 'vue'
import FilterBar from '@/components/ui/filter-bar/FilterBar.vue'
const filters = ref({ status: 'running' })
</script>
<template>
<FilterBar v-model="filters" v-slot="filter">
<label for="status" class="sr-only">Status</label>
<select
id="status"
:value="filter.draft.status || ''"
@change="filter.update('status', $event.currentTarget.value)"
>
<option value="">Any status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
<button v-bind="filter.applyAttrs">Apply</button>
<button v-bind="filter.cancelAttrs">Cancel</button>
<button
v-for="[key, value] in filter.entries"
:key="key"
v-bind="filter.removeAttrs(key)"
>
{{ key }}: {{ value }} ×
</button>
</FilterBar>
</template>
React
import { useState } from 'react'
import FilterBar from '@/components/ui/filter-bar/FilterBar.jsx'
export default function ServiceFilters() {
const [filters, setFilters] = useState({ status: 'running' })
return (
<FilterBar value={filters} onChange={setFilters}>
{(filter) => (
<>
<label htmlFor="status" className="sr-only">
Status
</label>
<select
id="status"
value={filter.draft.status ?? ''}
onChange={(event) => filter.update('status', event.target.value)}
>
<option value="">Any status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
<button {...filter.applyProps}>Apply</button>
<button {...filter.cancelProps}>Cancel</button>
{filter.entries.map(([key, value]) => (
<button key={key} {...filter.removeProps(key)}>
{key}: {value} ×
</button>
))}
</>
)}
</FilterBar>
)
}
Svelte
<script>
import FilterBar from '$lib/components/ui/filter-bar/FilterBar.svelte'
let filters = $state({ status: 'running' })
</script>
<FilterBar bind:value={filters}>
{#snippet children(filter)}
<label for="status" class="sr-only">Status</label>
<select
id="status"
value={filter.draft.status ?? ''}
onchange={(event) => filter.update('status', event.currentTarget.value)}
>
<option value="">Any status</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
<button {...filter.applyProps}>Apply</button>
<button {...filter.cancelProps}>Cancel</button>
{#each filter.entries as [key, value] (key)}
<button {...filter.removeProps(key)}>{key}: {value} ×</button>
{/each}
{/snippet}
</FilterBar>
The framework syntax changes. The contract does not: committed state is caller-owned, the slot or child function receives a separate draft, and the root remains one native search form.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Committed filters | v-model | value, onChange | bind:value |
| Initial uncontrolled state | model default | defaultValue | defaultValue |
| Pending visit | busy | busy | busy |
| Accessible form name | label | label | label |
| Application markup | default scoped slot | function child | children snippet |
| Styling | class | className | class |
The slot, function, or snippet receives:
| Value | Purpose |
|---|---|
draft, setDraft(next) | The editable filter object and a whole-object setter. |
update(key, value) | Replaces one draft value without committing it. |
entries, count | Entries and count from the committed filter object. |
dirty | Whether the draft differs from the committed filters. |
applyAttrs / applyProps | Native submit-button attributes, including truthful disabled state. |
cancelAttrs / cancelProps | Native reset-button attributes. |
clearAttrs / clearProps | Attributes for an immediate “Clear all” button. |
removeAttrs(key, label?) / removeProps(...) | Attributes for one accessible immediate remove button with focus recovery. |
apply(), cancel(), clear(), remove(key, event) | Direct methods for unusual application compositions. Prefer the attribute helpers. |
Use Apply for a draft that contains several related choices. Remove-one and Clear-all commit immediately because their intent is already complete. A pending busy form keeps the current filters visible while disabling commits that would duplicate an application visit.
Durable URL state
The visible result and the URL should describe the same committed filters. filterState.js exports four small framework-neutral helpers:
stableFilters(filters)sorts object keys recursively before JSON serialization;filtersEqual(left, right)compares typed JSON filter values deterministically;filterUrl(url, filters, defaults?)writes the conventionalfiltersquery parameter while preserving unrelated query values and the hash;filtersFromUrl(url, defaults?)reads a valid object and safely returns defaults for missing or malformed input.
<script setup>
import { router } from '@inertiajs/vue3'
import { ref } from 'vue'
import FilterBar from '@/components/ui/filter-bar/FilterBar.vue'
import {
filterUrl,
filtersFromUrl
} from '@/components/ui/filter-bar/filterState.js'
const filters = ref(filtersFromUrl(window.location.href))
const busy = ref(false)
function visit(nextFilters) {
router.visit(filterUrl(window.location.href, nextFilters), {
replace: false,
preserveScroll: true,
preserveState: true,
only: ['services', 'filters'],
onStart: () => (busy.value = true),
onFinish: () => (busy.value = false)
})
}
</script>
<template>
<FilterBar
v-model="filters"
:busy="busy"
@apply="visit"
@remove="visit"
@clear="visit"
v-slot="filter"
>
<!-- App-owned controls use filter.draft and filter.update(). -->
</FilterBar>
</template>
Normal filter commits should create a history entry so Back returns to the previous result. Debounced free-text search usually replaces the current history entry; DataTable already supplies that server-query convention. When Back, Forward, or a server response changes the caller-owned model, Filter Bar synchronizes the draft to that committed truth.
Defaults and an empty filter object disappear from the URL. The server still validates filter names, operators, values, permissions, and query cost. The browser helper does not duplicate a Waterline query language or authorization policy.
Typed application filters
Filter Bar accepts a plain JSON object and does not inspect its values. A simple page can use { status: 'running' }. Bridge can use richer values such as:
{
status: { operator: 'equals', value: 'running' },
createdAt: {
operator: 'between',
from: '2026-08-01',
to: '2026-08-24'
}
}That boundary keeps operator lists, relationship searches, saved views, and server serialization in the application where their meaning lives. Ordinary Klean controls edit draft; Filter Bar only coordinates when the complete object becomes committed.
Popover, Sheet, or inline?
Keep a small filter set inline. Put a compact desktop filter form inside Popover when it must float near a trigger. Use Sheet when the same controls need more room on a narrow viewport. The caller chooses those truthful layout semantics; Filter Bar does not hide them behind a mode or responsive variant.
Whichever surface contains it, keep Apply and Cancel in the same native form. Close the surface only after the application accepts the commit, and return focus to its trigger. Filter Bar continues to handle draft state and active-filter focus independently of the surrounding layout.
Keyboard and accessibility
- The root is a native form named as a search region. Give every control a visible label or an equally specific accessible name.
- Enter submits when a control does not use Enter for its own composite behavior. A native reset button cancels the draft.
- Apply and Cancel are disabled when nothing changed. Apply, remove, and clear are disabled while
busyprevents a duplicate visit. - Build active filters from real buttons using
removeAttrsorremoveProps. Include the complete filter description in each accessible name. - After removal, focus reaches the next active filter, the previous one, Clear all, or a caller-marked
[data-filter-trigger]. - The committed count is announced politely and never depends on color.
- A Combobox, Select, Checkbox, Popover, or Sheet keeps its own established keyboard contract inside the form.
Styling with Tailwind
class or className merges onto the form. Everything inside it is application markup, so Tailwind remains the entire visual API. A product can keep filters quiet and inline, place them in a bordered toolbar, or create an application-owned wrapper reused across pages.
There are no variant, tone, density, chipClass, panelClass, or filter-type style props. When several pages share one visual treatment, create a small application component from the copied Klean source and ordinary controls.
When not to use
- Use Input for one independent text value that does not coordinate result state.
- Use Select or Combobox when the task is choosing a value, not applying a group of filters.
- Use the query helper from DataTable when the page primarily needs server search, sorting, selection, and pagination as one result system.
- Do not use Filter Bar as a generic form generator or a client-side ORM query builder.
- Do not hide a small, frequently changed filter behind a panel merely to make the page look sparse.
Complete framework source
Vue
<script setup>
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import { filtersEqual } from './filterState.js'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Accessible name for the native search form. */
label: { type: String, default: 'Filters' },
/** Prevents duplicate commits while an application visit is pending. */
busy: { type: Boolean, default: false }
})
const emit = defineEmits(['apply', 'cancel', 'clear', 'remove'])
const value = defineModel({ default: () => ({}) })
const attrs = useAttrs()
const root = ref()
const draft = ref(clone(value.value))
function clone(source) {
return JSON.parse(JSON.stringify(source ?? {}))
}
const entries = computed(() => Object.entries(value.value ?? {}))
const count = computed(() => entries.value.length)
const dirty = computed(() => !filtersEqual(draft.value, value.value))
const rootAttrs = computed(() => {
const {
class: _class,
onSubmit: _onSubmit,
onReset: _onReset,
'data-slot': _dataSlot,
'data-dirty': _dataDirty,
'data-empty': _dataEmpty,
...rest
} = attrs
return rest
})
function setDraft(next) {
draft.value = clone(
typeof next === 'function' ? next(clone(draft.value)) : next
)
}
function update(key, nextValue) {
setDraft((current) => ({ ...current, [key]: nextValue }))
}
function commit(next, eventName) {
if (props.busy) return
const committed = clone(next)
value.value = committed
draft.value = clone(committed)
emit(eventName, clone(committed))
}
function apply() {
if (!dirty.value) return
commit(draft.value, 'apply')
}
function cancel() {
draft.value = clone(value.value)
emit('cancel', clone(value.value))
}
function clear() {
if (!count.value) return
commit({}, 'clear')
}
function focusAfterRemoval(index) {
nextTick(() => {
const candidates = [
...(root.value?.querySelectorAll?.('[data-filter-remove]') ?? [])
]
const next = candidates[Math.min(index, candidates.length - 1)]
;(
next ??
root.value?.querySelector?.('[data-filter-clear], [data-filter-trigger]')
)?.focus?.()
})
}
function remove(key, event) {
if (props.busy || !(key in (value.value ?? {}))) return
const buttons = [
...(root.value?.querySelectorAll?.('[data-filter-remove]') ?? [])
]
const index = Math.max(0, buttons.indexOf(event?.currentTarget))
const next = clone(value.value)
delete next[key]
commit(next, 'remove')
focusAfterRemoval(index)
}
function handleSubmit(event) {
for (const listener of Array.isArray(attrs.onSubmit)
? attrs.onSubmit
: [attrs.onSubmit]) {
listener?.(event)
}
if (!event.defaultPrevented) {
event.preventDefault()
apply()
}
}
function handleReset(event) {
for (const listener of Array.isArray(attrs.onReset)
? attrs.onReset
: [attrs.onReset]) {
listener?.(event)
}
if (!event.defaultPrevented) {
event.preventDefault()
cancel()
}
}
function removeAttrs(key, label) {
return {
type: 'button',
disabled: props.busy,
'aria-label': label ?? `Remove ${key} filter`,
'data-filter-remove': '',
'data-filter-key': key,
onClick: (event) => remove(key, event)
}
}
const applyAttrs = computed(() => ({
type: 'submit',
disabled: props.busy || !dirty.value
}))
const cancelAttrs = computed(() => ({
type: 'reset',
disabled: props.busy || !dirty.value
}))
const clearAttrs = computed(() => ({
type: 'button',
disabled: props.busy || count.value === 0,
'data-filter-clear': '',
onClick: clear
}))
watch(
value,
(next) => {
if (!filtersEqual(next, draft.value)) draft.value = clone(next)
},
{ deep: true }
)
defineExpose({ root, setDraft, apply, cancel, clear, remove })
</script>
<template>
<form
ref="root"
v-bind="rootAttrs"
role="search"
:aria-label="label"
:aria-busy="busy ? 'true' : undefined"
data-slot="filter-bar"
:data-dirty="dirty ? '' : undefined"
:data-empty="count === 0 ? '' : undefined"
:class="twMerge('flex flex-wrap items-center gap-2', attrs.class)"
@submit="handleSubmit"
@reset="handleReset"
>
<slot
:draft="draft"
:entries="entries"
:count="count"
:dirty="dirty"
:busy="busy"
:set-draft="setDraft"
:update="update"
:apply="apply"
:cancel="cancel"
:clear="clear"
:remove="remove"
:remove-attrs="removeAttrs"
:apply-attrs="applyAttrs"
:cancel-attrs="cancelAttrs"
:clear-attrs="clearAttrs"
/>
<span class="sr-only" aria-live="polite" aria-atomic="true">
{{ count }} active {{ count === 1 ? 'filter' : 'filters' }}.
</span>
</form>
</template>
function ordered(value) {
if (Array.isArray(value)) return value.map(ordered)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.keys(value)
.sort((left, right) => left.localeCompare(right))
.map((key) => [key, ordered(value[key])])
)
}
export function stableFilters(filters = {}) {
return JSON.stringify(ordered(filters ?? {}))
}
export function filtersEqual(left, right) {
return stableFilters(left) === stableFilters(right)
}
export function filtersFromUrl(url, defaults = {}) {
const parsed = new URL(url, 'https://klean.local')
const encoded = parsed.searchParams.get('filters')
if (!encoded) return structuredClone(defaults ?? {})
try {
const filters = JSON.parse(encoded)
if (!filters || Array.isArray(filters) || typeof filters !== 'object') {
return structuredClone(defaults ?? {})
}
return filters
} catch {
return structuredClone(defaults ?? {})
}
}
export function filterUrl(url, filters, defaults = {}) {
const parsed = new URL(url, 'https://klean.local')
if (
filtersEqual(filters, defaults) ||
Object.keys(filters ?? {}).length === 0
) {
parsed.searchParams.delete('filters')
} else {
parsed.searchParams.set('filters', stableFilters(filters))
}
if (/^[a-z][a-z\d+.-]*:/i.test(url)) return parsed.href
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
React
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import { filtersEqual, stableFilters } from './filterState.js'
function clone(value) {
return JSON.parse(JSON.stringify(value ?? {}))
}
const FilterBar = forwardRef(function FilterBar(
{
value,
defaultValue = {},
onChange,
onApply,
onCancel,
onClear,
onRemove,
label = 'Filters',
busy = false,
className,
children,
onSubmit,
onReset,
...formProps
},
forwardedRef
) {
const formRef = useRef(null)
const [internalValue, setInternalValue] = useState(() => clone(defaultValue))
const controlled = value !== undefined
const committed = controlled ? value : internalValue
const committedSignature = stableFilters(committed)
const [draft, setDraftState] = useState(() => clone(committed))
const entries = useMemo(
() => Object.entries(committed ?? {}),
[committedSignature]
)
const count = entries.length
const dirty = !filtersEqual(draft, committed)
useEffect(() => {
setDraftState(clone(committed))
}, [committedSignature])
function setRef(node) {
formRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}
function setDraft(next) {
setDraftState((current) =>
clone(typeof next === 'function' ? next(clone(current)) : next)
)
}
function update(key, nextValue) {
setDraftState((current) => ({ ...current, [key]: nextValue }))
}
function commit(next, callback) {
if (busy) return
const committedNext = clone(next)
if (!controlled) setInternalValue(committedNext)
setDraftState(clone(committedNext))
onChange?.(clone(committedNext))
callback?.(clone(committedNext))
}
function apply() {
if (!dirty) return
commit(draft, onApply)
}
function cancel() {
const next = clone(committed)
setDraftState(next)
onCancel?.(clone(next))
}
function clear() {
if (!count) return
commit({}, onClear)
}
function focusAfterRemoval(index) {
window.setTimeout(() => {
const buttons = [
...(formRef.current?.querySelectorAll?.('[data-filter-remove]') ?? [])
]
const next = buttons[Math.min(index, buttons.length - 1)]
;(
next ??
formRef.current?.querySelector?.(
'[data-filter-clear], [data-filter-trigger]'
)
)?.focus?.()
})
}
function remove(key, event) {
if (busy || !(key in (committed ?? {}))) return
const buttons = [
...(formRef.current?.querySelectorAll?.('[data-filter-remove]') ?? [])
]
const index = Math.max(0, buttons.indexOf(event?.currentTarget))
const next = clone(committed)
delete next[key]
commit(next, (filters) => onRemove?.(filters, key))
focusAfterRemoval(index)
}
function handleSubmit(event) {
onSubmit?.(event)
if (!event.defaultPrevented) {
event.preventDefault()
apply()
}
}
function handleReset(event) {
onReset?.(event)
if (!event.defaultPrevented) {
event.preventDefault()
cancel()
}
}
function removeProps(key, removeLabel) {
return {
type: 'button',
disabled: busy,
'aria-label': removeLabel ?? `Remove ${key} filter`,
'data-filter-remove': '',
'data-filter-key': key,
onClick: (event) => remove(key, event)
}
}
const state = {
draft,
setDraft,
entries,
count,
dirty,
busy,
update,
apply,
cancel,
clear,
remove,
removeProps,
applyProps: { type: 'submit', disabled: busy || !dirty },
cancelProps: { type: 'reset', disabled: busy || !dirty },
clearProps: {
type: 'button',
disabled: busy || count === 0,
'data-filter-clear': '',
onClick: clear
}
}
return (
<form
{...formProps}
ref={setRef}
role="search"
aria-label={label}
aria-busy={busy || undefined}
data-slot="filter-bar"
data-dirty={dirty ? '' : undefined}
data-empty={count === 0 ? '' : undefined}
className={twMerge('flex flex-wrap items-center gap-2', className)}
onSubmit={handleSubmit}
onReset={handleReset}
>
{children?.(state)}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{count} active {count === 1 ? 'filter' : 'filters'}.
</span>
</form>
)
})
export default FilterBar
function ordered(value) {
if (Array.isArray(value)) return value.map(ordered)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.keys(value)
.sort((left, right) => left.localeCompare(right))
.map((key) => [key, ordered(value[key])])
)
}
function clone(value) {
return JSON.parse(JSON.stringify(value ?? {}))
}
export function stableFilters(filters = {}) {
return JSON.stringify(ordered(filters ?? {}))
}
export function filtersEqual(left, right) {
return stableFilters(left) === stableFilters(right)
}
export function filtersFromUrl(url, defaults = {}) {
const parsed = new URL(url, 'https://klean.local')
const encoded = parsed.searchParams.get('filters')
if (!encoded) return clone(defaults)
try {
const filters = JSON.parse(encoded)
if (!filters || Array.isArray(filters) || typeof filters !== 'object') {
return clone(defaults)
}
return filters
} catch {
return clone(defaults)
}
}
export function filterUrl(url, filters, defaults = {}) {
const parsed = new URL(url, 'https://klean.local')
if (
filtersEqual(filters, defaults) ||
Object.keys(filters ?? {}).length === 0
) {
parsed.searchParams.delete('filters')
} else {
parsed.searchParams.set('filters', stableFilters(filters))
}
if (/^[a-z][a-z\d+.-]*:/i.test(url)) return parsed.href
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
Svelte
<script>
import { untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import { filtersEqual, stableFilters } from "./filterState.js";
let {
value = $bindable(),
defaultValue = {},
label = "Filters",
busy = false,
class: className = "",
children,
onchange,
onapply,
oncancel,
onclear,
onremove,
onsubmit,
onreset,
"data-slot": _dataSlot,
"data-dirty": _dataDirty,
"data-empty": _dataEmpty,
...formProps
} = $props();
function clone(source) {
return JSON.parse(JSON.stringify(source ?? {}));
}
let formElement;
let internalValue = $state(clone(untrack(() => defaultValue)));
let committed = $derived(value !== undefined ? value : internalValue);
let committedSignature = $derived(stableFilters(committed));
let draft = $state(clone(untrack(() => committed)));
let entries = $derived(Object.entries(committed ?? {}));
let count = $derived(entries.length);
let dirty = $derived(!filtersEqual(draft, committed));
let previousSignature = untrack(() => committedSignature);
$effect(() => {
const signature = committedSignature;
if (signature !== previousSignature) {
draft = clone(committed);
previousSignature = signature;
}
});
function setDraft(next) {
draft = clone(typeof next === "function" ? next(clone(draft)) : next);
}
function update(key, nextValue) {
draft = { ...draft, [key]: nextValue };
}
function commit(next, callback) {
if (busy) return;
const committedNext = clone(next);
if (value === undefined) internalValue = committedNext;
else value = committedNext;
draft = clone(committedNext);
onchange?.(clone(committedNext));
callback?.(clone(committedNext));
}
function apply() {
if (!dirty) return;
commit(draft, onapply);
}
function cancel() {
const next = clone(committed);
draft = next;
oncancel?.(clone(next));
}
function clear() {
if (!count) return;
commit({}, onclear);
}
function focusAfterRemoval(index) {
setTimeout(() => {
const buttons = [
...(formElement?.querySelectorAll?.("[data-filter-remove]") ?? []),
];
const next = buttons[Math.min(index, buttons.length - 1)];
(
next ??
formElement?.querySelector?.(
"[data-filter-clear], [data-filter-trigger]",
)
)?.focus?.();
});
}
function remove(key, event) {
if (busy || !(key in (committed ?? {}))) return;
const buttons = [
...(formElement?.querySelectorAll?.("[data-filter-remove]") ?? []),
];
const index = Math.max(0, buttons.indexOf(event?.currentTarget));
const next = clone(committed);
delete next[key];
commit(next, (filters) => onremove?.(filters, key));
focusAfterRemoval(index);
}
function handleSubmit(event) {
onsubmit?.(event);
if (!event.defaultPrevented) {
event.preventDefault();
apply();
}
}
function handleReset(event) {
onreset?.(event);
if (!event.defaultPrevented) {
event.preventDefault();
cancel();
}
}
function removeProps(key, removeLabel) {
return {
type: "button",
disabled: busy,
"aria-label": removeLabel ?? `Remove ${key} filter`,
"data-filter-remove": "",
"data-filter-key": key,
onclick: (event) => remove(key, event),
};
}
let state = $derived({
draft,
setDraft,
entries,
count,
dirty,
busy,
update,
apply,
cancel,
clear,
remove,
removeProps,
applyProps: { type: "submit", disabled: busy || !dirty },
cancelProps: { type: "reset", disabled: busy || !dirty },
clearProps: {
type: "button",
disabled: busy || count === 0,
"data-filter-clear": "",
onclick: clear,
},
});
</script>
<form
{...formProps}
bind:this={formElement}
role="search"
aria-label={label}
aria-busy={busy ? "true" : undefined}
data-slot="filter-bar"
data-dirty={dirty ? "" : undefined}
data-empty={count === 0 ? "" : undefined}
class={twMerge("flex flex-wrap items-center gap-2", className)}
onsubmit={handleSubmit}
onreset={handleReset}
>
{@render children?.(state)}
<span class="sr-only" aria-live="polite" aria-atomic="true">
{count} active {count === 1 ? "filter" : "filters"}.
</span>
</form>
function ordered(value) {
if (Array.isArray(value)) return value.map(ordered)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.keys(value)
.sort((left, right) => left.localeCompare(right))
.map((key) => [key, ordered(value[key])])
)
}
function clone(value) {
return JSON.parse(JSON.stringify(value ?? {}))
}
export function stableFilters(filters = {}) {
return JSON.stringify(ordered(filters ?? {}))
}
export function filtersEqual(left, right) {
return stableFilters(left) === stableFilters(right)
}
export function filtersFromUrl(url, defaults = {}) {
const parsed = new URL(url, 'https://klean.local')
const encoded = parsed.searchParams.get('filters')
if (!encoded) return clone(defaults)
try {
const filters = JSON.parse(encoded)
if (!filters || Array.isArray(filters) || typeof filters !== 'object') {
return clone(defaults)
}
return filters
} catch {
return clone(defaults)
}
}
export function filterUrl(url, filters, defaults = {}) {
const parsed = new URL(url, 'https://klean.local')
if (
filtersEqual(filters, defaults) ||
Object.keys(filters ?? {}).length === 0
) {
parsed.searchParams.delete('filters')
} else {
parsed.searchParams.set('filters', stableFilters(filters))
}
if (/^[a-z][a-z\d+.-]*:/i.test(url)) return parsed.href
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
Related components
- DataTable — server-driven search, filters, sorting, selection, and pagination as one result system.
- Input — free-text search and scalar values.
- Select — one value from a short fixed list.
- Combobox — searchable local or remote values.
- Checkbox — independent boolean filter choices.
- Popover — a compact non-modal desktop filter surface.
- Sheet — a roomy narrow-viewport filter surface.
- Badge — static metadata only; removable filters stay real buttons.