Filter bar

A menu of tick boxes per property, the choices in use as removable tags, and one button to clear them all.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add filter-bar
pnpm dlx ermineui add filter-bar
yarn dlx ermineui add filter-bar
bunx ermineui add filter-bar

That copies 4 files into src/components/ui/filter-bar/, and the 2 components it is built from. Files you already have are kept.

Use

import { FilterBar, facetOptions } from '@/components/ui/filter-bar'
<script setup lang="ts">
import { FilterBar } from '@/components/ui/filter-bar'
</script>

<template>
  <FilterBar />
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
FilterBar.vuesrc/components/ui/filter-bar/FilterBar.vue
facets.tssrc/components/ui/filter-bar/facets.ts
index.tssrc/components/ui/filter-bar/index.ts
types.tssrc/components/ui/filter-bar/types.ts

It is built from: Button, Dropdown menu. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

FilterBar.vue 174 lines
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '../button'
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '../dropdown-menu'
import type { FilterBarFacet, FilterBarOption, FilterBarValue } from './types'

const props = withDefaults(
  defineProps<{
    facets: readonly FilterBarFacet[]
    /** Accessible name of the whole bar. */
    label?: string
  }>(),
  { label: 'Filters' },
)

/** For each facet key, the values that are ticked. Bind the same object to a DataTable's `filters`. */
const model = defineModel<FilterBarValue>({ default: () => ({}) })

const option = (item: string | FilterBarOption): FilterBarOption => (typeof item === 'string' ? { value: item } : item)
const chosen = (key: string) => model.value[key] ?? []

function toggle(key: string, value: string) {
  const current = chosen(key)
  model.value = { ...model.value, [key]: current.includes(value) ? current.filter((other) => other !== value) : [...current, value] }
}

/** Every ticked value, in the order of the facets, for the row of removable tags. */
const active = computed(() =>
  props.facets.flatMap((facet) =>
    facet.options
      .map(option)
      .filter((item) => chosen(facet.key).includes(item.value))
      .map((item) => ({ key: facet.key, value: item.value, text: `${facet.label}: ${item.label ?? item.value}` })),
  ),
)

function clear() {
  model.value = {}
}
</script>

<template>
  <div class="er-filter-bar" role="group" :aria-label="label">
    <div class="er-filter-bar-controls">
      <!-- Anything that belongs in the same row, such as a search field or a date range. -->
      <slot />
      <DropdownMenu v-for="facet in facets" :key="facet.key">
        <DropdownMenuTrigger as-child>
          <Button variant="outline" size="sm">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
            {{ facet.label }}
            <span v-if="chosen(facet.key).length" class="er-filter-bar-count">{{ chosen(facet.key).length }}<span class="er-filter-bar-hidden"> chosen</span></span>
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          <DropdownMenuCheckboxItem
            v-for="item in facet.options.map(option)"
            :key="item.value"
            :model-value="chosen(facet.key).includes(item.value)"
            @update:model-value="toggle(facet.key, item.value)"
          >
            {{ item.label ?? item.value }}
            <span v-if="item.count !== undefined" class="er-filter-bar-tally">{{ item.count }}</span>
          </DropdownMenuCheckboxItem>
        </DropdownMenuContent>
      </DropdownMenu>
      <Button v-if="active.length" variant="ghost" size="sm" @click="clear">Clear all</Button>
    </div>
    <ul v-if="active.length" class="er-filter-bar-active" aria-label="Filters in use">
      <li v-for="chip in active" :key="`${chip.key}/${chip.value}`">
        {{ chip.text }}
        <button type="button" :aria-label="`Remove filter ${chip.text}`" @click="toggle(chip.key, chip.value)">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
        </button>
      </li>
    </ul>
  </div>
</template>

<style>
@layer components {
  .er-filter-bar {
    display: grid;
    gap: 0.5rem;
    min-width: 0;
  }

  .er-filter-bar-controls,
  .er-filter-bar-active {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }

  .er-filter-bar-count {
    display: inline-grid;
    place-items: center;
    min-width: 1.25rem;
    height: 1.25rem;
    padding: 0 0.25rem;
    border-radius: var(--er-radius-sm);
    background: var(--er-primary);
    color: var(--er-primary-foreground);
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
  }

  .er-filter-bar-tally {
    margin-left: auto;
    padding-left: 1rem;
    color: var(--er-muted-foreground);
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
  }

  .er-filter-bar-active {
    margin: 0;
    padding: 0;
    list-style: none;
    gap: 0.375rem;
  }

  .er-filter-bar-active li {
    display: inline-flex;
    align-items: center;
    gap: 0.125rem;
    padding: 0.125rem 0.125rem 0.125rem 0.5rem;
    border-radius: var(--er-radius-sm);
    background: var(--er-secondary);
    color: var(--er-secondary-foreground);
    font-size: 0.75rem;
    font-weight: var(--er-weight-medium);
    line-height: 1.25rem;
  }

  .er-filter-bar-active button {
    display: grid;
    place-items: center;
    width: 1.25rem;
    height: 1.25rem;
    padding: 0;
    border: 0;
    border-radius: var(--er-radius-sm);
    background: transparent;
    color: var(--er-muted-foreground);
    cursor: pointer;
  }

  .er-filter-bar-active button:hover {
    background: color-mix(in oklab, var(--er-foreground) 10%, transparent);
    color: var(--er-foreground);
  }

  .er-filter-bar-active button:focus-visible {
    outline: 2px solid var(--er-ring);
  }

  .er-filter-bar-active svg {
    width: 0.75rem;
    height: 0.75rem;
  }

  .er-filter-bar-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
</style>
facets.ts 16 lines
import type { FilterBarOption } from './types'

// Fixed locale, so server and browser list the values in the same order.
const collator = new Intl.Collator('en-US', { numeric: true, sensitivity: 'base' })

/** The different values one property takes across some rows, each with how many rows have it: ready to use as a facet's options. */
export function facetOptions(rows: readonly object[], key: string): FilterBarOption[] {
  const counts = new Map<string, number>()
  for (const row of rows) {
    const value = (row as Record<string, unknown>)[key]
    if (value === null || value === undefined || value === '') continue
    counts.set(String(value), (counts.get(String(value)) ?? 0) + 1)
  }
  return [...counts].map(([value, count]) => ({ value, count })).sort((a, b) => collator.compare(a.value, b.value))
}
index.ts 5 lines
/** Filter bar — a menu of tick boxes per property, the choices in use as removable tags, and one button to clear them all. */
export { default as FilterBar } from './FilterBar.vue'
export { facetOptions } from './facets'
export type { FilterBarFacet, FilterBarOption, FilterBarValue } from './types'
types.ts 18 lines
export interface FilterBarOption {
  value: string
  /** What to call the value. The value itself is shown when there is none. */
  label?: string
  /** How many rows have this value, shown beside it. */
  count?: number
}

export interface FilterBarFacet {
  /** The property being filtered on. With a DataTable, the column's key. */
  key: string
  label: string
  options: ReadonlyArray<string | FilterBarOption>
}

/** For each facet key, the values that are ticked. */
export type FilterBarValue = Record<string, string[]>