Overlays

NotificationCenter

A bell with the unread count, opening a panel of notifications to filter and mark read, one by one or all at once.

#notification#popover#overlay#interactive

Demo

Preview
Nothing read yet.
Nothing unread: no count on the bell.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/notification_center.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/notification_center.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/notification_center.qwe
component NotificationCenterDemoLive() {    state did = "Nothing read yet."    <div.demo-row>        <NotificationCenter id="nc-demo" all_href="#notification_center" items={[            { id: 1, title: "Ana commented on Invoice #1042", text: "Looks good to me, sending it today.", time: "2 min ago" },            { id: 2, title: "Payment received", text: "€1,240.00 from Northwind.", time: "1 h ago", href: "#notification_center" },            { id: 3, title: "Ben invited you to Design", time: "Yesterday" },            { id: 4, title: "Your export is ready", time: "2 days ago", read: true },        ]} on_read={(i) => { did = `Read ${i}.` }} on_read_all={() => { did = "Read all." }}/>        <span.demo-note>{did}</span>    </div>}view NotificationCenterDemo() =    <div.demo-stack>        <NotificationCenterDemoLive/>        <div.demo-row>            <NotificationCenter id="nc-demo-empty" align="start" items={[{ id: 1, title: "Welcome aboard", time: "Last week", read: true }]}/>            <span.demo-note>Nothing unread: no count on the bell.</span>        </div>    </div>

Source

ui/notification_center.qwe
// NotificationCenter: a bell with the number of unread notifications on it, opening// a panel of them, newest first: unread ones marked with a dot, All and Unread to// filter, "Mark all as read", and each one marked read when opened. It is the// browser's `popover`, so Escape or a click elsewhere closes it; where the browser// can anchor it (CSS anchor positioning) it opens under the bell, else mid-screen.////   <NotificationCenter id="inbox" all_href="/notifications" items={[//       { id: 1, title: "Ana commented on Invoice #1042", text: "Looks good to me.", time: "2 min ago", href: "/invoices/1042" },//       { id: 2, title: "Your export is ready", time: "1 h ago", read: true },//   ]} on_read={(id) => { mark_read(id) }} on_read_all={() => { mark_all_read() }}/>//// id (required): the panel's id, unique on the page. items: a list of { id, title,// text?, time? (as you want it shown), read?: true, href? }. An item with an href is// a link (opening it marks it read); one without is a button that marks it read.// label ("Notifications"): the bell's name and the panel's heading. empty ("You're all// caught up."): what the panel says when there is nothing to show. all_href: a "See// all" link at the bottom. align: "end" (default: the panel's right edge under the// bell's) or "start".// on_read(id): called when one is marked read; on_read_all(): when all are, inside// one of your components.import ui/theme.qwe use ui_clslet notification_center_css = css`.ui-notification-center-bell {  position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; padding: 0; border: 1px solid transparent;  border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); cursor: pointer; vertical-align: middle;}.ui-notification-center-bell:hover { background: var(--ui-subtle); }.ui-notification-center-bell:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-notification-center-bell svg { width: 20px; height: 20px; }.ui-notification-center-badge {  position: absolute; top: 3px; right: 2px; min-width: 18px; height: 18px; padding: 0 5px; border: 2px solid var(--ui-bg);  border-radius: 999px; background: var(--ui-danger); color: #fff; font: 700 10px/14px var(--ui-font); text-align: center; box-sizing: border-box;}.ui-notification-center {  box-sizing: border-box; width: min(380px, calc(100vw - 16px)); max-height: min(520px, calc(100dvh - 16px)); margin: auto; padding: 0;  overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface);  color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 14px/1.45 var(--ui-font);}.ui-notification-center:popover-open { display: flex; flex-direction: column; animation: ui-notification-center-in .12s ease-out; }@keyframes ui-notification-center-in { from { opacity: 0; transform: translateY(-4px); } }@supports (position-area: bottom) {  .ui-notification-center { inset: auto; margin: 6px 0; position-area: bottom span-left; position-try-fallbacks: flip-block, flip-inline; }  .ui-notification-center-start { position-area: bottom span-right; }}@supports not (position-area: bottom) { .ui-notification-center::backdrop { background: var(--ui-overlay); } }.ui-notification-center-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: none; padding: 12px 14px 8px; }.ui-notification-center-head h2 { margin: 0; font-size: 15px; font-weight: 600; }.ui-notification-center-link {  padding: 2px 4px; border: 0; border-radius: 4px; background: none; color: var(--ui-accent); font: 500 13px/1.3 var(--ui-font);  text-decoration: none; cursor: pointer;}.ui-notification-center-link:hover { text-decoration: underline; }.ui-notification-center-link:disabled { color: var(--ui-muted); text-decoration: none; cursor: default; }.ui-notification-center-filter { display: flex; gap: 4px; flex: none; padding: 0 14px 8px; border-bottom: 1px solid var(--ui-border); }.ui-notification-center-tab {  height: 28px; padding: 0 10px; border: 0; border-radius: 999px; background: transparent; color: var(--ui-muted);  font: 500 13px/1 var(--ui-font); cursor: pointer;}.ui-notification-center-tab:hover { color: var(--ui-fg); background: var(--ui-subtle); }.ui-notification-center-tab[aria-pressed="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-notification-center-list { flex: 1; min-height: 0; margin: 0; padding: 4px; overflow: auto; list-style: none; overscroll-behavior: contain; }.ui-notification-center-item {  position: relative; display: grid; gap: 2px; width: 100%; padding: 10px 12px 10px 26px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none; cursor: pointer;}.ui-notification-center-item:hover { background: var(--ui-subtle); }.ui-notification-center-unread { background: color-mix(in srgb, var(--ui-accent-soft) 55%, transparent); }.ui-notification-center-unread::before { content: ""; position: absolute; left: 10px; top: 16px; width: 8px; height: 8px; border-radius: 50%; background: var(--ui-accent); }.ui-notification-center-title { font-weight: 500; }.ui-notification-center-read .ui-notification-center-title { font-weight: 400; color: var(--ui-muted); }.ui-notification-center-text { color: var(--ui-muted); font-size: 13px; }.ui-notification-center-time { color: var(--ui-muted); font-size: 12px; }.ui-notification-center-empty { margin: 0; padding: 32px 16px; color: var(--ui-muted); text-align: center; }.ui-notification-center-foot { flex: none; padding: 8px 14px; border-top: 1px solid var(--ui-border); text-align: center; }.ui-notification-center-tab:focus-visible, .ui-notification-center-item:focus-visible, .ui-notification-center-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (prefers-reduced-motion: reduce) { .ui-notification-center:popover-open { animation: none; } }`component NotificationCenter(id: str, items: list = [], label: str = "Notifications", empty: str = "You're all caught up.",                             all_href: str? = null, align: str = "end", on_read = null, on_read_all = null, class: str? = null) {    style global notification_center_css    state rows = items | map((it) => { ...it, read: get(it, "read") == true })    state only_unread = false    let unread = len(rows | filter((r) => !r.read))    let shown = only_unread ? rows | filter((r) => !r.read) : rows    let mark = (rid) => {        if any(rows, (r) => r.id == rid && !r.read) {            rows = rows | map((r) => r.id == rid ? { ...r, read: true } : r)            on_read && on_read(rid)        }    }    <span class={class} style={`anchor-name: --${id}`}>        <button.ui-notification-center-bell type="button" popovertarget={id} aria-haspopup="dialog"                aria-label={unread > 0 ? `${label}, ${unread} unread` : label} title={label}>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"/></svg>            {unread > 0 && <span.ui-notification-center-badge aria-hidden="true">{unread > 99 ? "99+" : unread}</span>}        </button>        <div id={id} popover="auto" class={ui_cls(["ui-notification-center", align == "start" && "ui-notification-center-start"])} role="dialog"             aria-labelledby={`${id}-title`} style={`position-anchor: --${id}`}>            <div.ui-notification-center-head>                <h2 id={`${id}-title`}>{label}</h2>                <button.ui-notification-center-link type="button" disabled={unread == 0} @click={() => {                    rows = rows | map((r) => { ...r, read: true })                    on_read_all && on_read_all()                }}>Mark all as read</button>            </div>            <div.ui-notification-center-filter role="group" aria-label="Show">                <button.ui-notification-center-tab type="button" aria-pressed={only_unread ? "false" : "true"} @click={() => { only_unread = false }}>All</button>                <button.ui-notification-center-tab type="button" aria-pressed={only_unread ? "true" : "false"} @click={() => { only_unread = true }}>                    Unread{unread > 0 ? ` (${unread})` : ""}                </button>            </div>            {len(shown) == 0                ? <p.ui-notification-center-empty>{empty}</p>                : <ul.ui-notification-center-list aria-label={label}>                    {for r in shown {                        let cls = ui_cls(["ui-notification-center-item", r.read ? "ui-notification-center-read" : "ui-notification-center-unread"])                        let body = [                            <span.ui-notification-center-title>{r.title}{!r.read && <span.ui-sr>, unread</span>}</span>,                            get(r, "text") != null && <span.ui-notification-center-text>{r.text}</span>,                            get(r, "time") != null && <span.ui-notification-center-time>{r.time}</span>,                        ]                        <li key={r.id}>{get(r, "href") != null                            ? <a class={cls} href={r.href} @click={() => { mark(r.id) }}>{body}</a>                            : <button class={cls} type="button" @click={() => { mark(r.id) }}>{body}</button>}</li>                    }}                  </ul>}            {all_href != null && <div.ui-notification-center-foot><a.ui-notification-center-link href={all_href}>See all</a></div>}        </div>    </span>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

On NotificationCenter: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.