Mobile filter bottom sheet
The canonical mobile filtering pattern. A grid of
md-card products, a Filters button that opens an
md-bottom-sheet holding four different filter
controls — brand multi-select, price range slider, size filter chips, sort
segments — and an actions-row apply button whose label recounts the matching
results live as you tweak. Applying closes the sheet, reorders and prunes the
grid, and renders each active filter as a removable chip above the results, so
undoing one filter never means reopening the sheet.
Live preview — open Filters, tweak the controls, and apply
Running shoes
6 of 6 products
NK
Nike
Pegasus 41
$130
AD
Adidas
Ultraboost Light
$180
NB
New Balance
Fresh Foam X 1080
$165
HK
Hoka
Clifton 9
$145
AS
Asics
Gel-Nimbus 26
$160
BR
Brooks
Ghost 16
$140
Price
$100 – $200
Size (EU)
Sort by
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<div style="display: flex; justify-content: space-between;">
<h2>Running shoes</h2>
<md-button variant="tonal" icon="tune">Filters</md-button>
</div>
<div class="applied-filters"><!-- removable md-chip per active filter --></div>
<div class="product-grid">
<md-card variant="outlined">
<span>Nike</span>
<span>Pegasus 41</span>
<span>$130</span>
</md-card>
<!-- more cards … -->
</div>
<md-bottom-sheet headline="Filter products" closeable top-divider bottom-divider>
<md-multi-select label="Brand" placeholder="All brands" clearable>
<md-select-option value="Nike">Nike</md-select-option>
<md-select-option value="Adidas">Adidas</md-select-option>
</md-multi-select>
<md-slider range min="100" max="200" step="5" value-start="100" value-end="200"
label-start="Minimum price" label-end="Maximum price"></md-slider>
<md-chip variant="filter" label="EU 40"></md-chip>
<md-chip variant="filter" label="EU 41"></md-chip>
<md-segmented-button-set aria-label="Sort by">
<md-segmented-button value="featured" label="Featured" selected></md-segmented-button>
<md-segmented-button value="price" label="Price"></md-segmented-button>
<md-segmented-button value="name" label="Name"></md-segmented-button>
</md-segmented-button-set>
<md-button slot="actions" variant="text">Reset</md-button>
<md-button slot="actions" variant="filled">Show 6 results</md-button>
</md-bottom-sheet>
<script type="module">
const grid = document.querySelector('[data-grid]');
const cards = Array.prototype.slice.call(grid.querySelectorAll('md-card'));
const sheet = document.querySelector('[data-filter-sheet]');
const openBtn = document.querySelector('[data-open-filters]');
const multi = document.querySelector('md-multi-select');
const slider = document.querySelector('md-slider');
const sizeChips = Array.prototype.slice.call(document.querySelectorAll('[data-size-chip]'));
const sortSet = document.querySelector('md-segmented-button-set');
const applyBtn = document.querySelector('[data-apply]');
const resetBtn = document.querySelector('[data-reset]');
const readout = document.querySelector('[data-price-readout]');
const appliedWrap = document.querySelector('[data-applied]');
const resultLine = document.querySelector('[data-result-line]');
const PRICE_MIN = 100;
const PRICE_MAX = 200;
const DEFAULTS = { brands: [], min: PRICE_MIN, max: PRICE_MAX, sizes: [], sort: 'featured' };
let applied = { brands: [], min: PRICE_MIN, max: PRICE_MAX, sizes: [], sort: 'featured' };
// The sheet's controls hold the *pending* state; read it straight off them.
function readPending() {
const sizes = [];
sizeChips.forEach((c) => { if (c.selected) sizes.push(c.getAttribute('data-size')); });
let sort = 'featured';
sortSet.querySelectorAll('md-segmented-button').forEach((s) => {
if (s.selected) sort = s.getAttribute('value');
});
return {
brands: (multi.value || []).slice(),
min: Number(slider.valueStart),
max: Number(slider.valueEnd),
sizes: sizes,
sort: sort,
};
}
function matches(card, f) {
const price = Number(card.getAttribute('data-price'));
const sizes = card.getAttribute('data-sizes').split(' ');
if (f.brands.length && f.brands.indexOf(card.getAttribute('data-brand')) === -1) return false;
if (price < f.min || price > f.max) return false;
if (f.sizes.length && !f.sizes.some((s) => sizes.indexOf(s) !== -1)) return false;
return true;
}
// Live result count on the sticky apply button, on every tweak.
function updateSheet() {
const f = readPending();
readout.textContent = '$' + f.min + ' – $' + f.max;
let n = 0;
cards.forEach((c) => { if (matches(c, f)) n += 1; });
applyBtn.textContent = 'Show ' + n + (n === 1 ? ' result' : ' results');
}
function addChip(label, kind, value) {
const chip = document.createElement('md-chip');
chip.setAttribute('variant', 'input');
chip.setAttribute('selectable', 'false');
chip.setAttribute('removable', '');
chip.setAttribute('label', label);
chip.setAttribute('data-kind', kind);
chip.setAttribute('data-value', value);
appliedWrap.appendChild(chip);
}
function applyFilters() {
const f = applied;
const ordered = cards.slice();
if (f.sort === 'price') {
ordered.sort((a, b) => Number(a.getAttribute('data-price')) - Number(b.getAttribute('data-price')));
} else if (f.sort === 'name') {
ordered.sort((a, b) => a.getAttribute('data-name').localeCompare(b.getAttribute('data-name')));
}
let shown = 0;
ordered.forEach((c) => {
grid.appendChild(c);
const ok = matches(c, f);
c.style.display = ok ? '' : 'none';
if (ok) shown += 1;
});
resultLine.textContent = shown + ' of ' + cards.length + ' products';
const priceNarrowed = f.min > PRICE_MIN || f.max < PRICE_MAX;
appliedWrap.textContent = '';
f.brands.forEach((b) => addChip(b, 'brand', b));
if (priceNarrowed) addChip('$' + f.min + ' – $' + f.max, 'price', '');
f.sizes.forEach((s) => addChip('EU ' + s, 'size', s));
const nFilters = f.brands.length + f.sizes.length + (priceNarrowed ? 1 : 0);
appliedWrap.style.display = nFilters ? 'flex' : 'none';
openBtn.textContent = nFilters ? 'Filters (' + nFilters + ')' : 'Filters';
}
function syncControls(f) {
multi.value = f.brands.slice();
slider.valueStart = f.min;
slider.valueEnd = f.max;
sizeChips.forEach((c) => { c.selected = f.sizes.indexOf(c.getAttribute('data-size')) !== -1; });
sortSet.querySelectorAll('md-segmented-button').forEach((s) => {
s.selected = s.getAttribute('value') === f.sort;
});
}
// Open with the sheet mirroring what is actually applied.
openBtn.addEventListener('mdClick', () => {
syncControls(applied);
updateSheet();
sheet.show();
});
multi.addEventListener('mdChange', updateSheet);
slider.addEventListener('mdInput', updateSheet);
slider.addEventListener('mdChange', updateSheet);
sizeChips.forEach((c) => c.addEventListener('mdSelect', updateSheet));
sortSet.addEventListener('mdChange', updateSheet);
resetBtn.addEventListener('mdClick', () => {
syncControls(DEFAULTS);
updateSheet();
});
// Slotted action buttons never close the sheet on their own — call close().
applyBtn.addEventListener('mdClick', () => {
applied = readPending();
applyFilters();
sheet.close();
});
// Removing an applied chip re-filters without reopening the sheet.
// mdRemove's default action already removes the chip from the DOM;
// we just update state and rebuild the row.
appliedWrap.addEventListener('mdRemove', (e) => {
const kind = e.target.getAttribute('data-kind');
const value = e.target.getAttribute('data-value');
if (kind === 'brand') applied.brands = applied.brands.filter((b) => b !== value);
if (kind === 'size') applied.sizes = applied.sizes.filter((s) => s !== value);
if (kind === 'price') { applied.min = PRICE_MIN; applied.max = PRICE_MAX; }
applyFilters();
});
</script>import { useMemo, useState } from 'react';
import {
MdBottomSheet, MdButton, MdCard, MdChip, MdMultiSelect,
MdSegmentedButton, MdSegmentedButtonSet, MdSelectOption, MdSlider,
} from '@awc-ui/react';
const PRODUCTS = [
{ brand: 'Nike', name: 'Pegasus 41', price: 130, sizes: ['41', '42', '43'] },
{ brand: 'Adidas', name: 'Ultraboost Light', price: 180, sizes: ['42', '43', '44'] },
{ brand: 'New Balance', name: 'Fresh Foam X 1080', price: 165, sizes: ['40', '42', '44'] },
{ brand: 'Hoka', name: 'Clifton 9', price: 145, sizes: ['40', '41', '44'] },
{ brand: 'Asics', name: 'Gel-Nimbus 26', price: 160, sizes: ['42', '43'] },
{ brand: 'Brooks', name: 'Ghost 16', price: 140, sizes: ['40', '41', '42', '43'] },
];
const BRANDS = PRODUCTS.map((p) => p.brand);
const SIZES = ['40', '41', '42', '43', '44'];
const DEFAULTS = { brands: [] as string[], min: 100, max: 200, sizes: [] as string[], sort: 'featured' };
type Filters = typeof DEFAULTS;
const matches = (p: (typeof PRODUCTS)[number], f: Filters) =>
(!f.brands.length || f.brands.includes(p.brand)) &&
p.price >= f.min && p.price <= f.max &&
(!f.sizes.length || f.sizes.some((s) => p.sizes.includes(s)));
export default function MobileFilterSheet() {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState<Filters>(DEFAULTS);
const [applied, setApplied] = useState<Filters>(DEFAULTS);
const pendingCount = PRODUCTS.filter((p) => matches(p, pending)).length;
const visible = useMemo(() => {
const list = PRODUCTS.filter((p) => matches(p, applied));
if (applied.sort === 'price') list.sort((a, b) => a.price - b.price);
if (applied.sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}, [applied]);
const chips = [
...applied.brands.map((b) => ({ kind: 'brand', label: b, value: b })),
...(applied.min > 100 || applied.max < 200
? [{ kind: 'price', label: '$' + applied.min + ' – $' + applied.max, value: '' }] : []),
...applied.sizes.map((s) => ({ kind: 'size', label: 'EU ' + s, value: s })),
];
const removeChip = (c: { kind: string; value: string }) => setApplied((f) =>
c.kind === 'brand' ? { ...f, brands: f.brands.filter((b) => b !== c.value) }
: c.kind === 'size' ? { ...f, sizes: f.sizes.filter((s) => s !== c.value) }
: { ...f, min: 100, max: 200 });
return (
<div style={{ display: 'grid', gap: 16, maxWidth: 720, marginInline: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'grid', gap: 2 }}>
<span>Running shoes</span>
<span>{visible.length} of {PRODUCTS.length} products</span>
</div>
<MdButton variant="tonal" icon="tune" onMdClick={() => { setPending(applied); setOpen(true); }}>
{chips.length ? 'Filters (' + chips.length + ')' : 'Filters'}
</MdButton>
</div>
{chips.length > 0 && (
<div aria-label="Applied filters" style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{chips.map((c) => (
<MdChip key={c.kind + c.value} variant="input" selectable={false} removable label={c.label}
onMdRemove={(e) => { e.preventDefault(); removeChip(c); }} />
))}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 16 }}>
{visible.map((p) => (
<MdCard key={p.name} variant="outlined">
<div style={{ display: 'grid', gap: 2, padding: 12 }}>
<span>{p.brand}</span>
<span>{p.name}</span>
<span>{'$' + p.price}</span>
</div>
</MdCard>
))}
</div>
<MdBottomSheet headline="Filter products" closeable topDivider bottomDivider
open={open} onMdClose={() => setOpen(false)}>
<div style={{ display: 'grid', gap: 24, paddingBlock: '8px 16px' }}>
<MdMultiSelect label="Brand" placeholder="All brands" clearable value={pending.brands}
onMdChange={(e) => setPending({ ...pending, brands: e.detail })}>
{BRANDS.map((b) => <MdSelectOption key={b} value={b}>{b}</MdSelectOption>)}
</MdMultiSelect>
<div style={{ display: 'grid', gap: 4 }}>
<span>Price</span>
<MdSlider range min={100} max={200} step={5}
valueStart={pending.min} valueEnd={pending.max}
ariaLabelStart="Minimum price" ariaLabelEnd="Maximum price"
onMdInput={(e) => setPending({ ...pending, min: e.detail.valueStart ?? 100, max: e.detail.valueEnd ?? 200 })} />
<span>{'$' + pending.min + ' – $' + pending.max}</span>
</div>
<div style={{ display: 'grid', gap: 8 }}>
<span>Size (EU)</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{SIZES.map((s) => (
<MdChip key={s} variant="filter" label={'EU ' + s} selected={pending.sizes.includes(s)}
onMdSelect={(e) => setPending({ ...pending,
sizes: e.detail.selected ? [...pending.sizes, s] : pending.sizes.filter((x) => x !== s) })} />
))}
</div>
</div>
<div style={{ display: 'grid', gap: 8 }}>
<span>Sort by</span>
<MdSegmentedButtonSet aria-label="Sort by"
onMdChange={(e) => setPending({ ...pending, sort: e.detail[0] || 'featured' })}>
<MdSegmentedButton value="featured" label="Featured" selected={pending.sort === 'featured'} />
<MdSegmentedButton value="price" label="Price" selected={pending.sort === 'price'} />
<MdSegmentedButton value="name" label="Name" selected={pending.sort === 'name'} />
</MdSegmentedButtonSet>
</div>
</div>
<MdButton slot="actions" variant="text" onMdClick={() => setPending({ ...DEFAULTS })}>Reset</MdButton>
<MdButton slot="actions" variant="filled" onMdClick={() => { setApplied(pending); setOpen(false); }}>
{'Show ' + pendingCount + (pendingCount === 1 ? ' result' : ' results')}
</MdButton>
</MdBottomSheet>
</div>
);
}import { Component } from '@angular/core';
import {
MdBottomSheet, MdButton, MdCard, MdChip, MdMultiSelect,
MdSegmentedButton, MdSegmentedButtonSet, MdSelectOption, MdSlider,
} from '@awc-ui/angular';
interface Product { brand: string; name: string; price: number; sizes: string[]; }
interface Filters { brands: string[]; min: number; max: number; sizes: string[]; sort: string; }
const fresh = (): Filters => ({ brands: [], min: 100, max: 200, sizes: [], sort: 'featured' });
@Component({
selector: 'app-mobile-filter-sheet',
standalone: true,
imports: [
MdBottomSheet, MdButton, MdCard, MdChip, MdMultiSelect,
MdSegmentedButton, MdSegmentedButtonSet, MdSelectOption, MdSlider,
],
template:
'<div class="shop">' +
'<div class="head"><div class="titles"><span>Running shoes</span>' +
'<span>{{ visible.length }} of {{ products.length }} products</span></div>' +
'<md-button variant="tonal" icon="tune" (mdClick)="openSheet()">{{ filtersLabel }}</md-button></div>' +
'@if (chips.length) {<div class="applied" aria-label="Applied filters">' +
'@for (c of chips; track c.kind + c.value) {' +
'<md-chip variant="input" [selectable]="false" removable [label]="c.label" (mdRemove)="removeChip(c, $event)"></md-chip>' +
'}</div>}' +
'<div class="grid">' +
'@for (p of visible; track p.name) {' +
'<md-card variant="outlined"><div class="body">' +
'<span>{{ p.brand }}</span><span>{{ p.name }}</span><span>{{ "$" + p.price }}</span>' +
'</div></md-card>}' +
'</div>' +
'<md-bottom-sheet headline="Filter products" closeable top-divider bottom-divider [open]="sheetOpen" (mdClose)="sheetOpen = false">' +
'<div class="controls">' +
'<md-multi-select label="Brand" placeholder="All brands" clearable [value]="pending.brands" (mdChange)="onBrands($event)">' +
'@for (b of brands; track b) {<md-select-option [value]="b">{{ b }}</md-select-option>}' +
'</md-multi-select>' +
'<div class="field"><span>Price</span>' +
'<md-slider range min="100" max="200" step="5" label-start="Minimum price" label-end="Maximum price" ' +
'[valueStart]="pending.min" [valueEnd]="pending.max" (mdInput)="onPrice($event)"></md-slider>' +
'<span>{{ priceReadout }}</span></div>' +
'<div class="field"><span>Size (EU)</span><div class="chips">' +
'@for (s of sizes; track s) {' +
'<md-chip variant="filter" [label]="sizeLabel(s)" [selected]="pending.sizes.includes(s)" (mdSelect)="toggleSize(s, $event)"></md-chip>' +
'}</div></div>' +
'<div class="field"><span>Sort by</span>' +
'<md-segmented-button-set aria-label="Sort by" (mdChange)="onSort($event)">' +
'<md-segmented-button value="featured" label="Featured" [selected]="sortFeatured"></md-segmented-button>' +
'<md-segmented-button value="price" label="Price" [selected]="sortPrice"></md-segmented-button>' +
'<md-segmented-button value="name" label="Name" [selected]="sortName"></md-segmented-button>' +
'</md-segmented-button-set></div>' +
'</div>' +
'<md-button slot="actions" variant="text" (mdClick)="resetPending()">Reset</md-button>' +
'<md-button slot="actions" variant="filled" (mdClick)="apply()">{{ applyLabel }}</md-button>' +
'</md-bottom-sheet></div>',
styles: [
'.shop { display: grid; gap: 16px; max-width: 720px; margin-inline: auto; }' +
' .head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }' +
' .titles { display: grid; gap: 2px; }' +
' .applied, .chips { display: flex; flex-wrap: wrap; gap: 8px; }' +
' .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; }' +
' .body { display: grid; gap: 2px; padding: 12px; }' +
' .controls { display: grid; gap: 24px; padding-block: 8px 16px; }' +
' .field { display: grid; gap: 8px; }',
],
})
export class MobileFilterSheetComponent {
readonly products: Product[] = [
{ brand: 'Nike', name: 'Pegasus 41', price: 130, sizes: ['41', '42', '43'] },
{ brand: 'Adidas', name: 'Ultraboost Light', price: 180, sizes: ['42', '43', '44'] },
{ brand: 'New Balance', name: 'Fresh Foam X 1080', price: 165, sizes: ['40', '42', '44'] },
{ brand: 'Hoka', name: 'Clifton 9', price: 145, sizes: ['40', '41', '44'] },
{ brand: 'Asics', name: 'Gel-Nimbus 26', price: 160, sizes: ['42', '43'] },
{ brand: 'Brooks', name: 'Ghost 16', price: 140, sizes: ['40', '41', '42', '43'] },
];
readonly brands = this.products.map((p) => p.brand);
readonly sizes = ['40', '41', '42', '43', '44'];
sheetOpen = false;
pending = fresh();
applied = fresh();
private matches(p: Product, f: Filters) {
return (!f.brands.length || f.brands.includes(p.brand)) &&
p.price >= f.min && p.price <= f.max &&
(!f.sizes.length || f.sizes.some((s) => p.sizes.includes(s)));
}
get visible() {
const list = this.products.filter((p) => this.matches(p, this.applied));
if (this.applied.sort === 'price') list.sort((a, b) => a.price - b.price);
if (this.applied.sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}
get pendingCount() { return this.products.filter((p) => this.matches(p, this.pending)).length; }
get chips() {
const f = this.applied;
const out = f.brands.map((b) => ({ kind: 'brand', label: b, value: b }));
if (f.min > 100 || f.max < 200) out.push({ kind: 'price', label: '$' + f.min + ' – $' + f.max, value: '' });
f.sizes.forEach((s) => out.push({ kind: 'size', label: 'EU ' + s, value: s }));
return out;
}
get filtersLabel() { return this.chips.length ? 'Filters (' + this.chips.length + ')' : 'Filters'; }
get priceReadout() { return '$' + this.pending.min + ' – $' + this.pending.max; }
get applyLabel() { return 'Show ' + this.pendingCount + (this.pendingCount === 1 ? ' result' : ' results'); }
get sortFeatured() { return this.pending.sort === 'featured'; }
get sortPrice() { return this.pending.sort === 'price'; }
get sortName() { return this.pending.sort === 'name'; }
sizeLabel(s: string) { return 'EU ' + s; }
openSheet() {
this.pending = { ...this.applied, brands: [...this.applied.brands], sizes: [...this.applied.sizes] };
this.sheetOpen = true;
}
resetPending() { this.pending = fresh(); }
apply() { this.applied = this.pending; this.sheetOpen = false; }
onBrands(e: CustomEvent<string[]>) { this.pending.brands = e.detail; }
onSort(e: CustomEvent<string[]>) { this.pending.sort = e.detail[0] || 'featured'; }
onPrice(e: CustomEvent<{ value: number; valueStart?: number; valueEnd?: number }>) {
this.pending.min = e.detail.valueStart ?? 100;
this.pending.max = e.detail.valueEnd ?? 200;
}
toggleSize(s: string, e: CustomEvent<{ selected: boolean }>) {
this.pending.sizes = e.detail.selected
? [...this.pending.sizes, s]
: this.pending.sizes.filter((x) => x !== s);
}
removeChip(c: { kind: string; value: string }, e: Event) {
e.preventDefault();
const f = this.applied;
if (c.kind === 'brand') this.applied = { ...f, brands: f.brands.filter((b) => b !== c.value) };
else if (c.kind === 'size') this.applied = { ...f, sizes: f.sizes.filter((s) => s !== c.value) };
else this.applied = { ...f, min: 100, max: 200 };
}
}<script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdBottomSheet, MdButton, MdCard, MdChip, MdMultiSelect,
MdSegmentedButton, MdSegmentedButtonSet, MdSelectOption, MdSlider,
} from '@awc-ui/vue';
interface Filters { brands: string[]; min: number; max: number; sizes: string[]; sort: string; }
const products = [
{ brand: 'Nike', name: 'Pegasus 41', price: 130, sizes: ['41', '42', '43'] },
{ brand: 'Adidas', name: 'Ultraboost Light', price: 180, sizes: ['42', '43', '44'] },
{ brand: 'New Balance', name: 'Fresh Foam X 1080', price: 165, sizes: ['40', '42', '44'] },
{ brand: 'Hoka', name: 'Clifton 9', price: 145, sizes: ['40', '41', '44'] },
{ brand: 'Asics', name: 'Gel-Nimbus 26', price: 160, sizes: ['42', '43'] },
{ brand: 'Brooks', name: 'Ghost 16', price: 140, sizes: ['40', '41', '42', '43'] },
];
const brands = products.map((p) => p.brand);
const sizes = ['40', '41', '42', '43', '44'];
const fresh = (): Filters => ({ brands: [], min: 100, max: 200, sizes: [], sort: 'featured' });
const open = ref(false);
const pending = ref(fresh());
const applied = ref(fresh());
function matches(p: (typeof products)[number], f: Filters) {
return (!f.brands.length || f.brands.includes(p.brand)) &&
p.price >= f.min && p.price <= f.max &&
(!f.sizes.length || f.sizes.some((s) => p.sizes.includes(s)));
}
const visible = computed(() => {
const list = products.filter((p) => matches(p, applied.value));
if (applied.value.sort === 'price') list.sort((a, b) => a.price - b.price);
if (applied.value.sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name));
return list;
});
const pendingCount = computed(() => products.filter((p) => matches(p, pending.value)).length);
const chips = computed(() => {
const f = applied.value;
const out = f.brands.map((b) => ({ kind: 'brand', label: b, value: b }));
if (f.min > 100 || f.max < 200) out.push({ kind: 'price', label: '$' + f.min + ' – $' + f.max, value: '' });
f.sizes.forEach((s) => out.push({ kind: 'size', label: 'EU ' + s, value: s }));
return out;
});
const filtersLabel = computed(() => (chips.value.length ? 'Filters (' + chips.value.length + ')' : 'Filters'));
const applyLabel = computed(() => 'Show ' + pendingCount.value + (pendingCount.value === 1 ? ' result' : ' results'));
const priceReadout = computed(() => '$' + pending.value.min + ' – $' + pending.value.max);
function openSheet() {
pending.value = { ...applied.value, brands: [...applied.value.brands], sizes: [...applied.value.sizes] };
open.value = true;
}
function onPrice(e: CustomEvent<{ value: number; valueStart?: number; valueEnd?: number }>) {
pending.value.min = e.detail.valueStart ?? 100;
pending.value.max = e.detail.valueEnd ?? 200;
}
function toggleSize(s: string, e: CustomEvent<{ selected: boolean }>) {
pending.value.sizes = e.detail.selected
? [...pending.value.sizes, s]
: pending.value.sizes.filter((x) => x !== s);
}
function removeChip(c: { kind: string; value: string }, e: Event) {
e.preventDefault();
const f = applied.value;
if (c.kind === 'brand') applied.value = { ...f, brands: f.brands.filter((b) => b !== c.value) };
else if (c.kind === 'size') applied.value = { ...f, sizes: f.sizes.filter((s) => s !== c.value) };
else applied.value = { ...f, min: 100, max: 200 };
}
function apply() {
applied.value = pending.value;
open.value = false;
}
</script>
<template>
<div class="shop">
<div class="head">
<div class="titles">
<span>Running shoes</span>
<span>{{ visible.length }} of {{ products.length }} products</span>
</div>
<MdButton variant="tonal" icon="tune" @md-click="openSheet">{{ filtersLabel }}</MdButton>
</div>
<div v-if="chips.length" class="applied" aria-label="Applied filters">
<MdChip v-for="c in chips" :key="c.kind + c.value" variant="input" :selectable="false" removable
:label="c.label" @md-remove="removeChip(c, $event)" />
</div>
<div class="grid">
<MdCard v-for="p in visible" :key="p.name" variant="outlined">
<div class="body">
<span>{{ p.brand }}</span><span>{{ p.name }}</span><span>{{ '$' + p.price }}</span>
</div>
</MdCard>
</div>
<MdBottomSheet headline="Filter products" closeable top-divider bottom-divider
:open="open" @md-close="open = false">
<div class="controls">
<MdMultiSelect label="Brand" placeholder="All brands" clearable :value="pending.brands"
@md-change="pending.brands = $event.detail">
<MdSelectOption v-for="b in brands" :key="b" :value="b">{{ b }}</MdSelectOption>
</MdMultiSelect>
<div class="field">
<span>Price</span>
<MdSlider range :min="100" :max="200" :step="5" aria-label-start="Minimum price" aria-label-end="Maximum price"
:value-start="pending.min" :value-end="pending.max" @md-input="onPrice" />
<span>{{ priceReadout }}</span>
</div>
<div class="field">
<span>Size (EU)</span>
<div class="chips">
<MdChip v-for="s in sizes" :key="s" variant="filter" :label="'EU ' + s"
:selected="pending.sizes.includes(s)" @md-select="toggleSize(s, $event)" />
</div>
</div>
<div class="field">
<span>Sort by</span>
<MdSegmentedButtonSet aria-label="Sort by" @md-change="pending.sort = $event.detail[0] || 'featured'">
<MdSegmentedButton value="featured" label="Featured" :selected="pending.sort === 'featured'" />
<MdSegmentedButton value="price" label="Price" :selected="pending.sort === 'price'" />
<MdSegmentedButton value="name" label="Name" :selected="pending.sort === 'name'" />
</MdSegmentedButtonSet>
</div>
</div>
<MdButton slot="actions" variant="text" @md-click="pending = fresh()">Reset</MdButton>
<MdButton slot="actions" variant="filled" @md-click="apply">{{ applyLabel }}</MdButton>
</MdBottomSheet>
</div>
</template>
<style scoped>
.shop { display: grid; gap: 16px; max-width: 720px; margin-inline: auto; }
.head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.titles { display: grid; gap: 2px; }
.applied, .chips { display: flex; flex-wrap: wrap; gap: 8px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; }
.body { display: grid; gap: 2px; padding: 12px; }
.controls { display: grid; gap: 24px; padding-block: 8px 16px; }
.field { display: grid; gap: 8px; }
</style><script>
// Registers every md-* element once on the client.
// In SvelteKit, run this behind an if (browser) guard from '$app/environment'.
import { defineCustomElements } from '@awc-ui/svelte';
if (typeof window !== 'undefined') defineCustomElements(window);
const products = [
{ brand: 'Nike', name: 'Pegasus 41', price: 130, sizes: ['41', '42', '43'] },
{ brand: 'Adidas', name: 'Ultraboost Light', price: 180, sizes: ['42', '43', '44'] },
{ brand: 'New Balance', name: 'Fresh Foam X 1080', price: 165, sizes: ['40', '42', '44'] },
{ brand: 'Hoka', name: 'Clifton 9', price: 145, sizes: ['40', '41', '44'] },
{ brand: 'Asics', name: 'Gel-Nimbus 26', price: 160, sizes: ['42', '43'] },
{ brand: 'Brooks', name: 'Ghost 16', price: 140, sizes: ['40', '41', '42', '43'] },
];
const brands = products.map((p) => p.brand);
const sizes = ['40', '41', '42', '43', '44'];
const fresh = () => ({ brands: [], min: 100, max: 200, sizes: [], sort: 'featured' });
let open = false;
let pending = fresh();
let applied = fresh();
let multiRef;
function matches(p, f) {
return (!f.brands.length || f.brands.includes(p.brand)) &&
p.price >= f.min && p.price <= f.max &&
(!f.sizes.length || f.sizes.some((s) => p.sizes.includes(s)));
}
$: visible = products
.filter((p) => matches(p, applied))
.sort((a, b) => applied.sort === 'price' ? a.price - b.price
: applied.sort === 'name' ? a.name.localeCompare(b.name) : 0);
$: pendingCount = products.filter((p) => matches(p, pending)).length;
$: chips = [
...applied.brands.map((b) => ({ kind: 'brand', label: b, value: b })),
...(applied.min > 100 || applied.max < 200
? [{ kind: 'price', label: '$' + applied.min + ' – $' + applied.max, value: '' }] : []),
...applied.sizes.map((s) => ({ kind: 'size', label: 'EU ' + s, value: s })),
];
// md-multi-select's value is a string[] — assign it as a property, not an attribute.
$: if (multiRef) multiRef.value = pending.brands;
function openSheet() {
pending = { ...applied, brands: [...applied.brands], sizes: [...applied.sizes] };
open = true;
}
function onPrice(e) {
pending = { ...pending, min: e.detail.valueStart ?? 100, max: e.detail.valueEnd ?? 200 };
}
function toggleSize(s, e) {
pending = { ...pending,
sizes: e.detail.selected ? [...pending.sizes, s] : pending.sizes.filter((x) => x !== s) };
}
function removeChip(c, e) {
e.preventDefault();
if (c.kind === 'brand') applied = { ...applied, brands: applied.brands.filter((b) => b !== c.value) };
else if (c.kind === 'size') applied = { ...applied, sizes: applied.sizes.filter((s) => s !== c.value) };
else applied = { ...applied, min: 100, max: 200 };
}
function apply() { applied = pending; open = false; }
</script>
<div class="shop">
<div class="head">
<div class="titles">
<span>Running shoes</span>
<span>{visible.length} of {products.length} products</span>
</div>
<md-button variant="tonal" icon="tune" on:mdClick={openSheet}>
{chips.length ? 'Filters (' + chips.length + ')' : 'Filters'}
</md-button>
</div>
{#if chips.length}
<div class="applied" aria-label="Applied filters">
{#each chips as c (c.kind + c.value)}
<md-chip variant="input" selectable="false" removable label={c.label}
on:mdRemove={(e) => removeChip(c, e)}></md-chip>
{/each}
</div>
{/if}
<div class="grid">
{#each visible as p (p.name)}
<md-card variant="outlined">
<div class="body">
<span>{p.brand}</span><span>{p.name}</span><span>{'$' + p.price}</span>
</div>
</md-card>
{/each}
</div>
<md-bottom-sheet headline="Filter products" closeable top-divider bottom-divider
open={open} on:mdClose={() => (open = false)}>
<div class="controls">
<md-multi-select label="Brand" placeholder="All brands" clearable bind:this={multiRef}
on:mdChange={(e) => (pending = { ...pending, brands: e.detail })}>
{#each brands as b (b)}
<md-select-option value={b}>{b}</md-select-option>
{/each}
</md-multi-select>
<div class="field">
<span>Price</span>
<md-slider range min="100" max="200" step="5" label-start="Minimum price" label-end="Maximum price"
value-start={pending.min} value-end={pending.max} on:mdInput={onPrice}></md-slider>
<span>{'$' + pending.min + ' – $' + pending.max}</span>
</div>
<div class="field">
<span>Size (EU)</span>
<div class="chips">
{#each sizes as s (s)}
<md-chip variant="filter" label={'EU ' + s} selected={pending.sizes.includes(s)}
on:mdSelect={(e) => toggleSize(s, e)}></md-chip>
{/each}
</div>
</div>
<div class="field">
<span>Sort by</span>
<md-segmented-button-set aria-label="Sort by"
on:mdChange={(e) => (pending = { ...pending, sort: e.detail[0] || 'featured' })}>
<md-segmented-button value="featured" label="Featured" selected={pending.sort === 'featured'}></md-segmented-button>
<md-segmented-button value="price" label="Price" selected={pending.sort === 'price'}></md-segmented-button>
<md-segmented-button value="name" label="Name" selected={pending.sort === 'name'}></md-segmented-button>
</md-segmented-button-set>
</div>
</div>
<md-button slot="actions" variant="text" on:mdClick={() => (pending = fresh())}>Reset</md-button>
<md-button slot="actions" variant="filled" on:mdClick={apply}>
{'Show ' + pendingCount + (pendingCount === 1 ? ' result' : ' results')}
</md-button>
</md-bottom-sheet>
</div>
<style>
.shop { display: grid; gap: 16px; max-width: 720px; margin-inline: auto; }
.head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.titles { display: grid; gap: 2px; }
.applied, .chips { display: flex; flex-wrap: wrap; gap: 8px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; }
.body { display: grid; gap: 2px; padding: 12px; }
.controls { display: grid; gap: 24px; padding-block: 8px 16px; }
.field { display: grid; gap: 8px; }
</style>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-bottom-sheet | The filter surface. closeable gives keyboard users an exit (the drag handle is pointer-only), top-divider/bottom-divider frame the scrollable body, and the actions slot is the sticky footer holding Reset and the live-count Apply button. |
md-multi-select | Brand picker. Selected brands stay visible as removable chips below the field, and clearable wipes them in one tap. mdChange delivers the full string[] for the live count. |
md-slider | Price range: range + value-start/value-end, step="5", per-thumb names via label-start/label-end. The text readout below carries the values — value-indicator bubbles are always-on and would overflow the sheet at the endpoints. mdInput recounts continuously during the drag. |
md-chip | Two jobs: variant="filter" size toggles inside the sheet, and variant="input" selectable="false" removable display tokens above the grid whose only action is the remove ✕. |
md-segmented-button-set | Sort order — three exclusive options, all visible, mdChange reports the selection as string[]. |
md-card | One product per card, variant="outlined" for a defined edge in a dense grid. The demo filters by toggling display and reorders by re-appending. |
md-button | The tonal Filters trigger (its label grows a live count, “Filters (3)”), plus the sheet’s Reset and Apply actions. |
The details that make it production-grade
Section titled “The details that make it production-grade”- The sheet is a real modal dialog.
role="dialog"witharia-modal="true", focus trapped inside while open, body scroll locked, Escape handled on a capture-phase document listener — and on close, focus returns to the Filters button that opened it. - A slotted action button does not close the sheet by itself — Apply calls
close()explicitly. That is deliberate: it lets Reset live in the same actions row without dismissing anything. - Each slider thumb is a native
<input type="range">, so arrow keys step bystep, Home/End jump to the bounds, and the two thumbs push each other rather than crossing — the range can never invert. - Filter chips do not rely on color alone: the selected state renders a leading checkmark. And every applied chip’s ✕ gets a generated accessible name — “Remove Nike” — with no extra markup.
- Removing an applied chip is the chip’s own default action.
mdRemoveremoves the element from the DOM unless youpreventDefault(); the handler here only updates state and re-renders, never callsremove()twice.
Variations
Section titled “Variations”- Instant filtering, no Apply: commit on every
mdChange/mdInputand drop the actions row. Keep the live count somewhere visible — the result line above the grid already does that job. - Desktop: bottom sheets are a mobile pattern; at larger breakpoints swap
in
md-side-sheetwithvariant="standard"so users filter alongside the visible grid instead of over it. - Many brands: past ~15 options add
filterableto the multi-select for in-menu search, and switch todisplay-mode="count"so 20 selections read as “20 selected” instead of a wall of chips. - Guarded dismissal: with expensive un-applied changes, set
scrim-dismissible="false"and listen formdCancel(fired on Escape, drag, or the close button) to confirm before discarding.
Related
Section titled “Related”- Recipe: Moderation queue — filtering and triaging a work list with the same commit-on-apply pattern.
- Recipe: Role & permission assignment
—
md-multi-selectdoing heavier lifting with caps and select-all. - Component manuals:
md-bottom-sheetandmd-multi-select— full API, behavioral contracts, and anti-patterns.