Notification preferences
The settings page every product ends up needing. Three notification
categories (md-accordion) each hold an
email/push/SMS matrix of md-switch controls with
fieldset-style labeling, a md-multi-select
picks the weekly digest topics, two md-time-pickers
bound a quiet-hours window (overnight ranges supported), and a mute-all switch
pauses everything at once — flagged by a warning
md-chip and confirmed on save by a
md-snackbar.
Live preview — toggle channels, try Mute all, then Save preferences
Notification preferences
Choose how Meridian reaches you, per category and per channel.
Mute all notifications
Pauses email, push and SMS until you turn it back on
Email
Push
SMS
Feature announcements
Tips and tutorials
Email
Push
SMS
Invoices and receipts
Payment failures
Email
Push
SMS
New sign-ins
Password changes (email always on)
Quiet hours
Push and SMS are held overnight and delivered after the window ends.
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; align-items: center; gap: 12px;">
<span id="mute-label">Mute all notifications</span>
<md-chip color="warning" appearance="filled" label="Notifications paused" hidden></md-chip>
<md-switch aria-labelledby="mute-label" icons></md-switch>
</div>
<md-accordion heading-level="3" default-expanded="0">
<md-accordion-item headline="Product" supporting-text="3 of 6 channels on" icon="rocket_launch">
<div role="group" aria-label="Product notification channels">
<!-- one grid row per notification type; every switch is named by
its row label AND its column header -->
<span id="p-em">Email</span> <span id="p-pu">Push</span> <span id="p-sm">SMS</span>
<span id="p-feat">Feature announcements</span>
<md-switch selected aria-labelledby="p-feat p-em"></md-switch>
<md-switch selected aria-labelledby="p-feat p-pu"></md-switch>
<md-switch aria-labelledby="p-feat p-sm"></md-switch>
</div>
</md-accordion-item>
<!-- Billing and Security items follow the same shape -->
</md-accordion>
<md-multi-select label="Weekly digest topics" clearable
supporting-text="Bundled into one summary email every Monday at 09:00">
<md-select-option value="releases" selected>Release notes</md-select-option>
<md-select-option value="advisories" selected>Security advisories</md-select-option>
<md-select-option value="roadmap">Roadmap updates</md-select-option>
</md-multi-select>
<md-time-picker label="Start" name="quiet_start" format="24h" value="22:00" minute-step="15"></md-time-picker>
<md-time-picker label="End" name="quiet_end" format="24h" value="07:00" minute-step="15"></md-time-picker>
<md-button variant="filled">Save preferences</md-button>
<md-snackbar closeable></md-snackbar>
<script type="module">
const mute = document.querySelector('[data-mute]');
const muteChip = document.querySelector('[data-mute-chip]');
const accordion = document.querySelector('md-accordion');
const items = Array.from(document.querySelectorAll('md-accordion-item'));
const channels = Array.from(document.querySelectorAll('md-switch[data-channel]'));
const saveBtn = document.querySelector('[data-save]');
const bar = document.querySelector('[data-saved-bar]');
// Keep each category's supporting text in sync with its matrix.
// md-switch's mdChange bubbles and is composed, so one listener on the
// accordion hears every switch inside it.
function refreshCounts() {
items.forEach((item) => {
const sws = Array.from(item.querySelectorAll('md-switch[data-channel]'));
const on = sws.filter((s) => s.selected).length;
item.supportingText = on + ' of ' + sws.length + ' channels on';
});
}
accordion.addEventListener('mdChange', refreshCounts);
refreshCounts();
// Mute all: show the warning chip and disable every channel switch.
// The always-on security email switch stays disabled either way.
mute.addEventListener('mdChange', (e) => {
const paused = e.detail.selected;
muteChip.style.display = paused ? '' : 'none';
channels.forEach((s) => {
if (!s.hasAttribute('data-locked')) s.disabled = paused;
});
});
// Save: confirm through the snackbar with a real summary.
saveBtn.addEventListener('mdClick', () => {
const active = channels.filter((s) => s.selected).length;
bar.message = mute.selected
? 'Preferences saved — all notifications paused'
: 'Preferences saved — ' + active + ' channels active';
bar.show();
});
</script>import {
MdAccordion, MdAccordionItem, MdButton, MdChip, MdMultiSelect,
MdSelectOption, MdSnackbar, MdSwitch, MdTimePicker,
} from '@awc-ui/react';
import { useRef, useState } from 'react';
type Cell = { ch: string; on: boolean; locked?: boolean };
type Category = { name: string; icon: string; rows: { label: string; cells: Cell[] }[] };
const cell = (ch: string, on: boolean, locked = false): Cell => ({ ch, on, locked });
const initial: Category[] = [
{ name: 'Product', icon: 'rocket_launch', rows: [
{ label: 'Feature announcements', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Tips and tutorials', cells: [cell('Email', true), cell('Push', false), cell('SMS', false)] },
] },
{ name: 'Security', icon: 'security', rows: [
{ label: 'New sign-ins', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Password changes', cells: [cell('Email', true, true), cell('Push', true), cell('SMS', false)] },
] },
];
const countOn = (cat: Category) => {
const cells = cat.rows.flatMap((r) => r.cells);
return cells.filter((c) => c.on).length + ' of ' + cells.length + ' channels on';
};
export function NotificationPreferences() {
const [categories, setCategories] = useState(initial);
const [muted, setMuted] = useState(false);
const [topics, setTopics] = useState(['releases', 'advisories']);
const [quiet, setQuiet] = useState({ start: '22:00', end: '07:00' });
const [message, setMessage] = useState('');
const bar = useRef<HTMLMdSnackbarElement>(null);
const setCell = (ci: number, ri: number, k: number, on: boolean) =>
setCategories((cats) => cats.map((cat, i) => i !== ci ? cat : {
...cat,
rows: cat.rows.map((row, j) => j !== ri ? row : {
...row,
cells: row.cells.map((c, n) => (n !== k ? c : { ...c, on })),
}),
}));
const save = () => {
const active = categories.flatMap((c) => c.rows).flatMap((r) => r.cells).filter((c) => c.on).length;
setMessage(muted
? 'Preferences saved — all notifications paused'
: 'Preferences saved — ' + active + ' channels active');
requestAnimationFrame(() => bar.current?.show());
};
return (
<div style={{ display: 'grid', gap: 20, maxWidth: 640 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span id="mute-label" style={{ flex: 1 }}>Mute all notifications</span>
{muted && <MdChip color="warning" appearance="filled" label="Notifications paused" />}
<MdSwitch icons aria-labelledby="mute-label" selected={muted}
onMdChange={(e) => setMuted(e.detail.selected)} />
</div>
<MdAccordion headingLevel={3} defaultExpanded={0}>
{categories.map((cat, ci) => (
<MdAccordionItem key={cat.name} headline={cat.name} icon={cat.icon} supportingText={countOn(cat)}>
{cat.rows.map((row, ri) => (
<div key={row.label} style={{ display: 'grid', gridTemplateColumns: '1fr 64px 64px 64px', alignItems: 'center' }}>
<span>{row.label}</span>
{row.cells.map((c, k) => (
<MdSwitch key={c.ch} selected={c.on} disabled={muted || c.locked}
aria-label={row.label + ' ' + c.ch}
onMdChange={(e) => setCell(ci, ri, k, e.detail.selected)} />
))}
</div>
))}
</MdAccordionItem>
))}
</MdAccordion>
<MdMultiSelect label="Weekly digest topics" clearable value={topics}
supportingText="Bundled into one summary email every Monday at 09:00"
onMdChange={(e) => setTopics(e.detail)}>
<MdSelectOption value="releases">Release notes</MdSelectOption>
<MdSelectOption value="advisories">Security advisories</MdSelectOption>
<MdSelectOption value="roadmap">Roadmap updates</MdSelectOption>
</MdMultiSelect>
<div style={{ display: 'flex', gap: 12 }}>
<MdTimePicker label="Start" format="24h" minuteStep={15} value={quiet.start}
onMdChange={(e) => setQuiet((q) => ({ ...q, start: e.detail.value }))} />
<MdTimePicker label="End" format="24h" minuteStep={15} value={quiet.end}
onMdChange={(e) => setQuiet((q) => ({ ...q, end: e.detail.value }))} />
</div>
<MdButton variant="filled" onMdClick={save}>Save preferences</MdButton>
<MdSnackbar ref={bar} message={message} closeable />
</div>
);
}import { Component, ElementRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MdAccordion, MdAccordionItem, MdButton, MdChip, MdMultiSelect,
MdSelectOption, MdSnackbar, MdSwitch, MdTimePicker,
SelectValueAccessor, SwitchValueAccessor,
} from '@awc-ui/angular';
type Cell = { ch: string; on: boolean; locked?: boolean };
type Category = { name: string; icon: string; rows: { label: string; cells: Cell[] }[] };
const cell = (ch: string, on: boolean, locked = false): Cell => ({ ch, on, locked });
@Component({
selector: 'app-notification-preferences',
standalone: true,
imports: [
FormsModule, MdAccordion, MdAccordionItem, MdButton, MdChip, MdMultiSelect,
MdSelectOption, MdSnackbar, MdSwitch, MdTimePicker,
SelectValueAccessor, SwitchValueAccessor,
],
template: [
'<div style="display: grid; gap: 20px; max-width: 640px;">',
' <div style="display: flex; align-items: center; gap: 12px;">',
' <span id="mute-label" style="flex: 1;">Mute all notifications</span>',
' @if (muted) { <md-chip color="warning" appearance="filled" label="Notifications paused"></md-chip> }',
' <md-switch icons aria-labelledby="mute-label" [(ngModel)]="muted"></md-switch>',
' </div>',
' <md-accordion [headingLevel]="3" [defaultExpanded]="0">',
' @for (cat of categories; track cat.name) {',
' <md-accordion-item [headline]="cat.name" [icon]="cat.icon" [supportingText]="countOn(cat)">',
' @for (row of cat.rows; track row.label) {',
' <div style="display: grid; grid-template-columns: 1fr 64px 64px 64px; align-items: center;">',
' <span>{{ row.label }}</span>',
' @for (c of row.cells; track c.ch) {',
' <md-switch [(ngModel)]="c.on" [disabled]="muted || c.locked" aria-label="{{ row.label }} {{ c.ch }}"></md-switch>',
' }',
' </div>',
' }',
' </md-accordion-item>',
' }',
' </md-accordion>',
' <md-multi-select label="Weekly digest topics" clearable [(ngModel)]="topics">',
' <md-select-option value="releases">Release notes</md-select-option>',
' <md-select-option value="advisories">Security advisories</md-select-option>',
' <md-select-option value="roadmap">Roadmap updates</md-select-option>',
' </md-multi-select>',
' <div style="display: flex; gap: 12px;">',
' <md-time-picker label="Start" format="24h" minute-step="15" [(ngModel)]="quietStart"></md-time-picker>',
' <md-time-picker label="End" format="24h" minute-step="15" [(ngModel)]="quietEnd"></md-time-picker>',
' </div>',
' <md-button variant="filled" (mdClick)="save()">Save preferences</md-button>',
'</div>',
'<md-snackbar #bar closeable></md-snackbar>',
].join('\n'),
})
export class NotificationPreferencesComponent {
categories: Category[] = [
{ name: 'Product', icon: 'rocket_launch', rows: [
{ label: 'Feature announcements', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Tips and tutorials', cells: [cell('Email', true), cell('Push', false), cell('SMS', false)] },
] },
{ name: 'Security', icon: 'security', rows: [
{ label: 'New sign-ins', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Password changes', cells: [cell('Email', true, true), cell('Push', true), cell('SMS', false)] },
] },
];
muted = false;
topics = ['releases', 'advisories'];
quietStart = '22:00';
quietEnd = '07:00';
@ViewChild('bar') bar!: ElementRef<HTMLMdSnackbarElement>;
countOn(cat: Category): string {
const cells = cat.rows.flatMap((r) => r.cells);
return cells.filter((c) => c.on).length + ' of ' + cells.length + ' channels on';
}
save(): void {
const active = this.categories.flatMap((c) => c.rows).flatMap((r) => r.cells).filter((c) => c.on).length;
const el = this.bar.nativeElement;
el.message = this.muted
? 'Preferences saved — all notifications paused'
: 'Preferences saved — ' + active + ' channels active';
el.show();
}
}<script setup lang="ts">
import {
MdAccordion, MdAccordionItem, MdButton, MdChip, MdMultiSelect,
MdSelectOption, MdSnackbar, MdSwitch, MdTimePicker,
} from '@awc-ui/vue';
import { reactive, ref } from 'vue';
type Cell = { ch: string; on: boolean; locked?: boolean };
type Category = { name: string; icon: string; rows: { label: string; cells: Cell[] }[] };
const cell = (ch: string, on: boolean, locked = false): Cell => ({ ch, on, locked });
const categories = reactive<Category[]>([
{ name: 'Product', icon: 'rocket_launch', rows: [
{ label: 'Feature announcements', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Tips and tutorials', cells: [cell('Email', true), cell('Push', false), cell('SMS', false)] },
] },
{ name: 'Security', icon: 'security', rows: [
{ label: 'New sign-ins', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Password changes', cells: [cell('Email', true, true), cell('Push', true), cell('SMS', false)] },
] },
]);
const muted = ref(false);
const topics = ref(['releases', 'advisories']);
const quietStart = ref('22:00');
const quietEnd = ref('07:00');
const bar = ref<any>(null);
const countOn = (cat: Category) => {
const cells = cat.rows.flatMap((r) => r.cells);
return cells.filter((c) => c.on).length + ' of ' + cells.length + ' channels on';
};
function save() {
const active = categories.flatMap((c) => c.rows).flatMap((r) => r.cells).filter((c) => c.on).length;
const el = bar.value?.$el as HTMLMdSnackbarElement;
if (!el) return;
el.message = muted.value
? 'Preferences saved — all notifications paused'
: 'Preferences saved — ' + active + ' channels active';
el.show();
}
</script>
<template>
<div style="display: grid; gap: 20px; max-width: 640px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span id="mute-label" style="flex: 1;">Mute all notifications</span>
<MdChip v-if="muted" color="warning" appearance="filled" label="Notifications paused" />
<MdSwitch icons aria-labelledby="mute-label" :selected="muted"
@md-change="muted = $event.detail.selected" />
</div>
<MdAccordion :heading-level="3" :default-expanded="0">
<MdAccordionItem v-for="cat in categories" :key="cat.name"
:headline="cat.name" :icon="cat.icon" :supporting-text="countOn(cat)">
<div v-for="row in cat.rows" :key="row.label"
style="display: grid; grid-template-columns: 1fr 64px 64px 64px; align-items: center;">
<span>{{ row.label }}</span>
<MdSwitch v-for="c in row.cells" :key="c.ch" :selected="c.on"
:disabled="muted || c.locked" :aria-label="row.label + ' ' + c.ch"
@md-change="c.on = $event.detail.selected" />
</div>
</MdAccordionItem>
</MdAccordion>
<MdMultiSelect label="Weekly digest topics" clearable :value="topics"
supporting-text="Bundled into one summary email every Monday at 09:00"
@md-change="topics = $event.detail">
<MdSelectOption value="releases">Release notes</MdSelectOption>
<MdSelectOption value="advisories">Security advisories</MdSelectOption>
<MdSelectOption value="roadmap">Roadmap updates</MdSelectOption>
</MdMultiSelect>
<div style="display: flex; gap: 12px;">
<MdTimePicker label="Start" format="24h" :minute-step="15" :value="quietStart"
@md-change="quietStart = $event.detail.value" />
<MdTimePicker label="End" format="24h" :minute-step="15" :value="quietEnd"
@md-change="quietEnd = $event.detail.value" />
</div>
<MdButton variant="filled" @md-click="save">Save preferences</MdButton>
</div>
<MdSnackbar ref="bar" closeable />
</template><script>
// Register the md-* elements once in your client entry:
// import { defineCustomElements } from '@awc-ui/svelte';
// defineCustomElements(window);
// (SvelteKit: run it behind an if (browser) guard — registration is client-only.)
const cell = (ch, on, locked = false) => ({ ch, on, locked });
let categories = [
{ name: 'Product', icon: 'rocket_launch', rows: [
{ label: 'Feature announcements', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Tips and tutorials', cells: [cell('Email', true), cell('Push', false), cell('SMS', false)] },
] },
{ name: 'Security', icon: 'security', rows: [
{ label: 'New sign-ins', cells: [cell('Email', true), cell('Push', true), cell('SMS', false)] },
{ label: 'Password changes', cells: [cell('Email', true, true), cell('Push', true), cell('SMS', false)] },
] },
];
let muted = false;
let topics = ['releases', 'advisories'];
let quietStart = '22:00';
let quietEnd = '07:00';
let bar;
const countOn = (cat) => {
const cells = cat.rows.flatMap((r) => r.cells);
return cells.filter((c) => c.on).length + ' of ' + cells.length + ' channels on';
};
function toggleCell(c, e) {
c.on = e.detail.selected;
categories = categories; // reassign so the counts re-derive
}
function save() {
const active = categories.flatMap((c) => c.rows).flatMap((r) => r.cells).filter((c) => c.on).length;
bar.message = muted
? 'Preferences saved — all notifications paused'
: 'Preferences saved — ' + active + ' channels active';
bar.show();
}
</script>
<div style="display: grid; gap: 20px; max-width: 640px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span id="mute-label" style="flex: 1;">Mute all notifications</span>
{#if muted}
<md-chip color="warning" appearance="filled" label="Notifications paused"></md-chip>
{/if}
<md-switch icons aria-labelledby="mute-label" selected={muted}
on:mdChange={(e) => (muted = e.detail.selected)}></md-switch>
</div>
<md-accordion heading-level="3" default-expanded="0">
{#each categories as cat (cat.name)}
<md-accordion-item headline={cat.name} icon={cat.icon} supporting-text={countOn(cat)}>
{#each cat.rows as row (row.label)}
<div style="display: grid; grid-template-columns: 1fr 64px 64px 64px; align-items: center;">
<span>{row.label}</span>
{#each row.cells as c (c.ch)}
<md-switch selected={c.on} disabled={muted || c.locked}
aria-label={row.label + ' ' + c.ch}
on:mdChange={(e) => toggleCell(c, e)}></md-switch>
{/each}
</div>
{/each}
</md-accordion-item>
{/each}
</md-accordion>
<md-multi-select label="Weekly digest topics" clearable
supporting-text="Bundled into one summary email every Monday at 09:00"
on:mdChange={(e) => (topics = e.detail)}>
<md-select-option value="releases" selected>Release notes</md-select-option>
<md-select-option value="advisories" selected>Security advisories</md-select-option>
<md-select-option value="roadmap">Roadmap updates</md-select-option>
</md-multi-select>
<div style="display: flex; gap: 12px;">
<md-time-picker label="Start" format="24h" minute-step="15" value={quietStart}
on:mdChange={(e) => (quietStart = e.detail.value)}></md-time-picker>
<md-time-picker label="End" format="24h" minute-step="15" value={quietEnd}
on:mdChange={(e) => (quietEnd = e.detail.value)}></md-time-picker>
</div>
<md-button variant="filled" on:mdClick={save}>Save preferences</md-button>
</div>
<md-snackbar bind:this={bar} closeable></md-snackbar>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-accordion + md-accordion-item | One section per category. heading-level="3" slots real <h3> headings into the page outline; default-expanded="0" opens Product on load; each item’s supporting-text is a live “n of 6 channels on” summary, visible even while collapsed. |
md-switch | The channel matrix. Each switch is named via aria-labelledby pointing at both its row label and its column header — the fieldset/legend pattern for a grid of toggles. The mute-all switch uses icons so its state reads by shape, not just color. |
md-multi-select | Digest topics as removable chips. Options carrying selected are adopted at load, so the initial selection lives in markup, not script. |
md-time-picker | The quiet-hours window. format="24h" with minute-step="15" matches how delivery windows are actually scheduled; 22:00 → 07:00 is a legal overnight range. |
md-chip | The color="warning" chip flags the muted state at the top of the screen — a theme role name that resolves to --md-sys-color-warning-* tokens. |
md-snackbar | Save confirmation with a channel count. Low-priority by design: it auto-hides after 4 s and never blocks the screen. |
md-button | The save action, listening on mdClick. |
The details that make it production-grade
Section titled “The details that make it production-grade”- Switch groups need fieldset-style labeling.
md-switchhas no default slot, so a bare switch has no accessible name. Here each matrix lives in arole="group"with anaria-label, and every switch’saria-labelledbychains its row label and column header — a screen reader announces “Feature announcements Email, switch, on” instead of eighteen anonymous toggles. - Muting stays silent in the right way. Assigning
selectedordisabledfrom script never firesmdInput/mdChangeon a switch — so bulk-disabling the matrix doesn’t trigger a cascade of phantom change handlers or corrupt the per-category counts. - “Always on” is
disabled, not a lie. The password-changes email switch shipsselected disabled: it renders as on, announcesaria-disabled, and leaves the tab order, while the mute-all sweep explicitly skips it so unmuting never re-enables a channel the policy locked. - The quiet-hours window can cross midnight.
md-time-pickertreats a reversedmin/maxas an overnight range (like native<input type="time">), andvalueis always a canonical 24-hourHH:MM— what you submit is what you stored, regardless of display format. - The snackbar respects reading time. Its auto-hide timer pauses while the surface is hovered or focused (WCAG 2.2.1) and it announces through a polite live region, so the save confirmation is perceivable without stealing focus.
Variations
Section titled “Variations”- Settings-shell navigation: add
exclusive keep-one-expandedto the accordion so exactly one category is open at a time — the locked-open panel advertisesaria-disabled="true"on its header. - Server-confirmed SMS: make the SMS switches controlled — call
preventDefault()onmdInput, verify the phone number server-side, then assignselectedyourself once the carrier accepts. - Undo on save: give the snackbar
action="Undo", callhide('action')from yourmdActionhandler, and branch onmdClose’sdetail.reasonto roll the preferences back. - Many digest topics: switch the multi-select to
display-mode="count"with acountFormatter, or addfilterableonce the topic list passes ~15 entries.
Related
Section titled “Related”- Recipe: Two-factor verification — the security settings these notifications guard.
- Component manuals:
md-switch— the controlled-mode event split;md-accordion— heading levels, region roles and the full keyboard map.