-
Notifications
You must be signed in to change notification settings - Fork 4k
Description
Background
Ability to perform client-side filtering in AMP is needed to support several use-cases such as product filters in eCommerce AMP pages.
#8368 lays down the requirements for client-side sort/filter which is distinct from server-side sort/filter that is handled by #6054.
Proposal
amp-filter
is a functional AMP extension that allows developers to define conditions, compose them using logical operators and turn the conditions on/off via actions
or amp-bind
.
amp-filter
executes the conditions and simply adds either amp-filter-matched
or amp-filter-unmatched
attribute to descendants that do/don't satisfy the conditions. There attributes can be targeted in CSS to hide/show or highlight matches.
Simple Example
Let's assume a product listing page for T-shirts. T-shirts have color, size, price, might be new, or on sale. Throughout this document we will use this scenario to showcase how amp-filter
can be used to achieve various filtering needs.
<amp-filter>
<amp-filter-condition
id="onSaleFilter"
attr="data-on-sale"
condition="1">
</amp-filter-condition>
<input type="checkbox" on="change:onSaleFilter.toggle(on=event.checked)">
Show on-sale items only.
</input>
<ul>
<li data-on-sale="1">Red Shirt - $30</li>
<li data-on-sale="0">Blue T-Shirt - $10</li>
<li data-on-sale="1">Green Hoodie - $20</li>
</ul>
</amp-filter>
Demos
https://amphtml-ae5fc.firebaseapp.com/ampfilter/test/manual/amp-filter.amp.html
Details
There are three main steps to consider when using amp-filter
.
1. Defining filters:
Define which attribute's value is to be used and what the expected condition is.
Examples
Color is red condition:
<amp-filter-condition attr="data-color" condition="red">
Color is NOT red condition:
<amp-filter-condition attr="data-color" negate condition="red">
Use expressions (whatever is supported by amp-bind
) to filter price between 10 and 20
<amp-filter-condition attr="data-price" value-type="number"
condition="{value > 10 && value < 20 }">
Use expressions to compare value with an amp-bind
variable.
<amp-filter-condition attr="data-price" value-type="number"
condition="{value > selectedPriceRangeMin && value < selectedPriceRangeMax }">
2. Composing filters:
Group filters and decide whether they are conjunction (AND) or disjunction (OR). Any number of nesting is allowed.
Examples
"is new" or "is on sale" condition: (default operator is 'and')
<amp-filter operator="or">
<amp-filter-condition attr="data-is-new" condition="1">
<amp-filter-condition attr="data-is-on-sale" condition="1">
Use amp-filter-condition-group
for complex composition rules such as "Color is selectedColor" AND ("is on sale" OR "is new") condition:
<amp-filter operator="and">
<amp-filter-condition
attr="data-color"
condition="{value == selectedColor}">
</amp-filter-condition>
<amp-filter-condition-group operator="or">
<amp-filter-condition attr="data-on-sale" condition="1"></amp-filter-condition>
<amp-filter-condition attr="data-is-new" condition="1"></amp-filter-condition>
</amp-filter-condition-group>
3. Turning conditions on/off
So far all we are doing is defining conditions and how they compose. Filters
do not take effect until they are toggled on.
Toggling conditions can only be done as result of a user action. (i.e. via actions or amp-bind)
Examples
Using actions:
<input type="checkbox" on="change:onSaleFilter.toggle(on=event.checked)">
Show on-sale items only.
</input>
<amp-filter-condition
id="onSaleFilter"
attr="data-on-sale"
condition="1">
</amp-filter-condition>
Using amp-bind:
<h3>Color filter:</h3>
<select on="change:AMP.setState({selectedColor: event.value})">
<option value="all">All</option>
<option value="red">red</option>
<option value="blue">blue</option>
<option value="green">green</option>
<select>
<h3>Other filters:</h3>
<input type="checkbox" on="change:AMP.setState({filterByIsNew: event.checked})">New</input>
<input type="checkbox" on="change:AMP.setState({filterByOnSale: event.checked})">On Sale</input>
<amp-filter operator="and">
<amp-filter-condition
attr="data-color"
condition="{value == selectedColor}"
[toggle]="selectedColor != 'all'">
</amp-filter-condition>
<amp-filter-condition-group operator="or">
<amp-filter-condition [toggle]="filterByOnSale" attr="data-on-sale" condition="1"></amp-filter-condition>
<amp-filter-condition [toggle]="filterByIsNew" attr="data-is-new" condition="1"></amp-filter-condition>
</amp-filter-condition-group>
What happens when conditions are turned on/off?
amp-filter
finds all descendants that have the attribute specified byattr
of an active condition.- For each, evaluates the
condition
and adds either aamp-filter-matched
oramp-filter-unmatched
attribute to the elements. - If an active
filter-condition
has itscondition
tied to abind
variable, when bind variable mutates, filter is reapplied. - If there is at least one active
filter-condition
, afiltered
attribute is added toamp-filter
5- If there is no activefilter-condition
,filtered
attribute is removed fromamp-filter
.
Note: There are cases where one might want to exclude an element from filtering
despite that element having the attribute that matches an active condition's attr
(e.g. the attribute is used for a different purpose than filtering). Any element can be excluded by adding amp-filter-exclude
<amp-filter>
<div data-price="30">Red Shirt - $30</div>
<div data-price="10">Blue Shirt - $10</div>
<div data-price="5" amp-filter-exclude>Shipping Cost is $5 Flat</div>
</amp-filter>
CSS
Default
amp-filter [amp-filter-unmatched] {
display: none;
}
Custom examples
amp-filter [amp-filter-matched] {
outline: 1px solid pink;
}
amp-filter [amp-filter-unmatched] {
opacity: 0.5;
}
Why have both matched and unmatched? Isn't one enough?:
Yes, but having both gives flexibility to write more readable CSS code and eliminates the need to use not-so-performant CSS :not
pseudo-selector. Also given ability to exclude descendants from filter using amp-filter-exclude
, not having both can result in complicated CSS rules.
For example if we only had matched
the rule above would have been:
amp-filter [amp-filter-matched] {
outline: 1px solid pink;
}
amp-filter :not([amp-filter-matched]):not([amp-filter-exclude]) {
opacity: 0.5;
}
or
amp-filter[filtered] [amp-filter-matched] {
outline: 1px solid pink;
opacity: 1;
}
amp-filter[filtered] > * {
opacity: 0.5;
}
amp-filter[filtered] > [amp-filter-exclude] {
opacity: 1;
}
ugh!
amp-filter [amp-filter-matched] {
outline: 1px solid pink;
}
amp-filter [amp-filter-unmatched] {
opacity: 0.5;
}
is just better!
Accessibility
aria-hidden="true"
is added to all unmatched elements. This ensure if CSS is customized to be something other than display:none
filter still stays accessible.
Adhering to AMP Design Principles
- Filters can never be applied at page load and therefore can not cause page jumps.
- Filters can only be applied via user actions since only triggers are actions or amp-bind.
- Conditions that are expressions are evaluated using amp-bind which uses web workers.
- Non-expression conditions are simple equality checks.
- Scanning descendants for attributes has acceptable performance. We can decide to cache them as well but have to be mindful that
amp-bind
can add attributes or change their values which means we would need signals to invalidate the cache. - Mutations are simple add/remove of attributes and happen in a single mutate cycle.
Future Improvements
Change event
It would be nice for amp-filter
to dispatch a change
event when filters are being applied/not applied with additional data such as number of matches. This would allow UI updates such as showing 3/10 items
e.g.
<h1 hidden [hidden]="!filtered">
Showing <span [text]="numMatches"></span>
out of <span [text]="numTotal"></span>
</h1>
<amp-filter
on="change:AMP.setState({filtered: event.filtered, numMatches: event.matches, numTotal: event.total})>
</amp-filter>
Toggle all conditions
Ability to toggle on/off all amp-filter-condition
s in <amp-filter>
or <amp-filter-condition-group>
Composability with amp-sort
amp-filter
and amp-sort
are independent components and can be used with or without each other:
<input type="checkbox" on="change:onSaleFilter.toggle(on=event.checked)">
Show on-sale items only.
</input>
<button on="tap:priceSorter.sort();">
Sort by price - High to Low
</button>
<amp-sort id="priceSorter"
sort-by="data-price"
sort-direction="desc"
value-type="number">
<amp-filter>
<amp-filter-condition
id="onSaleFilter"
attr="data-on-sale"
condition="1">
</amp-filter-condition>
<ul>
<li data-price="30" data-on-sale="1">Red Shirt - $30</li>
<li data-price="10" data-on-sale="0">Blue T-Shirt - $10</li>
<li data-price="20" data-on-sale="1">Green Hoodie - $20</li>
</ul>
</amp-filter>
</amp-sort>
Alternative Design
One alternative approach is allowing only a single expression
for AMP filter and removing amp-filter-condition
, amp-filter-condition-group
and operators
.
Example:
<amp-filter expression="{ element.getAttribute('data-price`) < maxPrice
&& (element.hasAttribute('data-is-on-sale`) || element.hasAttribute('data-is-new`)) }"
A big advantage of this approach is consistency in writing the expression: Instead of it being a mix of HTML-based structure and JS-like expressions combined, it is always a JS-like expression.
One problem with this approach is that it makes toggling parts of the expression on/off a bit harder.
For example, in the expression above, what if user unselects the price filter? We need to either:
- set
maxPrice
toINFINITY
or - change the expression to be
(element.getAttribute('data-price') < maxPrice || !priceFilterToggled)
.
Realistically, it means many sub-expression become of the form condition || conditionNotToggled
which is something the previous design has first-class support for.
We would also need to extend bind
to support some Element
methods like getAttribute
and amp-filter
would become fully dependent on amp-bind
which this approach.
Prototype Reference Code
A basic prototype of amp-filter in AMP code lives in https://github.com/aghassemi/amphtml/blob/amp-filter-proto-1/extensions/amp-filter/0.1/amp-filter.js and can be used as a reference for the final implementation.
Code for the demo also lives here: https://github.com/aghassemi/amphtml/blob/amp-filter-proto-1/test/manual/amp-filter.amp.html