CollaborationFilter menu
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callFilter menu
Add filters from a menu, see them as chips, and watch the count change. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add filter-menuReact
npx designme add filter-menu --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Filter menu is the filter bar above a list. A Filter button opens a menu in two steps, a field and then its value, and each filter in use shows as a chip that reads like a sentence, "Status is Open". A chip's operator flips with a click, its × takes it off, and the count of matches and Clear all sit under the chips.
- When a list can be narrowed by a few known fields, like status, owner or date.
- When people should see at a glance which filters are on, and take one off without opening a menu.
- When the number of matches matters, so people know how far a filter narrowed the list before they read it.
- When a filter sometimes needs to read the other way, like "is not" or "older than".
- The menu's first step lists the fields, each with its icon and, once used, its value. Picking one shows its values, with a back row to return.
- Each value can show how many items picking it would leave, with the other filters kept (
countFor). The value in use is checked. - One filter per field: picking a new value for a field replaces the old one. A new chip pops in; the ones already there keep still.
- Clicking a chip's operator flips it between the field's two
operators, "is" and "is not" by default, or "within" and "older than" for a date. - The count reads "24 issues", or "4 of 24 issues" once a filter is on, and crossfades as it changes. Clear all takes every filter off. The first few matches show under it, with "and 5 more" for the rest, or a line saying nothing matches.
- The Filter button has
aria-haspopup="menu"andaria-expanded. Enter, Space or the down arrow opens the menu on its first field, the up arrow on its last. - Each step is a
role="menu". Fields aremenuitems witharia-haspopup="menu", and values aremenuitemradioitems witharia-checked. - Inside, the arrow keys move and wrap, Home and End jump to the ends, the right arrow opens a field's values and the left arrow goes back. Escape closes the menu and gives the focus back to the Filter button; picking a value does too.
- The chips are a list named "Filters". Each operator and × button is labelled with the whole filter, like "Remove filter Status is Open". The count is a polite live region.
Usage
Import it and drive it from your own state.
Import
import { FilterMenu } from '@/components/designme/filter-menu/FilterMenu';Example
The demo above as a React component, 59 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { FilterMenu, type FilterMenuField, type FilterMenuFilter } from './FilterMenu';
import { avatarInk } from '../lib/motion';
import { useStory } from '../lib/useScript';
/* ---------- Filter menu: filter to open issues, then to Maya's, then take the status filter off ---------- */
const OWNERS: Record<string, [string, string]> = { MK: ['Maya Kim', 'var(--dm-av-l)'], LP: ['Leo Park', 'var(--dm-av-b)'], AR: ['Ana Ruiz', 'var(--dm-av-m)'], SC: ['Sam Cole', 'var(--dm-av-p)'] };
const STATUS: Record<string, [string, string]> = { open: ['Open', 'var(--dm-flm-open)'], prog: ['In progress', 'var(--dm-flm-prog)'], done: ['Done', 'var(--dm-flm-done)'] };
// title, status, owner and days since the last update, most recent first
const ISSUES = ([
['Pricing toggle hides the yearly saving', 'open', 'MK', 0], ['Plan cards wrap at 768px', 'open', 'MK', 1], ['Checkout button loses its focus ring', 'open', 'MK', 3],
['Invoice email shows the wrong currency', 'open', 'MK', 9], ['Seat slider skips 25', 'open', 'LP', 0], ['Coupon field accepts expired codes', 'open', 'LP', 2],
['Dark logo has a white edge', 'open', 'AR', 1], ['FAQ accordion jumps on open', 'open', 'AR', 5], ['Trial banner covers the nav', 'open', 'SC', 0],
['EU tax line rounds down', 'open', 'SC', 12], ['Annual plan link goes to a 404', 'open', 'LP', 20], ['Move pricing copy into the CMS', 'prog', 'MK', 0],
['Comparison table for Team', 'prog', 'MK', 2], ['Prices in yen for Japan', 'prog', 'LP', 4], ['New hero illustration', 'prog', 'AR', 1],
['Track plan clicks', 'prog', 'SC', 6], ['Enterprise contact form', 'prog', 'MK', 15], ['Seat limits on the Free plan', 'prog', 'AR', 3],
['Ship the new plan names', 'done', 'MK', 1], ['Price flicker on load', 'done', 'MK', 8], ['Remove the Starter plan', 'done', 'LP', 3],
['Badge for the yearly discount', 'done', 'AR', 2], ['Retry failed Stripe webhooks', 'done', 'SC', 25], ['Refund policy link', 'done', 'SC', 10]
] as [string, string, string, number][]).map(([title, status, owner, age]) => ({ title, status, owner, age })).sort((a, b) => a.age - b.age);
type Issue = (typeof ISSUES)[number];
const FIELDS: FilterMenuField[] = [
{ key: 'status', label: 'Status', icon: 'target', options: Object.entries(STATUS).map(([value, [label, color]]) => ({ value, label, glyph: <span className="dm-dot" style={{ color }} /> })) },
{ key: 'owner', label: 'Owner', icon: 'user', options: Object.entries(OWNERS).map(([value, [label, color]]) => ({ value, label, glyph: <span className="dm-av" style={{ background: color, color: avatarInk(color) }}>{value}</span> })) },
{ key: 'date', label: 'Updated', icon: 'cal', operators: ['within', 'older than'], options: [{ value: '1', label: 'Past day', chip: '1 day' }, { value: '7', label: 'Past 7 days', chip: '7 days' }, { value: '30', label: 'Past 30 days', chip: '30 days' }] }
];
const test = (t: Issue, f: FilterMenuFilter) => (f.field === 'status' ? t.status === f.value : f.field === 'owner' ? t.owner === f.value : t.age < +f.value) !== !!f.negate;
export default function FilterMenuDemo() {
const ref = useRef<HTMLDivElement>(null);
const [filters, setFilters] = useState<FilterMenuFilter[]>([]);
const hits = ISSUES.filter(t => filters.every(f => test(t, f)));
// the preview's story: filter to open issues, then to Maya's, and watch the count drop; then take the status filter off
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('.dm-flm-tr');
await wait(1200); press('.dm-mi[data-k="status"]');
await wait(1200); press('.dm-flm-lv:not([hidden]) .dm-mi[role="menuitemradio"]');
await wait(2500); press('.dm-flm-tr');
await wait(1200); press('.dm-mi[data-k="owner"]');
await wait(1200); press('.dm-flm-lv:not([hidden]) .dm-mi[role="menuitemradio"]');
await wait(2500); press('.dm-flm-x');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<FilterMenu fields={FIELDS} filters={filters} onFiltersChange={setFilters} total={ISSUES.length} count={hits.length}
// each value says how many issues picking it would leave, with the other filters kept
countFor={(field, value) => ISSUES.filter(t => filters.every(f => f.field === field || test(t, f)) && test(t, { field, value })).length}
results={hits.slice(0, 4).map(t => ({
id: t.title, title: t.title, meta: t.age ? t.age + 'd' : 'Today',
dot: { color: STATUS[t.status][1], label: STATUS[t.status][0] }, avatar: { initials: t.owner, color: OWNERS[t.owner][1], label: OWNERS[t.owner][0] }
}))} />
</div>
);
}
Props
Everything FilterMenu takes.
| Prop | Type | What it does |
|---|---|---|
fieldsrequired | FilterMenuField[] | |
filters | FilterMenuFilter[] | The filters in use, in the order they were added; one per field. Leave it out to let the menu keep them |
defaultFilters | FilterMenuFilter[] | |
onFiltersChange | (filters: FilterMenuFilter[]) => void | |
totalrequired | number | How many items there are, and how many match the filters |
countrequired | number | |
noun | string | The items' name, like "issues" |
countFor | (field: string, value: string) => number | The number beside a value in the menu: how many items picking it would leave. Left out, no numbers show |
results | FilterMenuResult[] | The first few matches, shown under the count |
emptyLabel | string | Shown when nothing matches. Defaults to "No issues match these filters" with your noun |
label | string | Defaults to "Filter" and "Clear all" |
clearLabel | string | |
open | boolean | Whether the menu is open. Leave it out to let the Filter button, Escape and a click outside open and close it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
className | string |
Code
The full source, as the CLI installs it.
FilterMenu.tsxReact · 211 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { avatarInk, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './filter-menu.css';
/** One value a field can be filtered by */
export interface FilterMenuOption {
value: string;
/** How the menu names it, like "Past 7 days" */
label: string;
/** How a chip names it, like "7 days". Defaults to the label */
chip?: string;
/** A dot, avatar or icon before the label in the menu */
glyph?: ReactNode;
}
/** A field the menu can filter on, like Status, Owner or Updated */
export interface FilterMenuField {
key: string;
label: string;
icon?: IconName;
/** The two ways a filter on it reads, the second when it's flipped. Defaults to "is" and "is not" */
operators?: [string, string];
options: FilterMenuOption[];
}
/** A filter in use. `negate` reads it the other way: "is not", or the field's second operator */
export interface FilterMenuFilter {
field: string;
value: string;
negate?: boolean;
}
/** A row in the results under the filters */
export interface FilterMenuResult {
id: string;
title: string;
/** The colour of the dot before it, like 'var(--dm-flm-open)', and what it means, like "Open" */
dot?: { color: string; label: string };
/** An avatar colour, like 'var(--dm-av-l)', brings its own ink for the initials */
avatar?: { initials: string; color: string; label: string };
/** The quiet text at the end, like "Today" or "3d" */
meta?: string;
}
export interface FilterMenuProps {
fields: FilterMenuField[];
/** The filters in use, in the order they were added; one per field. Leave it out to let the menu keep them */
filters?: FilterMenuFilter[];
defaultFilters?: FilterMenuFilter[];
onFiltersChange?: (filters: FilterMenuFilter[]) => void;
/** How many items there are, and how many match the filters */
total: number;
count: number;
/** The items' name, like "issues" */
noun?: string;
/** The number beside a value in the menu: how many items picking it would leave. Left out, no numbers show */
countFor?: (field: string, value: string) => number;
/** The first few matches, shown under the count */
results?: FilterMenuResult[];
/** Shown when nothing matches. Defaults to "No issues match these filters" with your noun */
emptyLabel?: string;
/** Defaults to "Filter" and "Clear all" */
label?: string;
clearLabel?: string;
/** Whether the menu is open. Leave it out to let the Filter button, Escape and a click outside open and close it */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/** A Filter button that adds filters from a two-step menu (a field, then its value), the filters as removable chips whose
operator flips with a click, and the number of matches with Clear all. */
export function FilterMenu({
fields, filters, defaultFilters = [], onFiltersChange, total, count, noun = 'issues', countFor, results = [], emptyLabel,
label = 'Filter', clearLabel = 'Clear all', open, defaultOpen = false, onOpenChange, className
}: FilterMenuProps) {
const [list, setList] = useControllable(filters, defaultFilters, onFiltersChange);
const menu = usePopover({ side: 'down', open, defaultOpen, onOpenChange });
const pop = menu.pop.ref, trigger = menu.trigger.ref;
const root = useRef<HTMLDivElement>(null);
// the field whose values the menu shows, or null for the list of fields
const [field, setField] = useState<string | null>(null);
const from = useRef<string | null>(null);
const focusTo = useRef<'first' | 'last' | 'value' | 'field' | null>(null);
// chips already on screen keep still when the list changes; only a new one pops in
const seen = useRef(new Set<string>());
useEffect(() => { seen.current = new Set(list.map(f => f.field)); }, [list]);
const fieldOf = (k: string) => fields.find(f => f.key === k);
const opsOf = (f?: FilterMenuField) => f?.operators ?? ['is', 'is not'];
const optOf = (f: FilterMenuFilter) => fieldOf(f.field)?.options.find(o => o.value === f.value);
const items = () => Array.from(pop.current?.querySelectorAll<HTMLElement>('[role="menu"]:not([hidden]) [role^="menuitem"]') ?? []);
// the menu always opens on its list of fields
useEffect(() => { if (!menu.open) setField(null); }, [menu.open]);
// after opening, or moving between the two steps, the focus goes where the keys asked
useEffect(() => {
const to = focusTo.current; if (!menu.open || !to) return;
focusTo.current = null;
const all = items();
const pick = to === 'last' ? all[all.length - 1] : to === 'value' ? (all.find(x => x.getAttribute('aria-checked') === 'true') ?? all[1]) : to === 'field' ? all.find(x => x.dataset.k === from.current) : all[0];
(pick ?? all[0])?.focus();
}); // eslint-disable-line react-hooks/exhaustive-deps
const inside = () => !!pop.current?.contains(document.activeElement);
const step = (k: string | null) => { if (inside()) focusTo.current = k ? 'value' : 'field'; from.current = field; setField(k); };
const apply = (k: string, value: string) => {
const had = inside();
setList(list.some(f => f.field === k) ? list.map(f => (f.field === k ? { field: k, value } : f)) : [...list, { field: k, value }]);
if (had) trigger.current?.focus();
menu.closeSoon();
};
const remove = (k: string, e: MouseEvent<HTMLButtonElement>) => {
const chip = e.currentTarget.closest('.dm-flm-ch'), next = (chip?.nextElementSibling ?? chip?.previousElementSibling)?.querySelector<HTMLElement>('.dm-flm-x');
const had = chip?.contains(document.activeElement);
setList(list.filter(f => f.field !== k));
if (had) setTimeout(() => (next ?? trigger.current)?.focus());
};
const onTrigger = (e: MouseEvent) => { if (!menu.open && e.nativeEvent.isTrusted) focusTo.current = 'first'; menu.trigger.onClick(); };
const onTriggerKey = (e: KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault(); focusTo.current = e.key === 'ArrowUp' ? 'last' : 'first';
if (menu.open) { const all = items(); (focusTo.current === 'last' ? all[all.length - 1] : all[0])?.focus(); focusTo.current = null; } else menu.setOpen(true);
};
// inside the menu: the arrow keys move and wrap, Home and End jump to the ends, the right arrow opens a field's values and
// the left arrow goes back, Tab leaves it (Escape is the popover's)
const onMenuKey = (e: KeyboardEvent) => {
const all = items(), i = all.indexOf(document.activeElement as HTMLElement);
const go = ({ ArrowDown: all[(i + 1) % all.length], ArrowUp: all[(i - 1 + all.length) % all.length], Home: all[0], End: all[all.length - 1] } as Record<string, HTMLElement | undefined>)[e.key];
if (go) { e.preventDefault(); go.focus(); }
else if (e.key === 'ArrowRight' && !field && all[i]?.dataset.k) { e.preventDefault(); step(all[i].dataset.k!); }
else if (e.key === 'ArrowLeft' && field) { e.preventDefault(); step(null); }
else if (e.key === 'Tab') setTimeout(() => menu.close());
};
const cur = field ? fieldOf(field) : undefined, on = list.find(f => f.field === field);
return (
<div data-dm="" ref={root} className={cx('dm-flm dm-panel', className)}>
<div className="dm-flm-bar">
<button {...menu.trigger} onClick={onTrigger} onKeyDown={onTriggerKey} type="button" className="dm-pill dm-line dm-sm dm-flm-tr"><Icon name="filter" />{label}</button>
<div className="dm-flm-chips" role="list" aria-label="Filters">
{list.map(f => {
const fd = fieldOf(f.field), o = optOf(f); if (!fd || !o) return null;
const ops = opsOf(fd), op = ops[+!!f.negate], other = ops[+!f.negate], text = `${fd.label} ${op} ${o.chip ?? o.label}`;
return (
<div key={f.field} className={cx('dm-flm-ch', seen.current.has(f.field) && 'dm-seen')} role="listitem">
<span className="dm-k">{fd.label}</span>
<Tip label={`Change to ${other}`}>
<button type="button" className="dm-op" aria-label={`${text}: change to ${other}`} onClick={() => setList(list.map(x => (x.field === f.field ? { ...x, negate: !x.negate } : x)))}>{op}</button>
</Tip>
<span className="dm-v">{o.chip ?? o.label}</span>
<Tip label="Remove filter">
<button type="button" className="dm-x dm-flm-x" aria-label={`Remove filter ${text}`} onClick={e => remove(f.field, e)}><Icon name="x" size="xs" /></button>
</Tip>
</div>
);
})}
</div>
</div>
<div {...menu.pop} className={cx(menu.pop.className, 'dm-flm-pop')} onKeyDown={onMenuKey}>
<div className="dm-flm-lv" role="menu" aria-label={`${label} by`} hidden={!!field}>
<div className="dm-ph" role="none">{label} by</div>
{fields.map(fd => {
const f = list.find(x => x.field === fd.key), o = f && optOf(f);
return (
<button key={fd.key} type="button" className="dm-mi" role="menuitem" aria-haspopup="menu" tabIndex={-1} data-k={fd.key} onClick={() => step(fd.key)}>
{fd.icon && <Icon name={fd.icon} />}<span>{fd.label}</span>
<span className="dm-r">{o ? (f.negate ? opsOf(fd)[1] + ' ' : '') + (o.chip ?? o.label) : ''}</span><Icon name="chev-r" size="xs" />
</button>
);
})}
</div>
<div className="dm-flm-lv" role="menu" aria-label={cur?.label} hidden={!field}>
{cur && <>
<button type="button" className="dm-mi dm-flm-bk" role="menuitem" tabIndex={-1} aria-label="Back to all fields" onClick={() => step(null)}><Icon name="chev-l" size="xs" /><span>{cur.label}</span></button>
<div className="dm-sep" role="separator" />
{cur.options.map(o => (
<button key={o.value} type="button" className="dm-mi" role="menuitemradio" aria-checked={on?.value === o.value} tabIndex={-1} onClick={() => apply(cur.key, o.value)}>
{o.glyph}<span>{o.label}</span>{countFor && <span className="dm-r dm-num">{countFor(cur.key, o.value)}</span>}<span className="dm-ck"><Icon name="check" size="xs" /></span>
</button>
))}
</>}
</div>
</div>
<div className="dm-flm-sum">
<span aria-live="polite"><Swap className="dm-flm-ct dm-num">{list.length ? `${count} of ${total} ${noun}` : `${total} ${noun}`}</Swap></span>
{list.length > 0 && <button type="button" className="dm-pill dm-ghost dm-sm dm-flm-clr" onClick={e => { const had = e.currentTarget === document.activeElement; setList([]); if (had) trigger.current?.focus(); }}>{clearLabel}</button>}
</div>
<div className="dm-flm-list">
{count === 0 ? <div className="dm-flm-e">{emptyLabel ?? `No ${noun} match these filters`}</div> : <>
{results.map((t, i) => (
<div key={t.id} className="dm-flm-r dm-enter" style={{ animationDelay: i * 50 + 'ms' }}>
{t.dot ? <span className="dm-dot" style={{ color: t.dot.color }} role="img" aria-label={t.dot.label} /> : <span />}
<span className="dm-t">{t.title}</span>
{t.avatar ? <span className="dm-av" style={{ background: t.avatar.color, color: avatarInk(t.avatar.color) }} role="img" aria-label={t.avatar.label}>{t.avatar.initials}</span> : <span />}
<span className="dm-a">{t.meta}</span>
</div>
))}
{count > results.length && <div className="dm-flm-more">and {count - results.length} more</div>}
</>}
</div>
</div>
);
}
filter-menu.cssReact · 45 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Filter menu ---------- */
/* a list's filter bar: a Filter button that opens a two-step menu (a field, then its value), the filters as chips, and
the issues that match under them */
.dm-flm{width:100%;max-width:440px;margin:0 auto;padding:var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-flm-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.dm-flm-chips{display:contents}
.dm-flm-tr{padding:0 11px 0 9px}
.dm-flm-tr .dm-i{color:var(--dm-fg2)}
.dm-flm-tr[aria-expanded="true"]{background:var(--dm-chip)}
.dm-flm-ch{display:inline-flex;align-items:center;height:28px;padding-left:9px;border-radius:9px;background:var(--dm-chip);font-size:12.5px;white-space:nowrap;max-width:100%;animation:dm-popin .4s var(--dm-ease) backwards}
.dm-flm-ch.dm-seen{animation:none}
.dm-flm-ch .dm-k{color:var(--dm-fg2)}
.dm-flm-ch .dm-op{height:22px;padding:0 5px;margin:0 1px;border-radius:6px;color:var(--dm-muted);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-flm-ch .dm-op:hover{background:var(--dm-hover);color:var(--dm-fg)}}
.dm-flm-ch .dm-v{font-weight:500;overflow:hidden;text-overflow:ellipsis}
.dm-flm-ch .dm-x{width:26px;height:28px;display:grid;place-items:center;flex:none;color:var(--dm-muted);transition:color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-flm-ch .dm-x:hover{color:var(--dm-fg)}}
.dm-flm-ch.dm-gone{animation:dm-leave .3s var(--dm-ease) both}
.dm-flm-pop{width:232px}
.dm-flm-lv[hidden]{display:none}
.dm-flm-pop .dm-mi .dm-r{margin-left:auto}
.dm-flm-pop .dm-mi .dm-ck{margin-left:0}
.dm-flm-pop .dm-mi .dm-r+.dm-ck,.dm-flm-pop .dm-mi .dm-r+.dm-i{margin-left:0}
.dm-flm-pop .dm-mi>.dm-i:last-child{color:var(--dm-muted)}
.dm-flm-pop .dm-mi .dm-av{width:20px;height:20px;font-size:9px}
.dm-flm-pop .dm-mi .dm-dot{width:8px;height:8px;margin:0 3px}
.dm-flm-bk{color:var(--dm-fg2)}
.dm-flm-sum{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);min-height:36px;margin:2px -12px 0;padding:6px var(--dm-s-2) 0 var(--dm-s-4);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2)}
.dm-flm-list{display:flex;flex-direction:column;margin:0 -4px;min-height:164px}
.dm-flm-r{display:grid;grid-template-columns:auto minmax(0,1fr) auto 44px;align-items:center;gap:10px;padding:var(--dm-s-2);border-radius:10px;font-size:13px;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-flm-r:hover{background:var(--dm-row)}}
.dm-flm-r .dm-dot{width:8px;height:8px}
.dm-flm-r .dm-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-flm-r .dm-av{width:20px;height:20px;font-size:9px}
.dm-flm-r .dm-a{font-size:12px;color:var(--dm-muted);text-align:right}
.dm-flm-more{padding:6px var(--dm-s-2) 0;font-size:12.5px;color:var(--dm-muted)}
.dm-flm-e{padding:28px var(--dm-s-2);text-align:center;font-size:13px;color:var(--dm-muted)}
.dm-flm .dm-dot[data-s="open"]{color:var(--dm-flm-open)}.dm-flm .dm-dot[data-s="prog"]{color:var(--dm-flm-prog)}.dm-flm .dm-dot[data-s="done"]{color:var(--dm-flm-done)}
/* the status dots are vivid marks, 3:1 on the surface in every style and theme */
.dm-flm{--dm-flm-open:#2f8cff;--dm-flm-prog:#cd7a00;--dm-flm-done:#00a44b}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-flm{--dm-flm-open:#5b9dff;--dm-flm-prog:#f59e0b;--dm-flm-done:#22c55e}}
:root[data-theme="dark"] .dm-flm{--dm-flm-open:#5b9dff;--dm-flm-prog:#f59e0b;--dm-flm-done:#22c55e}
.dm-flm .dm-av[data-o="MK"]{background:var(--dm-av-l);color:var(--dm-av-l-ink)}.dm-flm .dm-av[data-o="LP"]{background:var(--dm-av-b);color:var(--dm-av-b-ink)}.dm-flm .dm-av[data-o="AR"]{background:var(--dm-av-m);color:var(--dm-av-m-ink)}.dm-flm .dm-av[data-o="SC"]{background:var(--dm-av-p);color:var(--dm-av-p-ink)}
filter-menu.htmlHTML · 17 lines
<div class="card" data-c="filter-menu">
<div class="stage-in">
<div class="flm panel">
<div class="flm-bar">
<button class="pill line sm flm-tr" aria-haspopup="menu" aria-expanded="false" data-pop-pos="down"><svg class="i s"><use href="#i-filter"/></svg>Filter</button>
<div class="flm-chips" role="list" aria-label="Filters"></div>
</div>
<div class="pop flm-pop">
<div class="flm-lv flm-l1" role="menu" aria-label="Filter by"><div class="ph" role="none">Filter by</div></div>
<div class="flm-lv flm-l2" role="menu" hidden></div>
</div>
<div class="flm-sum"><span class="flm-ct num" aria-live="polite"></span><button class="pill ghost sm flm-clr" hidden>Clear all</button></div>
<div class="flm-list"></div>
</div>
</div>
</div>
filter-menu.cssHTML · 44 lines
/* ---------- Filter menu ---------- */
/* a list's filter bar: a Filter button that opens a two-step menu (a field, then its value), the filters as chips, and
the issues that match under them */
.flm{width:100%;max-width:440px;margin:0 auto;padding:var(--s-3);display:flex;flex-direction:column;gap:var(--s-2)}
.flm-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.flm-chips{display:contents}
.flm-tr{padding:0 11px 0 9px}
.flm-tr .i{color:var(--fg2)}
.flm-tr[aria-expanded="true"]{background:var(--chip)}
.flm-ch{display:inline-flex;align-items:center;height:28px;padding-left:9px;border-radius:9px;background:var(--chip);font-size:12.5px;white-space:nowrap;max-width:100%;animation:popin .4s var(--ease) backwards}
.flm-ch.seen{animation:none}
.flm-ch .k{color:var(--fg2)}
.flm-ch .op{height:22px;padding:0 5px;margin:0 1px;border-radius:6px;color:var(--muted);transition:background var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.flm-ch .op:hover{background:var(--hover);color:var(--fg)}}
.flm-ch .v{font-weight:500;overflow:hidden;text-overflow:ellipsis}
.flm-ch .x{width:26px;height:28px;display:grid;place-items:center;flex:none;color:var(--muted);transition:color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.flm-ch .x:hover{color:var(--fg)}}
.flm-ch.gone{animation:leave .3s var(--ease) both}
.flm-pop{width:232px}
.flm-lv[hidden]{display:none}
.flm-pop .mi .r{margin-left:auto}
.flm-pop .mi .ck{margin-left:0}
.flm-pop .mi .r+.ck,.flm-pop .mi .r+.i{margin-left:0}
.flm-pop .mi>.i:last-child{color:var(--muted)}
.flm-pop .mi .av{width:20px;height:20px;font-size:9px}
.flm-pop .mi .dot{width:8px;height:8px;margin:0 3px}
.flm-bk{color:var(--fg2)}
.flm-sum{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);min-height:36px;margin:2px -12px 0;padding:6px var(--s-2) 0 var(--s-4);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:13px;color:var(--fg2)}
.flm-list{display:flex;flex-direction:column;margin:0 -4px;min-height:164px}
.flm-r{display:grid;grid-template-columns:auto minmax(0,1fr) auto 44px;align-items:center;gap:10px;padding:var(--s-2);border-radius:10px;font-size:13px;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.flm-r:hover{background:var(--row)}}
.flm-r .dot{width:8px;height:8px}
.flm-r .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.flm-r .av{width:20px;height:20px;font-size:9px}
.flm-r .a{font-size:12px;color:var(--muted);text-align:right}
.flm-more{padding:6px var(--s-2) 0;font-size:12.5px;color:var(--muted)}
.flm-e{padding:28px var(--s-2);text-align:center;font-size:13px;color:var(--muted)}
.flm .dot[data-s="open"]{color:var(--flm-open)}.flm .dot[data-s="prog"]{color:var(--flm-prog)}.flm .dot[data-s="done"]{color:var(--flm-done)}
/* the status dots are vivid marks, 3:1 on the surface in every style and theme */
.flm{--flm-open:#2f8cff;--flm-prog:#cd7a00;--flm-done:#00a44b}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .flm{--flm-open:#5b9dff;--flm-prog:#f59e0b;--flm-done:#22c55e}}
:root[data-theme="dark"] .flm{--flm-open:#5b9dff;--flm-prog:#f59e0b;--flm-done:#22c55e}
.flm .av[data-o="MK"]{background:var(--av-l);color:var(--av-l-ink)}.flm .av[data-o="LP"]{background:var(--av-b);color:var(--av-b-ink)}.flm .av[data-o="AR"]{background:var(--av-m);color:var(--av-m-ink)}.flm .av[data-o="SC"]{background:var(--av-p);color:var(--av-p-ink)}
filter-menu.jsHTML · 125 lines
DM.def('filter-menu', (r, c) => {
r.innerHTML = r.innerHTML.replace('{filter}', I('filter'));
const OWN = { MK:'Maya Kim', LP:'Leo Park', AR:'Ana Ruiz', SC:'Sam Cole' };
const ST = { open:'Open', prog:'In progress', done:'Done' };
// the issues: title, status, owner and days since the last update, most recent first
const T = [
['Pricing toggle hides the yearly saving', 'open', 'MK', 0], ['Plan cards wrap at 768px', 'open', 'MK', 1], ['Checkout button loses its focus ring', 'open', 'MK', 3],
['Invoice email shows the wrong currency', 'open', 'MK', 9], ['Seat slider skips 25', 'open', 'LP', 0], ['Coupon field accepts expired codes', 'open', 'LP', 2],
['Dark logo has a white edge', 'open', 'AR', 1], ['FAQ accordion jumps on open', 'open', 'AR', 5], ['Trial banner covers the nav', 'open', 'SC', 0],
['EU tax line rounds down', 'open', 'SC', 12], ['Annual plan link goes to a 404', 'open', 'LP', 20], ['Move pricing copy into the CMS', 'prog', 'MK', 0],
['Comparison table for Team', 'prog', 'MK', 2], ['Prices in yen for Japan', 'prog', 'LP', 4], ['New hero illustration', 'prog', 'AR', 1],
['Track plan clicks', 'prog', 'SC', 6], ['Enterprise contact form', 'prog', 'MK', 15], ['Seat limits on the Free plan', 'prog', 'AR', 3],
['Ship the new plan names', 'done', 'MK', 1], ['Price flicker on load', 'done', 'MK', 8], ['Remove the Starter plan', 'done', 'LP', 3],
['Badge for the yearly discount', 'done', 'AR', 2], ['Retry failed Stripe webhooks', 'done', 'SC', 25], ['Refund policy link', 'done', 'SC', 10]
].map(([t, s, o, a]) => ({ t, s, o, a })).sort((x, y) => x.a - y.a);
// the fields a filter can use, the two ways each one reads, and its values. A value's c is how a chip says it
const F = {
status:{ l:'Status', ic:'target', ops:['is', 'is not'], o:Object.entries(ST).map(([v, l]) => ({ v, l, g:`<span class="dot" data-s="${v}"></span>` })), test:(t, v) => t.s === v },
owner:{ l:'Owner', ic:'user', ops:['is', 'is not'], o:Object.entries(OWN).map(([v, l]) => ({ v, l, g:`<span class="av" data-o="${v}">${v}</span>` })), test:(t, v) => t.o === v },
date:{ l:'Updated', ic:'cal', ops:['within', 'older than'], o:[{ v:1, l:'Past day', c:'1 day' }, { v:7, l:'Past 7 days', c:'7 days' }, { v:30, l:'Past 30 days', c:'30 days' }], test:(t, v) => t.a < v }
};
const tr = $('.flm-tr', r), pop = $('.flm-pop', r), l1 = $('.flm-l1', r), l2 = $('.flm-l2', r), chips = $('.flm-chips', r), ct = $('.flm-ct', r), clr = $('.flm-clr', r), list = $('.flm-list', r);
// the filters in the order they were added: field, value, and whether it reads the other way (is not, older than)
let A = [];
const val = (k, v) => F[k].o.find(o => o.v === v);
const match = (t, skip) => A.every(f => f.k === skip || F[f.k].test(t, f.v) !== f.neg);
const age = a => a ? a + 'd' : 'Today';
l1.insertAdjacentHTML('beforeend', Object.entries(F).map(([k, f]) => `<button class="mi" role="menuitem" aria-haspopup="menu" tabindex="-1" data-k="${k}">${I(f.ic)}<span>${f.l}</span><span class="r"></span>${I('chev-r','xs')}</button>`).join(''));
const fields = $$('.mi', l1);
function paint(){
// chips: one per field, a new one pops in and the ones already there keep still
const have = new Set(A.map(f => f.k));
$$('.flm-ch', chips).forEach(ch => { if (!have.has(ch.dataset.k)) ch.remove(); });
A.forEach(f => {
let ch = $(`.flm-ch[data-k="${f.k}"]`, chips);
if (!ch){ ch = html(`<div class="flm-ch" role="listitem" data-k="${f.k}"><span class="k">${F[f.k].l}</span><button class="op"></button><span class="v"></span><button class="x" data-tip="Remove filter">${I('x','xs')}</button></div>`); chips.appendChild(ch); wire(ch); }
else ch.classList.add('seen');
const o = val(f.k, f.v), op = F[f.k].ops[+f.neg], text = `${F[f.k].l} ${op} ${o.c || o.l}`;
$('.op', ch).textContent = op; $('.v', ch).textContent = o.c || o.l;
$('.op', ch).setAttribute('aria-label', `${text}: change to ${F[f.k].ops[+!f.neg]}`); $('.op', ch).dataset.tip = `Change to ${F[f.k].ops[+!f.neg]}`;
$('.x', ch).setAttribute('aria-label', `Remove filter ${text}`);
});
// the menu shows each field's value, if it has one
fields.forEach(m => { const f = A.find(x => x.k === m.dataset.k), o = f && val(f.k, f.v); $('.r', m).textContent = o ? (f.neg ? F[f.k].ops[1] + ' ' : '') + (o.c || o.l) : ''; });
const hit = T.filter(t => match(t)), n = hit.length;
swap(ct, A.length ? `${n} of ${T.length} issues` : `${T.length} issues`); clr.hidden = !A.length;
// the four most recently updated, and how many more
const old = new Set($$('.flm-r', list).map(x => x.dataset.t));
list.innerHTML = n ? hit.slice(0, 4).map(t => `<div class="flm-r" data-t="${t.t}"><span class="dot" data-s="${t.s}" aria-label="${ST[t.s]}" role="img"></span><span class="t">${t.t}</span><span class="av" data-o="${t.o}" aria-label="${OWN[t.o]}" role="img">${t.o}</span><span class="a">${age(t.a)}</span></div>`).join('') + (n > 4 ? `<div class="flm-more">and ${n - 4} more</div>` : '')
: '<div class="flm-e">No issues match these filters</div>';
$$('.flm-r', list).forEach((x, i) => { if (!old.has(x.dataset.t)) enter(x, i * 50); });
}
function wire(ch){
const k = ch.dataset.k;
$('.op', ch).onclick = () => { const f = A.find(x => x.k === k); f.neg = !f.neg; paint(); };
$('.x', ch).onclick = () => {
const had = ch.contains(document.activeElement), next = ch.nextElementSibling || ch.previousElementSibling;
A = A.filter(x => x.k !== k); hideTip(); paint();
if (had) ((next && $('.x', next)) || tr).focus({ preventScroll:true });
};
}
clr.onclick = () => { const had = clr === document.activeElement; A = []; paint(); if (had) tr.focus({ preventScroll:true }); };
// the menu has two steps: the fields, then the values of the one picked. Each value says how many issues it would leave.
// It opens and closes on its own rather than through the page's shared popover, so a story playing in the card beside it
// can't close it. Closing it while the focus is inside gives the focus back to the Filter button
let field = null;
const items = () => $$('.mi', field ? l2 : l1), isOpen = () => pop.classList.contains('open');
// the focus is the one highlight: a row the demo's pointer left lit goes dark
const focusItem = m => { if (!m) return; $$('.mi', pop).forEach(x => { x.tabIndex = x === m ? 0 : -1; x.classList.remove('hl'); }); m.focus({ preventScroll:true }); };
function show(on, focus){
if (on && !isOpen()){ step(null); hideTip(); placePop(tr, pop); }
pop.classList.toggle('open', on); pop.inert = !on; tr.setAttribute('aria-expanded', on);
if (on && focus){ const all = items(); focusItem(focus === 'last' ? all[all.length - 1] : all[0]); }
if (!on && pop.contains(document.activeElement)) tr.focus({ preventScroll:true });
}
function step(k){
const had = pop.contains(document.activeElement), from = field;
field = k; l1.hidden = !!k; l2.hidden = !k;
if (k){
const f = F[k], on = A.find(x => x.k === k);
l2.setAttribute('aria-label', f.l);
l2.innerHTML = `<button class="mi flm-bk" role="menuitem" tabindex="-1" aria-label="Back to all fields">${I('chev-l','xs')}<span>${f.l}</span></button><div class="sep" role="separator"></div>`
+ f.o.map(o => `<button class="mi" role="menuitemradio" aria-checked="${!!on && on.v === o.v}" tabindex="-1" data-v="${o.v}">${o.g || ''}<span>${o.l}</span><span class="r num">${T.filter(t => match(t, k) && f.test(t, o.v)).length}</span><span class="ck">${I('check','xs')}</span></button>`).join('');
$('.flm-bk', l2).onclick = () => step(null);
$$('.mi[data-v]', l2).forEach(m => m.onclick = () => {
const v = k === 'date' ? +m.dataset.v : m.dataset.v, f = A.find(x => x.k === k);
if (f){ f.v = v; f.neg = false; } else A.push({ k, v, neg:false });
$$('.mi[data-v]', l2).forEach(x => x.setAttribute('aria-checked', x === m)); paint();
// the pick shows for a moment before the menu fades
c.later(120, () => show(false));
});
}
if (had) focusItem(k ? ($('.mi[aria-checked="true"]', l2) || $$('.mi', l2)[1]) : $(`.mi[data-k="${from}"]`, l1) || fields[0]);
}
fields.forEach(m => m.onclick = () => step(m.dataset.k));
// a click, Enter, Space or the down arrow opens it with the focus on the first field, the up arrow on the last.
// A story's tap opens it without moving the focus
c.on(tr, 'click', e => show(!isOpen(), e.isTrusted && 'first'));
c.on(tr, 'keydown', e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); show(true, e.key === 'ArrowUp' ? 'last' : 'first'); } });
c.on(document, 'pointerdown', e => { if (isOpen() && !pop.contains(e.target) && !tr.contains(e.target)) show(false); });
c.on(window, 'resize', e => { if (e.isTrusted) show(false); });
// inside the menu: the arrow keys move and wrap, Home and End jump to the ends, the right arrow opens a field's values
// and the left arrow goes back, Escape closes it and Tab leaves it
c.on(pop, 'keydown', e => {
const all = items(), i = all.indexOf(document.activeElement);
const go = { ArrowDown:all[(i + 1) % all.length], ArrowUp:all[(i - 1 + all.length) % all.length], Home:all[0], End:all[all.length - 1] }[e.key];
if (go){ e.preventDefault(); focusItem(go); }
else if (e.key === 'ArrowRight' && !field && all[i]){ e.preventDefault(); step(all[i].dataset.k); }
else if (e.key === 'ArrowLeft' && field){ e.preventDefault(); step(null); }
else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); show(false); }
else if (e.key === 'Tab') setTimeout(() => show(false));
});
show(false); paint();
// the preview's story: filter to open issues, then to Maya's, and watch the count drop; then take the status filter off
c.story(async s => {
await s.beat(3); await s.tap(tr); await s.beat(2);
await s.tap($('.mi[data-k="status"]', l1)); await s.beat(2);
await s.tap($('.mi[data-v="open"]', l2)); s.away(); await s.beat(4);
await s.tap(tr); await s.beat(2);
await s.tap($('.mi[data-k="owner"]', l1)); await s.beat(2);
await s.tap($('.mi[data-v="MK"]', l2)); s.away(); await s.beat(4);
await s.tap($('.flm-ch[data-k="status"] .x', chips)); s.away(); await s.beat(4);
});
});
Questions
What people ask about it.
- Does it filter my data?
- No. It keeps the filters and calls
onFiltersChange. Filter your own data with them, and pass backcount,totaland the first fewresults. - Can a field take several values, like Status is Open or In progress?
- Not as built: each field holds one value, and a new pick replaces it. For several, keep a list of values per field in your own state and show it in the chip's
chiptext. - Can I add my own fields?
- Yes. Each field in
fieldshas akey, alabel, an optionalicon, its twooperators, and itsoptions, each with an optionalglyphlike a dot or an avatar.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.