Inbox with reading pane
A whole mail app screen from seven components. A
md-navigation-rail carries the folders and
their unread counts; the message list is a single-select
md-list whose rows get avatars and an unread
md-status-dot; md-search
filters conversations into its own results panel; opening a message slides in a
standard (non-modal) md-side-sheet reading pane
with a Reply md-split-button; and Archive is
reversible through an md-snackbar with Undo —
committed only when the snackbar closes without it.
Live preview — select a message to open the reading pane, then archive it and undo
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="position: relative; display: flex; block-size: clamp(540px, 78vh, 820px);">
<md-navigation-rail label="Mailboxes" active-index="0">
<md-navigation-rail-tab icon="inbox" label="Inbox" value="inbox" badge-value="3"></md-navigation-rail-tab>
<md-navigation-rail-tab icon="star" label="Starred" value="starred"></md-navigation-rail-tab>
<md-navigation-rail-tab icon="send" label="Sent" value="sent"></md-navigation-rail-tab>
<md-navigation-rail-tab icon="draft" label="Drafts" value="drafts" badge-value="2"></md-navigation-rail-tab>
</md-navigation-rail>
<div style="flex: 1; display: flex; flex-direction: column;">
<md-search layout="docked" trigger="bar" full-width debounce="200"
placeholder="Search mail" input-aria-label="Search mail">
<md-list slot="results" label="Search results"></md-list>
</md-search>
<md-list selection-mode="single-select" label="Inbox conversations">
<md-list-item leading-avatar-name="Priya Raghavan" headline="Priya Raghavan"
supporting-text="Q3 budget review — final numbers" lines="2">
<md-status-dot label="Unread" style="--md-status-dot-color: var(--md-sys-color-primary);" inline></md-status-dot>
</md-list-item>
<md-list-item leading-avatar-name="Sofia Lindqvist" headline="Sofia Lindqvist"
supporting-text="Design tokens migration plan" lines="2"
trailing-supporting-text="Yesterday"></md-list-item>
</md-list>
</div>
<md-side-sheet variant="standard" side="end" top-divider bottom-divider>
<p>Message body…</p>
<md-split-button slot="actions" id="reply-split" icon="reply" label="Reply"
menu-label="More reply options" controls="reply-menu"></md-split-button>
<md-button slot="actions" variant="text">Archive</md-button>
</md-side-sheet>
</div>
<md-menu id="reply-menu" anchor="reply-split" placement="top-end">
<md-menu-item headline="Reply all"></md-menu-item>
<md-menu-item headline="Forward"></md-menu-item>
</md-menu>
<md-snackbar action="Undo"></md-snackbar>
<script type="module">
const rail = document.querySelector('md-navigation-rail');
const inboxTab = document.querySelector('[data-tab-inbox]');
const draftsTab = document.querySelector('[data-tab-drafts]');
const list = document.querySelector('[data-messages]');
const emptyState = document.querySelector('[data-empty]');
const folderTitle = document.querySelector('[data-folder-title]');
const search = document.querySelector('[data-search]');
const results = document.querySelector('[data-search-results]');
const reader = document.querySelector('[data-reader]');
const replySplit = document.querySelector('[data-reply]');
const replyMenu = document.querySelector('[data-reply-menu]');
const archiveBtn = document.querySelector('[data-archive]');
const bar = document.querySelector('[data-undo-bar]');
const bodies = {
m1: 'Hi — the final Q3 numbers are in. We land 4.2% under budget overall, with cloud spend the only line that overran. Can you sign off before the Thursday board pack goes out?',
m2: 'Adding a 90-minute customer-panel slot on day two, as discussed. That pushes the roadmap workshop to after lunch. Shout if the new agenda clashes with your flight.',
m3: 'The migration plan is ready for review: we move color and typography tokens first, spacing in a second pass. Rollout is behind a flag, one product area per week.',
m4: 'Planned maintenance this Saturday 02:00-05:00 UTC. Mail and calendar stay up; the file service will be read-only. No action needed on your side.',
m5: 'Northwind wants to renew for 24 months at the current rate if we commit before the 30th. Draft contract attached in the deal room — flagging the liability clause for legal.'
};
let current = null;
let pendingArchive = null;
function syncBadge() {
const n = list.querySelectorAll('md-list-item[data-unread]').length;
inboxTab.badgeValue = n > 0 ? String(n) : '';
}
function openMessage(item) {
current = item;
if (item.hasAttribute('data-unread')) {
item.removeAttribute('data-unread');
const dot = item.querySelector('[data-dot]');
if (dot) dot.remove();
syncBadge();
}
const sender = item.getAttribute('headline');
reader.headline = item.getAttribute('supporting-text');
document.querySelector('[data-reader-avatar]').name = sender;
document.querySelector('[data-reader-sender]').textContent = sender;
document.querySelector('[data-reader-time]').textContent = item.getAttribute('data-time');
document.querySelector('[data-reader-body]').textContent = bodies[item.dataset.id] || '';
reader.show();
}
// Selecting a row in the message list opens the reading pane.
list.addEventListener('mdSelect', (e) => openMessage(e.detail.item));
// Search: fetch-free filter over the rows; md-search debounces and
// de-duplicates the query, we only render the hits.
search.addEventListener('mdSearch', (e) => {
const q = e.detail.value.toLowerCase();
results.innerHTML = '';
if (!q) return;
list.querySelectorAll('md-list-item').forEach((row) => {
const sender = row.getAttribute('headline') || '';
const subject = row.getAttribute('supporting-text') || '';
if ((sender + ' ' + subject).toLowerCase().indexOf(q) === -1) return;
const hit = document.createElement('md-list-item');
hit.setAttribute('type', 'button');
hit.setAttribute('headline', subject);
hit.setAttribute('supporting-text', sender);
hit.setAttribute('leading-icon', 'mail');
hit.dataset.id = row.dataset.id;
results.appendChild(hit);
});
});
results.addEventListener('mdClick', (e) => {
const id = e.detail.item.dataset.id;
const rows = Array.from(list.querySelectorAll('md-list-item'));
const row = rows.find((r) => r.dataset.id === id);
search.close();
if (!row) return;
list.selectItem(rows.indexOf(row)); // mirror state; emits no mdSelect
openMessage(row);
});
search.addEventListener('mdClose', () => { results.innerHTML = ''; });
// Folder switching: only the Inbox is populated in this demo.
const folderNames = { inbox: 'Inbox', starred: 'Starred', sent: 'Sent', drafts: 'Drafts' };
rail.addEventListener('mdTabChange', (e) => {
const isInbox = e.detail.value === 'inbox';
list.style.display = isInbox ? '' : 'none';
// Toggled via style.display, not the hidden attribute: this element
// carries an inline display, and an inline style outranks the UA
// stylesheet's [hidden] { display: none } — so hidden alone left it
// laid out (an invisible flex:1 sibling eating half the pane and
// squeezing the message list into a scroller).
emptyState.style.display = isInbox ? 'none' : 'grid';
folderTitle.textContent = folderNames[e.detail.value];
if (!isInbox) {
emptyState.textContent = folderNames[e.detail.value] + ' is empty in this demo.';
reader.close();
}
});
// Archive with undo: hide the row immediately, commit only when the
// snackbar closes for a reason other than the Undo action.
archiveBtn.addEventListener('mdClick', () => {
if (!current) return;
pendingArchive = current;
current.style.display = 'none';
current = null;
reader.close();
bar.action = 'Undo';
bar.message = 'Conversation archived';
bar.show();
});
bar.addEventListener('mdAction', () => bar.hide('action'));
bar.addEventListener('mdClose', (e) => {
if (!pendingArchive) return;
if (e.detail.reason === 'action') pendingArchive.style.display = '';
else pendingArchive.remove();
pendingArchive = null;
});
// Reply split button: the leading segment is the default action, the
// trailing toggle drives our own md-menu of variations.
function draftToast(kind) {
draftsTab.badgeValue = String(parseInt(draftsTab.badgeValue || '0', 10) + 1);
bar.action = '';
bar.message = kind + ' draft saved to Drafts';
bar.show();
}
replySplit.addEventListener('mdLeadingClick', () => draftToast('Reply'));
replySplit.addEventListener('mdTrailingClick', (e) => {
if (e.detail.checked) replyMenu.show();
else replyMenu.close();
});
// mdClose does not bubble — listen on the menu itself, and keep the
// chevron truthful when the menu closes by outside click or Escape.
replyMenu.addEventListener('mdClose', () => { replySplit.trailingChecked = false; });
replyMenu.addEventListener('mdClick', (e) => {
const item = e.target.closest('md-menu-item');
if (item) draftToast(item.getAttribute('headline'));
});
</script>import { useMemo, useRef, useState } from 'react';
import {
MdAvatar, MdButton, MdList, MdListItem, MdMenu, MdMenuItem,
MdNavigationRail, MdNavigationRailTab, MdSearch, MdSideSheet,
MdSnackbar, MdSplitButton, MdStatusDot,
} from '@awc-ui/react';
const FOLDERS = [
{ value: 'inbox', icon: 'inbox', label: 'Inbox' },
{ value: 'starred', icon: 'star', label: 'Starred' },
{ value: 'sent', icon: 'send', label: 'Sent' },
{ value: 'drafts', icon: 'draft', label: 'Drafts' },
];
const INITIAL = [
{ id: 'm1', sender: 'Priya Raghavan', subject: 'Q3 budget review — final numbers',
time: 'Today, 09:41', clock: '09:41', unread: true,
body: 'The final Q3 numbers are in — 4.2% under budget overall. Can you sign off before Thursday?' },
{ id: 'm2', sender: 'Marcus Webb', subject: 'Re: Berlin offsite agenda',
time: 'Today, 08:15', clock: '08:15', unread: true,
body: 'Adding a 90-minute customer-panel slot on day two; the roadmap workshop moves to after lunch.' },
{ id: 'm3', sender: 'Sofia Lindqvist', subject: 'Design tokens migration plan',
time: 'Yesterday, 17:02', clock: 'Yesterday', unread: false,
body: 'The migration plan is ready for review: color and typography tokens first, spacing second.' },
];
export default function InboxShell() {
const [messages, setMessages] = useState(INITIAL);
const [folder, setFolder] = useState('inbox');
const [query, setQuery] = useState('');
const [openId, setOpenId] = useState(null);
const [pendingId, setPendingId] = useState(null); // archived, awaiting the snackbar verdict
const [drafts, setDrafts] = useState(2);
const [menuOpen, setMenuOpen] = useState(false);
const [toast, setToast] = useState({ message: '', action: '' });
const searchRef = useRef(null);
const barRef = useRef(null);
const visible = messages.filter((m) => m.id !== pendingId);
const unread = messages.filter((m) => m.unread).length;
const openMsg = visible.find((m) => m.id === openId) || null;
const hits = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? messages.filter((m) => (m.sender + ' ' + m.subject).toLowerCase().includes(q)) : [];
}, [messages, query]);
const openMessage = (id) => { // opening marks the row read — dot and badge follow the state
setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, unread: false } : m)));
setOpenId(id);
};
const showToast = (message, action) => { setToast({ message, action }); barRef.current.show(); };
const archive = () => { setPendingId(openId); setOpenId(null); showToast('Conversation archived', 'Undo'); };
const settle = (reason) => { // commit only when the snackbar closes without Undo
if (!pendingId) return;
if (reason !== 'action') setMessages((prev) => prev.filter((m) => m.id !== pendingId));
setPendingId(null);
};
const draftToast = (kind) => { setDrafts((n) => n + 1); showToast(kind + ' draft saved to Drafts', ''); };
return (
<div style={{ position: 'relative', display: 'flex', blockSize: 540 }}>
<MdNavigationRail label="Mailboxes" activeIndex={FOLDERS.findIndex((f) => f.value === folder)}
onMdTabChange={(e) => { setFolder(e.detail.value); if (e.detail.value !== 'inbox') setOpenId(null); }}>
{FOLDERS.map((f) => (
<MdNavigationRailTab key={f.value} icon={f.icon} label={f.label} value={f.value}
badgeValue={f.value === 'inbox' ? (unread ? String(unread) : '')
: f.value === 'drafts' ? String(drafts) : ''} />
))}
</MdNavigationRail>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<MdSearch ref={searchRef} layout="docked" trigger="bar" fullWidth debounce={200}
placeholder="Search mail" inputAriaLabel="Search mail"
onMdSearch={(e) => setQuery(e.detail.value)} onMdClose={() => setQuery('')}>
<MdList slot="results" label="Search results">
{hits.map((m) => (
<MdListItem key={m.id} type="button" leadingIcon="mail" headline={m.subject}
supportingText={m.sender}
onMdClick={() => { searchRef.current.close(); openMessage(m.id); }} />
))}
</MdList>
</MdSearch>
{folder === 'inbox' ? (
<MdList selectionMode="single-select" label="Inbox conversations"
style={{ flex: 1, overflowY: 'auto' }}
onMdSelect={(e) => openMessage(visible[e.detail.index].id)}>
{visible.map((m) => (
<MdListItem key={m.id} leadingAvatarName={m.sender} headline={m.sender}
supportingText={m.subject} lines={2} selected={m.id === openId}>
<span slot="trailing" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{m.clock}
{m.unread && (
<MdStatusDot inline label="Unread"
style={{ '--md-status-dot-color': 'var(--md-sys-color-primary)' }} />
)}
</span>
</MdListItem>
))}
</MdList>
) : (
<p style={{ margin: 'auto' }}>{FOLDERS.find((f) => f.value === folder).label} is empty in this demo.</p>
)}
</div>
<MdSideSheet variant="standard" side="end" topDivider bottomDivider
open={openMsg !== null} headline={openMsg ? openMsg.subject : ''}>
{openMsg && (
<div style={{ display: 'grid', gap: 12 }}>
<MdAvatar name={openMsg.sender} />
<span>{openMsg.sender} · {openMsg.time}</span>
<p style={{ margin: 0 }}>{openMsg.body}</p>
</div>
)}
<MdSplitButton slot="actions" id="reply-split" icon="reply" label="Reply"
menuLabel="More reply options" controls="reply-menu" trailingChecked={menuOpen}
onMdLeadingClick={() => draftToast('Reply')}
onMdTrailingClick={(e) => setMenuOpen(e.detail.checked)} />
<MdButton slot="actions" variant="text" onMdClick={archive}>Archive</MdButton>
</MdSideSheet>
<MdMenu id="reply-menu" anchor="reply-split" placement="top-end"
open={menuOpen} onMdClose={() => setMenuOpen(false)}>
<MdMenuItem headline="Reply all" onMdClick={() => draftToast('Reply all')} />
<MdMenuItem headline="Forward" onMdClick={() => draftToast('Forward')} />
</MdMenu>
<MdSnackbar ref={barRef} message={toast.message} action={toast.action}
onMdAction={() => barRef.current.hide('action')}
onMdClose={(e) => settle(e.detail.reason)} />
</div>
);
}import { Component, ViewChild } from '@angular/core';
import {
MdAvatar, MdButton, MdList, MdListItem, MdMenu, MdMenuItem,
MdNavigationRail, MdNavigationRailTab, MdSearch, MdSideSheet,
MdSnackbar, MdSplitButton, MdStatusDot,
} from '@awc-ui/angular';
interface Message {
id: string; sender: string; subject: string;
time: string; clock: string; unread: boolean; body: string;
}
@Component({
selector: 'inbox-shell',
standalone: true,
imports: [
MdAvatar, MdButton, MdList, MdListItem, MdMenu, MdMenuItem,
MdNavigationRail, MdNavigationRailTab, MdSearch, MdSideSheet,
MdSnackbar, MdSplitButton, MdStatusDot,
],
// The proxies bind camelCase inputs and re-emit the component events;
// #search and #bar expose close() / show() / hide() for the imperative moments.
template:
'<div style="position: relative; display: flex; block-size: clamp(540px, 78vh, 820px);">' +
' <md-navigation-rail label="Mailboxes" [activeIndex]="folderIndex" (mdTabChange)="setFolder($event.detail.value)">' +
' @for (f of folders; track f.value) {' +
' <md-navigation-rail-tab [icon]="f.icon" [label]="f.label" [value]="f.value" [badgeValue]="badgeFor(f.value)"></md-navigation-rail-tab>' +
' }' +
' </md-navigation-rail>' +
' <div style="flex: 1; display: flex; flex-direction: column;">' +
' <md-search #search layout="docked" trigger="bar" full-width debounce="200"' +
' placeholder="Search mail" input-aria-label="Search mail"' +
' (mdSearch)="query = $event.detail.value" (mdClose)="clearQuery()">' +
' <md-list slot="results" label="Search results">' +
' @for (m of hits; track m.id) {' +
' <md-list-item type="button" leading-icon="mail" [headline]="m.subject" [supportingText]="m.sender" (mdClick)="pickHit(m.id)"></md-list-item>' +
' }' +
' </md-list>' +
' </md-search>' +
' @if (inInbox) {' +
' <md-list selection-mode="single-select" label="Inbox conversations" style="flex: 1; overflow-y: auto;" (mdSelect)="openAt($event.detail.index)">' +
' @for (m of visible; track m.id) {' +
' <md-list-item [leadingAvatarName]="m.sender" [headline]="m.sender" [supportingText]="m.subject" lines="2" [selected]="m.id === openId">' +
' <span slot="trailing" style="display: flex; align-items: center; gap: 8px;">' +
' {{ m.clock }}' +
' @if (m.unread) {' +
' <md-status-dot inline label="Unread" style="--md-status-dot-color: var(--md-sys-color-primary);"></md-status-dot>' +
' }' +
' </span>' +
' </md-list-item>' +
' }' +
' </md-list>' +
' } @else {' +
' <p style="margin: auto;">{{ folderLabel }} is empty in this demo.</p>' +
' }' +
' </div>' +
' <md-side-sheet variant="standard" side="end" top-divider bottom-divider [open]="!!openMsg" [headline]="readerHeadline">' +
' @if (openMsg; as m) {' +
' <div style="display: grid; gap: 12px;">' +
' <md-avatar [name]="m.sender"></md-avatar>' +
' <span>{{ m.sender }} · {{ m.time }}</span>' +
' <p style="margin: 0;">{{ m.body }}</p>' +
' </div>' +
' }' +
' <md-split-button slot="actions" id="reply-split" icon="reply" label="Reply" menu-label="More reply options" controls="reply-menu"' +
' [trailingChecked]="menuOpen" (mdLeadingClick)="draftToast(defaultReply)" (mdTrailingClick)="menuOpen = $event.detail.checked"></md-split-button>' +
' <md-button slot="actions" variant="text" (mdClick)="archive()">Archive</md-button>' +
' </md-side-sheet>' +
' <md-menu id="reply-menu" anchor="reply-split" placement="top-end" [open]="menuOpen" (mdClose)="menuOpen = false">' +
' @for (kind of replyKinds; track kind) {' +
' <md-menu-item [headline]="kind" (mdClick)="draftToast(kind)"></md-menu-item>' +
' }' +
' </md-menu>' +
' <md-snackbar #bar [message]="toastMsg" [action]="toastAction" (mdAction)="undo()" (mdClose)="settle($event.detail.reason)"></md-snackbar>' +
'</div>',
})
export class InboxShellComponent {
@ViewChild('search') search!: MdSearch;
@ViewChild('bar') bar!: MdSnackbar;
folders = [
{ value: 'inbox', icon: 'inbox', label: 'Inbox' },
{ value: 'starred', icon: 'star', label: 'Starred' },
{ value: 'sent', icon: 'send', label: 'Sent' },
{ value: 'drafts', icon: 'draft', label: 'Drafts' },
];
defaultReply = 'Reply';
replyKinds = ['Reply all', 'Forward'];
messages: Message[] = [
{ id: 'm1', sender: 'Priya Raghavan', subject: 'Q3 budget review — final numbers', time: 'Today, 09:41', clock: '09:41', unread: true, body: 'The final Q3 numbers are in — 4.2% under budget overall. Can you sign off before Thursday?' },
{ id: 'm2', sender: 'Marcus Webb', subject: 'Re: Berlin offsite agenda', time: 'Today, 08:15', clock: '08:15', unread: true, body: 'Adding a 90-minute customer-panel slot on day two; the roadmap workshop moves to after lunch.' },
{ id: 'm3', sender: 'Sofia Lindqvist', subject: 'Design tokens migration plan', time: 'Yesterday, 17:02', clock: 'Yesterday', unread: false, body: 'The migration plan is ready for review: color and typography tokens first, spacing second.' },
];
folder = 'inbox';
query = '';
openId: string | null = null;
pendingId: string | null = null; // archived, awaiting the snackbar verdict
drafts = 2;
menuOpen = false;
toastMsg = '';
toastAction = '';
get folderIndex() { return this.folders.findIndex((f) => f.value === this.folder); }
get inInbox() { return this.folder === 'inbox'; }
get folderLabel() { return this.folders[this.folderIndex].label; }
get visible() { return this.messages.filter((m) => m.id !== this.pendingId); }
get openMsg() { return this.visible.find((m) => m.id === this.openId) ?? null; }
get readerHeadline() { return this.openMsg ? this.openMsg.subject : ''; }
get hits() {
const q = this.query.trim().toLowerCase();
return q ? this.messages.filter((m) => (m.sender + ' ' + m.subject).toLowerCase().includes(q)) : [];
}
badgeFor(value: string) {
if (value === 'inbox') {
const n = this.messages.filter((m) => m.unread).length;
return n ? String(n) : '';
}
return value === 'drafts' ? String(this.drafts) : '';
}
setFolder(value: string) { this.folder = value; if (value !== 'inbox') this.openId = null; }
clearQuery() { this.query = ''; }
openMessage(id: string) { // opening marks the row read — dot and badge follow the state
this.messages = this.messages.map((m) => (m.id === id ? { ...m, unread: false } : m));
this.openId = id;
}
openAt(index: number) { this.openMessage(this.visible[index].id); }
pickHit(id: string) { this.search.close(); this.openMessage(id); }
showToast(message: string, action: string) { this.toastMsg = message; this.toastAction = action; this.bar.show(); }
archive() { this.pendingId = this.openId; this.openId = null; this.showToast('Conversation archived', 'Undo'); }
undo() { this.bar.hide('action'); }
settle(reason: string) { // commit only when the snackbar closes without Undo
if (!this.pendingId) return;
if (reason !== 'action') this.messages = this.messages.filter((m) => m.id !== this.pendingId);
this.pendingId = null;
}
draftToast(kind: string) { this.drafts += 1; this.showToast(kind + ' draft saved to Drafts', ''); }
}<script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdAvatar, MdButton, MdList, MdListItem, MdMenu, MdMenuItem,
MdNavigationRail, MdNavigationRailTab, MdSearch, MdSideSheet,
MdSnackbar, MdSplitButton, MdStatusDot,
} from '@awc-ui/vue';
const folders = [
{ value: 'inbox', icon: 'inbox', label: 'Inbox' },
{ value: 'starred', icon: 'star', label: 'Starred' },
{ value: 'sent', icon: 'send', label: 'Sent' },
{ value: 'drafts', icon: 'draft', label: 'Drafts' },
];
const messages = ref([
{ id: 'm1', sender: 'Priya Raghavan', subject: 'Q3 budget review — final numbers', time: 'Today, 09:41', clock: '09:41', unread: true, body: 'The final Q3 numbers are in — 4.2% under budget overall. Can you sign off before Thursday?' },
{ id: 'm2', sender: 'Marcus Webb', subject: 'Re: Berlin offsite agenda', time: 'Today, 08:15', clock: '08:15', unread: true, body: 'Adding a 90-minute customer-panel slot on day two; the roadmap workshop moves to after lunch.' },
{ id: 'm3', sender: 'Sofia Lindqvist', subject: 'Design tokens migration plan', time: 'Yesterday, 17:02', clock: 'Yesterday', unread: false, body: 'The migration plan is ready for review: color and typography tokens first, spacing second.' },
]);
const folder = ref('inbox');
const query = ref('');
const openId = ref<string | null>(null);
const pendingId = ref<string | null>(null); // archived, awaiting the snackbar verdict
const drafts = ref(2);
const menuOpen = ref(false);
const toast = ref({ message: '', action: '' });
const search = ref();
const bar = ref();
const folderIndex = computed(() => folders.findIndex((f) => f.value === folder.value));
const visible = computed(() => messages.value.filter((m) => m.id !== pendingId.value));
const openMsg = computed(() => visible.value.find((m) => m.id === openId.value) ?? null);
const unread = computed(() => messages.value.filter((m) => m.unread).length);
const hits = computed(() => {
const q = query.value.trim().toLowerCase();
return q ? messages.value.filter((m) => (m.sender + ' ' + m.subject).toLowerCase().includes(q)) : [];
});
function badgeFor(value: string) {
if (value === 'inbox') return unread.value ? String(unread.value) : '';
return value === 'drafts' ? String(drafts.value) : '';
}
function setFolder(value: string) { folder.value = value; if (value !== 'inbox') openId.value = null; }
function openMessage(id: string) { // opening marks the row read — dot and badge follow the state
messages.value = messages.value.map((m) => (m.id === id ? { ...m, unread: false } : m));
openId.value = id;
}
function pickHit(id: string) { search.value.$el.close(); openMessage(id); }
function showToast(message: string, action: string) { toast.value = { message, action }; bar.value.$el.show(); }
function archive() { pendingId.value = openId.value; openId.value = null; showToast('Conversation archived', 'Undo'); }
function settle(reason: string) { // commit only when the snackbar closes without Undo
if (!pendingId.value) return;
if (reason !== 'action') messages.value = messages.value.filter((m) => m.id !== pendingId.value);
pendingId.value = null;
}
function draftToast(kind: string) { drafts.value += 1; showToast(kind + ' draft saved to Drafts', ''); }
</script>
<template>
<div style="position: relative; display: flex; block-size: clamp(540px, 78vh, 820px);">
<MdNavigationRail label="Mailboxes" :active-index="folderIndex"
@md-tab-change="setFolder($event.detail.value)">
<MdNavigationRailTab v-for="f in folders" :key="f.value" :icon="f.icon"
:label="f.label" :value="f.value" :badge-value="badgeFor(f.value)" />
</MdNavigationRail>
<div style="flex: 1; display: flex; flex-direction: column;">
<MdSearch ref="search" layout="docked" trigger="bar" full-width :debounce="200"
placeholder="Search mail" input-aria-label="Search mail"
@md-search="query = $event.detail.value" @md-close="query = ''">
<MdList slot="results" label="Search results">
<MdListItem v-for="m in hits" :key="m.id" type="button" leading-icon="mail"
:headline="m.subject" :supporting-text="m.sender" @md-click="pickHit(m.id)" />
</MdList>
</MdSearch>
<MdList v-if="folder === 'inbox'" selection-mode="single-select" label="Inbox conversations"
style="flex: 1; overflow-y: auto;" @md-select="openMessage(visible[$event.detail.index].id)">
<MdListItem v-for="m in visible" :key="m.id" :leading-avatar-name="m.sender"
:headline="m.sender" :supporting-text="m.subject" lines="2" :selected="m.id === openId">
<span slot="trailing" style="display: flex; align-items: center; gap: 8px;">
{{ m.clock }}
<MdStatusDot v-if="m.unread" inline label="Unread"
style="--md-status-dot-color: var(--md-sys-color-primary);" />
</span>
</MdListItem>
</MdList>
<p v-else style="margin: auto;">{{ folders[folderIndex].label }} is empty in this demo.</p>
</div>
<MdSideSheet variant="standard" side="end" top-divider bottom-divider
:open="openMsg !== null" :headline="openMsg ? openMsg.subject : ''">
<div v-if="openMsg" style="display: grid; gap: 12px;">
<MdAvatar :name="openMsg.sender" />
<span>{{ openMsg.sender }} · {{ openMsg.time }}</span>
<p style="margin: 0;">{{ openMsg.body }}</p>
</div>
<MdSplitButton slot="actions" id="reply-split" icon="reply" label="Reply"
menu-label="More reply options" controls="reply-menu" :trailing-checked="menuOpen"
@md-leading-click="draftToast('Reply')" @md-trailing-click="menuOpen = $event.detail.checked" />
<MdButton slot="actions" variant="text" @md-click="archive">Archive</MdButton>
</MdSideSheet>
<MdMenu id="reply-menu" anchor="reply-split" placement="top-end"
:open="menuOpen" @md-close="menuOpen = false">
<MdMenuItem headline="Reply all" @md-click="draftToast('Reply all')" />
<MdMenuItem headline="Forward" @md-click="draftToast('Forward')" />
</MdMenu>
<MdSnackbar ref="bar" :message="toast.message" :action="toast.action"
@md-action="bar.$el.hide('action')" @md-close="settle($event.detail.reason)" />
</div>
</template><script>
// The svelte package registers the raw md-* elements — client-only.
// In SvelteKit guard it: if (browser) defineCustomElements(window);
import { defineCustomElements } from '@awc-ui/svelte';
import { onMount } from 'svelte';
onMount(() => defineCustomElements(window));
const folders = [
{ value: 'inbox', icon: 'inbox', label: 'Inbox' },
{ value: 'starred', icon: 'star', label: 'Starred' },
{ value: 'sent', icon: 'send', label: 'Sent' },
{ value: 'drafts', icon: 'draft', label: 'Drafts' },
];
let messages = [
{ id: 'm1', sender: 'Priya Raghavan', subject: 'Q3 budget review — final numbers', time: 'Today, 09:41', clock: '09:41', unread: true, body: 'The final Q3 numbers are in — 4.2% under budget overall. Can you sign off before Thursday?' },
{ id: 'm2', sender: 'Marcus Webb', subject: 'Re: Berlin offsite agenda', time: 'Today, 08:15', clock: '08:15', unread: true, body: 'Adding a 90-minute customer-panel slot on day two; the roadmap workshop moves to after lunch.' },
{ id: 'm3', sender: 'Sofia Lindqvist', subject: 'Design tokens migration plan', time: 'Yesterday, 17:02', clock: 'Yesterday', unread: false, body: 'The migration plan is ready for review: color and typography tokens first, spacing second.' },
];
let folder = 'inbox';
let query = '';
let openId = null;
let pendingId = null; // archived, awaiting the snackbar verdict
let drafts = 2;
let menuOpen = false;
let toastMsg = '';
let toastAction = '';
let search;
let bar;
$: folderIndex = folders.findIndex((f) => f.value === folder);
$: visible = messages.filter((m) => m.id !== pendingId);
$: openMsg = visible.find((m) => m.id === openId) ?? null;
$: unread = messages.filter((m) => m.unread).length;
$: hits = query.trim()
? messages.filter((m) => (m.sender + ' ' + m.subject).toLowerCase().includes(query.trim().toLowerCase()))
: [];
function setFolder(value) { folder = value; if (value !== 'inbox') openId = null; }
function openMessage(id) { // opening marks the row read — dot and badge follow the state
messages = messages.map((m) => (m.id === id ? { ...m, unread: false } : m));
openId = id;
}
function pickHit(id) { search.close(); openMessage(id); }
function showToast(message, action) { toastMsg = message; toastAction = action; bar.show(); }
function archive() { pendingId = openId; openId = null; showToast('Conversation archived', 'Undo'); }
function settle(reason) { // commit only when the snackbar closes without Undo
if (!pendingId) return;
if (reason !== 'action') messages = messages.filter((m) => m.id !== pendingId);
pendingId = null;
}
function draftToast(kind) { drafts += 1; showToast(kind + ' draft saved to Drafts', ''); }
</script>
<div style="position: relative; display: flex; block-size: clamp(540px, 78vh, 820px);">
<md-navigation-rail label="Mailboxes" active-index={folderIndex}
on:mdTabChange={(e) => setFolder(e.detail.value)}>
{#each folders as f (f.value)}
<md-navigation-rail-tab icon={f.icon} label={f.label} value={f.value}
badge-value={f.value === 'inbox' ? (unread ? String(unread) : '') : f.value === 'drafts' ? String(drafts) : ''}></md-navigation-rail-tab>
{/each}
</md-navigation-rail>
<div style="flex: 1; display: flex; flex-direction: column;">
<md-search bind:this={search} layout="docked" trigger="bar" full-width debounce="200"
placeholder="Search mail" input-aria-label="Search mail"
on:mdSearch={(e) => (query = e.detail.value)} on:mdClose={() => (query = '')}>
<md-list slot="results" label="Search results">
{#each hits as m (m.id)}
<md-list-item type="button" leading-icon="mail" headline={m.subject}
supporting-text={m.sender} on:mdClick={() => pickHit(m.id)}></md-list-item>
{/each}
</md-list>
</md-search>
{#if folder === 'inbox'}
<md-list selection-mode="single-select" label="Inbox conversations"
style="flex: 1; overflow-y: auto;"
on:mdSelect={(e) => openMessage(visible[e.detail.index].id)}>
{#each visible as m (m.id)}
<md-list-item leading-avatar-name={m.sender} headline={m.sender}
supporting-text={m.subject} lines="2" selected={m.id === openId}>
<span slot="trailing" style="display: flex; align-items: center; gap: 8px;">
{m.clock}
{#if m.unread}
<md-status-dot label="Unread" style="--md-status-dot-color: var(--md-sys-color-primary);" inline></md-status-dot>
{/if}
</span>
</md-list-item>
{/each}
</md-list>
{:else}
<p style="margin: auto;">{folders[folderIndex].label} is empty in this demo.</p>
{/if}
</div>
<md-side-sheet variant="standard" side="end" top-divider bottom-divider
open={openMsg !== null} headline={openMsg ? openMsg.subject : ''}>
{#if openMsg}
<div style="display: grid; gap: 12px;">
<md-avatar name={openMsg.sender}></md-avatar>
<span>{openMsg.sender} · {openMsg.time}</span>
<p style="margin: 0;">{openMsg.body}</p>
</div>
{/if}
<md-split-button slot="actions" id="reply-split" icon="reply" label="Reply"
menu-label="More reply options" controls="reply-menu" trailing-checked={menuOpen}
on:mdLeadingClick={() => draftToast('Reply')}
on:mdTrailingClick={(e) => (menuOpen = e.detail.checked)}></md-split-button>
<md-button slot="actions" variant="text" on:mdClick={archive}>Archive</md-button>
</md-side-sheet>
<md-menu id="reply-menu" anchor="reply-split" placement="top-end"
open={menuOpen} on:mdClose={() => (menuOpen = false)}>
<md-menu-item headline="Reply all" on:mdClick={() => draftToast('Reply all')}></md-menu-item>
<md-menu-item headline="Forward" on:mdClick={() => draftToast('Forward')}></md-menu-item>
</md-menu>
<md-snackbar bind:this={bar} message={toastMsg} action={toastAction}
on:mdAction={() => bar.hide('action')} on:mdClose={(e) => settle(e.detail.reason)}></md-snackbar>
</div>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-navigation-rail | The folder column. mdTabChange carries a stable value for routing; each md-navigation-rail-tab’s badge-value renders the unread capsule, announced as a role="status" with a “new notifications” label. |
md-search | Docked bar above the list. debounce="200" gates mdSearch, which arrives trimmed and de-duplicated — the demo renders hits into the results slot and never double-fetches. |
md-list + md-list-item | The message list, selection-mode="single-select" so the open conversation stays highlighted. leading-avatar-name renders the internal md-avatar with hashed, stable initials colors. |
md-status-dot | The unread pip in each row’s trailing cluster, tinted with --md-status-dot-color and carrying label="Unread" so the state exists as words, not only color. |
md-side-sheet | The reading pane. variant="standard" is a non-modal role="region" beside the list — no scrim, no focus trap — and occupies zero width while closed. |
md-split-button + md-menu | Reply as the default action; Reply all and Forward as md-menu-item variations. The component renders the button — the menu is ours to open, close, and keep in sync. |
md-snackbar | Archive feedback with Undo. The row is only removed for real when the snackbar closes with a reason other than action. |
The details that make it production-grade
Section titled “The details that make it production-grade”- The shell owns its height. A three-zone layout needs a definite block
size for the list and reading pane to scroll independently, so the chassis
sets
clamp(540px, 78vh, 820px)— a floor for short windows, a ceiling so it stops growing on tall monitors. A full-page mail client would use100dvhinstead; the clamp is what suits an embedded panel like this one. - Undo is trustworthy. The snackbar’s action button does not dismiss it —
the handler calls
hide('action'), and themdCloselistener branches ondetail.reason:actionrestores the row, anything else commits the archive. The auto-hide timer also pauses while the surface is hovered or focused (WCAG 2.2.1), so slow readers do not lose the Undo window. - The reading pane is deliberately non-modal. A standard side sheet is
role="region"with no scrim and no focus trap, so the rail, search, and list stay fully interactive while a message is open — and a closed standard sheet isdisplay: none, returning its width to the list. - Search keyboard flow comes for free. Inside
md-search, the slotted resultsmd-liststops handling arrow keys and the search surface takes over: ArrowDown from the input lands on the first hit, ArrowUp from the first hit returns to the input, and a polite live region announces the result count. - Unread state is never color-only. The dot’s
label="Unread"gives itrole="img", and the rail tab’sbadge-valueis exposed as a status with a spoken count — so the three signals (dot, bold count, badge) all reach assistive tech. - The split button’s chevron cannot lie.
trailing-checkedflips on every toggle press, and the demo listens for the menu’s own non-bubblingmdCloseto reset it when the menu is dismissed by outside click or Escape.
Variations
Section titled “Variations”- Modal reading pane on narrow layouts: switch the sheet to
variant="modal"— it gains a scrim, a focus trap, Escape handling, and automatic focus restore to the row that opened it. - Collapsible rail: add
expandabletomd-navigation-railfor the built-in toggle between the 80px icon rail and the labelled 220px drawer. - More folders than fit: set
max-visible="4"on the rail — extra destinations collapse into an overflow menu and stay first-class selection targets. - Bulk triage: change the list to
selection-mode="multi-select"withinteraction-mode="multi-action", and move Archive into a toolbar acting ongetSelectedIndices().
Related
Section titled “Related”- Recipe: Two-factor verification — the same demo-first pattern applied to an auth flow.
- Component manuals:
md-side-sheet— the standard vs. modal accessibility contracts;md-search— the full query-event model (mdInputvs.mdSearchvs.mdSubmit).