Subscription Settings
Introduction
This example shows how to implement a subscription settings icon dropdown. This lets the user to change their subscription settings without navigating away from the email. It also works for websites.
Setup
We use an amp-list
component to query the user's current subscription setting on email or page load from the server.
<script async custom-element="amp-list" src="https://cdn.ampproject.org/v0/amp-list-0.1.js"></script>
We'll use amp-mustache
to render the dropdown icon linked to the server responded subscription state.
<script async custom-template="amp-mustache" src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"></script>
We'll also use amp-form
to update the user's subscription setting on the server when they select a new setting from the icon dropdown.
<script async custom-element="amp-form" src="https://cdn.ampproject.org/v0/amp-form-0.1.js"></script>
Lastly, we use amp-bind
to update the dropdown's state in response to events.
<script async custom-element="amp-bind" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"></script>
Implementation
Server
The server responds to GET and POST requests at https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription
.
It responds to GET requests with a JSON object like the following:
{ "currentSubscription": "only-mentions", "options": [ { "value": "watching", "isSelected": false, "text": "Watching", "imgUrl": "/images/watching.jpg" }, { "value": "only-mentions", "isSelected": true, "text": "Only mentions", "imgUrl": "/images/only-mentions.jpg" }, { "value": "ignoring", "isSelected": false, "text": "Ignoring", "imgUrl": "/images/ignoring.jpg" } ] }
currentSubscription
and isSelected
vary depending on the user's current subscription setting. isSelected
is only true
for one of the objects in options
at a time.
The server expects POST requests to specify one of the subscription settings above (e.g. ignoring
) for a nextSubscription
field in its form data. It then updates the user's current subscription setting to the specified value.
AMP-HTML
Step 1: Basic Dropdown
We start with a native select
element for our dropdown and fill it with our supported subscription settings. The select
element is a great choice because it is highly accessible on all platforms.
<select>
<option value="watching">Watching</option>
<option value="only-mentions">Only mentions</option>
<option value="ignoring">Ignoring</option>
</select>
Step 2: Fetching the Current Subscription Setting
We wrap the select
element in an amp-list
and set its src
to the URL our server is listening for GET requests. This queries the user's current subscription setting.
We use an amp-mustache
template to render the select
element and ensure that the option
element corresponding to the user's subscription setting has the selected
attribute. To understand why the code is duplicative, consider that the selected
attribute is a boolean attribute and review the amp-mustache
restrictions.
Finally, we add a placeholder and fallback to keep the user informed about the state of the user interface and gracefully handle any issues.
<amp-list src="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" binding="refresh" items="." single-item layout="fill">
<template type="amp-mustache">
<select>
{{#options}}
{{#isSelected}}
<option value="{{value}}" selected>
{{text}}
</option>
{{/isSelected}}
{{^isSelected}}
<option value="{{value}}">
{{text}}
</option>
{{/isSelected}}
{{/options}}
</select>
</template>
<div placeholder>Loading...</div>
<div fallback>Something went wrong. Please refresh</div>
</amp-list>
Note that the amp-list
is annotated with single-item
because our server responds to GET requests with a single logical value (the subscription setting) and we only want the template to be invoked once for the response. Setting items="."
ensures that the template can access the response object's root fields.
Step 3: Updating the Subscription Setting on Selection
To update the user's subscription setting on the server when they select a new setting, we start by wrapping the select
element in an AMP form
element, setting its method
to post
, and setting its action-xhr
attribute to the URL where our server is listening for POST requests.
The name
attribute of the select
element is set to nextSubscription
. nextSubscription
is the name of the form data field where the server expects the user's new subscription setting to be in the POST request (see the Server section) and the select
element contains the user's selected subscription setting.
Finally, we trigger the form submission when the select
element's value changes. We do so by using the on
attribute to attach an event handler to the select
element's change
event, invoking the form
element's submit
action. Note that we gave the form
element an id
so that we could refer to it in the event handler.
You can test the code by selecting a new subscription setting and refreshing the page. The select
element's subscription setting on page load is now the one you selected!
<amp-list src="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" binding="refresh" items="." single-item layout="fill">
<template type="amp-mustache">
<form action-xhr="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" method="post" id="form1">
<select name="nextSubscription" on="change: form1.submit;">
{{#options}}
{{#isSelected}}
<option value="{{value}}" selected>
{{text}}
</option>
{{/isSelected}}
{{^isSelected}}
<option value="{{value}}">
{{text}}
</option>
{{/isSelected}}
{{/options}}
</select>
</form>
</template>
<div placeholder>Loading...</div>
<div fallback>Something went wrong. Please refresh</div>
</amp-list>
Step 4: Disabling While Submitting
After selecting a new subscription setting from the dropdown, it is unclear when the form is submitting or submitted. Furthermore, nothing prevents the user from attempting to select a new subscription setting while the form is still submitting.
Ideally the select
element would be disabled while the form is submitting. However, there's a problem with this approach: values of disabled inputs are not submitted with a form. Under different circumstances we might consider using the readonly
attribute instead of the disabled
attribute to avoid this problem, but the select
element does not support the readonly
attribute. Fortunately, we can solve this problem by submitting the select
element's value through a new hidden input
element, which doesn't need to be disabled because users cannot interact with or see it, that we ensure always contains the same value as the select
element.
We start by adding a hidden input
element, and moving the name
of the select
element to the hidden input
element so that its value submits. To keep the hidden input
element's value in sync with the select
element's value, we update a new nextSubscription
state variable using AMP.setState
whenever the select
element's value changes and bind the hidden input
element's value
attribute to the state variable.
To disable the select
element we need a state variable that represents whether the form is currently submitting that we can use in the select
element's disabled
attribute binding. We reuse the nextSubscription
state variable by ensuring it is null
when the form is not submitting by updating nextSubscription
to null
on the submit-success
and submit-error
events of the form
element. Finally, we bind the select
element's disabled
attribute to !!nextSubscription
because the state variable is truthy when the form is submitting. We do the same for each of its option
elements to prevent the user from using the keyboard to switch between the values while the form is submitting.
<amp-list src="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" binding="refresh" items="." single-item layout="fill">
<template type="amp-mustache">
<form action-xhr="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" method="post" id="form2"
on="submit-success: AMP.setState({ nextSubscription: null });
submit-error: AMP.setState({ nextSubscription: null });">
<input type="hidden" name="nextSubscription" [value]="nextSubscription">
<select
on="change: AMP.setState({ nextSubscription: event.value }), form2.submit;"
[disabled]="!!nextSubscription">
{{#options}}
{{#isSelected}}
<option value="{{value}}" [disabled]="!!nextSubscription" selected>
{{text}}
</option>
{{/isSelected}}
{{^isSelected}}
<option value="{{value}}" [disabled]="!!nextSubscription">
{{text}}
</option>
{{/isSelected}}
{{/options}}
</select>
</form>
</template>
<div placeholder>Loading...</div>
<div fallback>Something went wrong. Please refresh</div>
</amp-list>
Note that despite the disabled
attribute being a boolean attribute, we can still effectively bind it because amp-bind
will remove the attribute when the binding expression is false
.
Step 5: Handling Form Submission Failure
We need to display an error message and revert the select
element to its previous value if the form fails to submit.
To display an error message when the form fails to submit, we add element containing our error message to the form
element's children. We annotate the child element with the submit-error
attribute. This attribute ensures that the element is only visible after a failed form submission.
Reverting the select
element's value to its previous value requires binding the selected
attribute of each option
element. This requires maintaining the select
element's current value in a currentSubscription
state variable and the element's previous value in a previousSubscription
state variable.
When the select
element's value changes, we set currentSubscription
to the new value and previousSubscription
to the element's previous value. We store these in the currentSubscription
state variable, if this is not the first time the element's value has changed. Otherwise, it's stored in the currentSubscription
field of the amp-list
response.
We ensure the select
element's selected option
stays in sync with currentSubscription
by binding the selected
attribute of each option
. Finally, we revert the select
element to its previous value on form submission failure by setting currentSubscription
to previousSubscription
in the form
element's submit-error
event.
The following code snippet is configured to fail form submissions. Test out the new behavior!
<amp-list src="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" binding="refresh" items="." single-item layout="fill">
<template type="amp-mustache">
<form action-xhr="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription?fail=true" method="post" id="form3"
on="submit-success: AMP.setState({ nextSubscription: null });
submit-error:
AMP.setState({
currentSubscription: previousSubscription,
nextSubscription: null
});">
<input type="hidden" name="nextSubscription" [value]="nextSubscription">
<select
on="change:
AMP.setState({
previousSubscription: currentSubscription || '{{currentSubscription}}',
currentSubscription: event.value,
nextSubscription: event.value
}),
form3.submit;"
[disabled]="!!nextSubscription">
{{#options}}
{{#isSelected}}
<option value="{{value}}" [disabled]="!!nextSubscription"
selected [selected]="(currentSubscription || '{{currentSubscription}}') == '{{value}}'">
{{text}}
</option>
{{/isSelected}}
{{^isSelected}}
<option value="{{value}}" [disabled]="!!nextSubscription"
[selected]="(currentSubscription || '{{currentSubscription}}') == '{{value}}'">
{{text}}
</option>
{{/isSelected}}
{{/options}}
</select>
<div submit-error>Something went wrong. Please try again</div>
</form>
</template>
<div placeholder>Loading...</div>
<div fallback>Something went wrong. Please refresh</div>
</amp-list>
Step 6: Icon Dropdown
Styling the select
element itself to look like an icon using only CSS supported by email clients isn't feasible. Instead, we'll hide the select
element's persistent user interface (the box showing the current value), and show a different icon for each dropdown state in its place. However, we'll keep the dropdown shown on click.
Fortunately, styling select
affects the persistent user interface while styling option
affects the dropdown. We can use this fact to hide the former, but keep the latter. Of the three common choices for hiding an element using CSS (display: none;
, visibility: hidden;
, and opacity: 0;
), opacity: 0;
is most suited because we want to keep the element in the tab order as well as keep receiving its click events:
.subscription-select { opacity: 0; cursor: pointer; }
We also set cursor: pointer;
for the select
element because it looks like a button now.
The next step is to create a new icon-based user interface. We start with a div
element containing an amp-img
component for each subscription setting. The div
element is annotated with the following icon
CSS class, which hides all of the images by default:
.icon > * { visibility: hidden; }
The amp-img
component corresponding to the current state of the select
element is made visible using the following visible
CSS class:
.visible { visibility: visible; }
If the select
element's value has not changed, then we determine the current state of the select
element from the isSelected
fields in the amp-list
response. Otherwise, we determine the current state from the currentSubscription
state variable.
Although at most one amp-img
component is visible at a time, each component still takes up space on the page. This means that the components will be positioned one below the other by default. Using the display
property instead of the visibility
property would fix this issue, but it would cause the icon to flicker between subscription setting changes because AMP does not preload amp-img
components that start out as display: none;
. Instead, we use the following relative
and absolute
CSS classes to stack the amp-img
component on top of each other on the z axis:
.relative { position: relative; } .absolute { position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
Note that the stacking order of the amp-img
components relative to each other doesn't matter because at most one is visible at a time.
We also use these CSS classes to position the icons behind the invisible select
user interface so that when a user tries to click on one of our icons they'll actually be clicking on the invisible select
element and triggering its events! We keep our new icons behind the select
element by placing the icon's code before the select
element's code (see Stacking without the z-index
property).
We ensure the select
element's click target bounding box is the same size as the icons using the following icon-sized
CSS class:
.icon-sized { width: 30px; height: 30px; }
Screen readers can get all of the information they need from the select
element so we make the icons invisible to screen readers using the aria-hidden
attribute.
Finally, we display an outline around the icons when the select
element is focused using the :focus-within
pseudo selector:
.icon-container:focus-within { outline-offset: 2px; outline: 1px solid black; outline: -webkit-focus-ring-color auto 1px; }
And we're done!
<amp-list src="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" binding="refresh" items="." single-item layout="fill">
<template type="amp-mustache">
<form action-xhr="https://amp.dev/documentation/examples/interactivity-dynamic-content/subscription_settings/subscription" method="post" id="form4"
on="submit-success: AMP.setState({ nextSubscription: null });
submit-error:
AMP.setState({
currentSubscription: previousSubscription,
nextSubscription: null
});">
<div class="icon-container relative icon-sized"
[class]="(nextSubscription ? 'disabled ' : ' ') + 'icon-container relative icon-sized'">
<div class="icon icon-sized" aria-hidden>
{{#options}}
<amp-img width="30" height="30" src="{{imgUrl}}"
class="{{#isSelected}}visible {{/isSelected}}absolute"
[class]="((currentSubscription || '{{currentSubscription}}') == '{{value}}' ? 'visible ' : ' ') + 'absolute'">
</amp-img>
{{/options}}
</div>
<input type="hidden" name="nextSubscription" [value]="nextSubscription">
<select
class="subscription-select absolute icon-sized"
on="change:
AMP.setState({
previousSubscription: currentSubscription || '{{currentSubscription}}',
currentSubscription: event.value,
nextSubscription: event.value
}),
form4.submit;"
[disabled]="!!nextSubscription">
{{#options}}
{{#isSelected}}
<option value="{{value}}" [disabled]="!!nextSubscription"
selected [selected]="(currentSubscription || '{{currentSubscription}}') == '{{value}}'">
{{text}}
</option>
{{/isSelected}}
{{^isSelected}}
<option value="{{value}}" [disabled]="!!nextSubscription"
[selected]="(currentSubscription || '{{currentSubscription}}') == '{{value}}'">
{{text}}
</option>
{{/isSelected}}
{{/options}}
</select>
</div>
<div submit-error>Something went wrong. Please try again</div>
</form>
</template>
<div placeholder>Loading...</div>
<div fallback>Something went wrong. Please refresh</div>
</amp-list>
If the explanations on this page don't cover all of your questions feel free to reach out to other AMP users to discuss your exact use case.
Go to Stack Overflow An unexplained feature?The AMP project strongly encourages your participation and contributions! We hope you'll become an ongoing participant in our open source community but we also welcome one-off contributions for the issues you're particularly passionate about.
Edit sample on GitHub-
Written by @TomerAberbach