Data

ActivityFeed

A stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.

#data#list#social#time#no-js

Demo

Preview
  1. Today
  2. Ada Lovelace commented on Invoice #1041

    Sent to accounts; they pay on the 30th.

  3. Grace Hopper merged pull request #212

  4. Alan Turing deleted draft-q3.pdf

  5. Yesterday
  6. Katherine Johnson created the project Moon shot

  7. Hedy Lamarr starred quillo/components

  1. CI deployed v1.8.2

  2. Mika uploaded build.tar.gz

  3. Sol tagged v1.8.2

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/activity_feed.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/activity_feed.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/activity_feed.qwe
let activity_feed_demo_items = [    { day: "Today", who: "Ada Lovelace", action: "commented on", target: "Invoice #1041", href: "#inv-1041", icon: "comment",      time: "2 h ago", datetime: "2026-10-28T09:12:00Z", body: "Sent to accounts; they pay on the 30th." },    { day: "Today", who: "Grace Hopper", action: "merged", target: "pull request #212", href: "#pr-212", icon: "merge", time: "5 h ago" },    { day: "Today", who: "Alan Turing", action: "deleted", target: "draft-q3.pdf", icon: "delete", time: "6 h ago" },    { day: "Yesterday", who: "Katherine Johnson", action: "created the project", target: "Moon shot", icon: "create", time: "Oct 27, 16:40" },    { day: "Yesterday", who: "Hedy Lamarr", action: "starred", target: "quillo/components", icon: "star", time: "Oct 27, 11:02" },]view ActivityFeedDemo() =    <div.demo-row style="align-items: flex-start; gap: 32px">        <div style="flex: 1 1 320px; max-width: 460px">            <ActivityFeed id="activity-feed-demo-main" label="Recent activity" items={activity_feed_demo_items}/>        </div>        <div style="flex: 1 1 240px; max-width: 320px">            <ActivityFeed id="activity-feed-demo-compact" label="Deploys" compact={true} items={[                { who: "CI", action: "deployed", target: "v1.8.2", icon: "check", time: "09:40" },                { who: "Mika", action: "uploaded", target: "build.tar.gz", icon: "upload", time: "09:31" },                { who: "Sol", action: "tagged", target: "v1.8.2", icon: "tag", tone: "accent", time: "09:30" },            ]}/>        </div>    </div>

Source

ui/activity_feed.qwe
// ActivityFeed: what people did, newest first: "Ada commented on Invoice #1041",// each with their picture or initials, a small icon for the kind of action, when,// and an optional quote of what they wrote. Lines with a day split the feed.////   <ActivityFeed label="Recent activity" items={[//       { who: "Ada Lovelace", action: "commented on", target: "Invoice #1041", href: "/invoices/1041",//         icon: "comment", time: "2 h ago", datetime: "2026-10-28T09:12:00Z", body: "Sent to accounts." },//       { who: "Grace Hopper", action: "merged", target: "#212", icon: "merge", tone: "accent", time: "5 h ago" },//   ]}/>//// items: maps of { who, action, target?, href?, icon?, tone?, time?, datetime?,// body?, avatar?, day? }: who did it, action what (a verb phrase), target to what// (a link with href). icon: "comment", "edit", "create", "delete", "upload",// "merge", "commit", "star", "check", "user", "mail", "tag" (or none). tone: the// icon's colour, "accent", "success", "warning", "danger", "info" or "neutral"// (default: from the icon: create and check are success, delete danger). time: when,// as it should read; datetime: the same in ISO form for <time>. body: a quote under// it. avatar: a picture's URL (else initials). day: a heading before the first item// of each day ("Today", "Yesterday", "Mar 12"). compact: one line each, no// pictures. label: the feed's name for screen readers. id: the element's id.import ui/theme.qwe use ui_clslet activity_feed_css = css`.ui-activity { margin: 0; padding: 0; list-style: none; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-activity-day { margin: 14px 0 6px; color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }.ui-activity-day:first-child { margin-top: 0; }.ui-activity-item { position: relative; display: grid; grid-template-columns: 36px 1fr; gap: 12px; padding: 0 0 18px; }.ui-activity-item::before { content: ""; position: absolute; left: 17px; top: 40px; bottom: 4px; width: 2px; border-radius: 1px; background: var(--ui-border); }.ui-activity-item:last-child::before, .ui-activity-item:has(+ .ui-activity-day)::before { display: none; }.ui-activity-pic { position: relative; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%;  background: var(--ui-subtle); color: var(--ui-muted); font-size: 13px; font-weight: 600; }.ui-activity-pic img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }.ui-activity-icon { position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 18px; height: 18px;  border-radius: 50%; border: 2px solid var(--ui-bg); background: var(--t); color: var(--ui-bg); --t: var(--ui-muted); }.ui-activity-icon svg { width: 10px; height: 10px; }.ui-activity-accent { --t: var(--ui-accent); }.ui-activity-success { --t: var(--ui-success); }.ui-activity-warning { --t: var(--ui-warning); }.ui-activity-danger { --t: var(--ui-danger); }.ui-activity-info { --t: var(--ui-info); }.ui-activity-text { min-width: 0; padding-top: 7px; }.ui-activity-line { margin: 0; color: var(--ui-muted); }.ui-activity-who { color: var(--ui-fg); font-weight: 600; }.ui-activity-target { color: var(--ui-fg); font-weight: 500; }a.ui-activity-target { color: var(--ui-accent); text-decoration: none; }a.ui-activity-target:hover { text-decoration: underline; }a.ui-activity-target:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: 3px; }.ui-activity-time { margin-left: 6px; color: var(--ui-muted); font-size: 12px; white-space: nowrap; }.ui-activity-body { margin: 6px 0 0; padding: 8px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-subtle); color: var(--ui-fg); }.ui-activity-compact .ui-activity-item { grid-template-columns: 20px 1fr; gap: 8px; padding-bottom: 10px; }.ui-activity-compact .ui-activity-item::before { left: 9px; top: 24px; }.ui-activity-compact .ui-activity-pic { width: 20px; height: 20px; background: none; }.ui-activity-compact .ui-activity-icon { position: static; width: 20px; height: 20px; border: 0; }.ui-activity-compact .ui-activity-text { padding-top: 0; }`fn activity_feed_initials(name: str) -> str {    let words = split(trim(name), " ") | filter((w) => w != "")    upper(len(words) == 0 ? "?" : (len(words) == 1 ? slice(words[0], 0, 2) : words[0][0] + words[-1][0]))}// An icon's SVG path, in a 12 by 12 box.fn activity_feed_path(icon: str) -> str = match icon {    "comment" => "M2 2.5h8v5.5H5l-2.5 2V8H2z"    "edit" => "M2 10l.5-2.5L8 2l2 2-5.5 5.5zM7 3l2 2"    "create" => "M6 2v8M2 6h8"    "delete" => "M2.5 3.5h7M5 3.5V2h2v1.5M3.5 3.5l.5 6.5h4l.5-6.5"    "upload" => "M6 9V2.5M3 5.5l3-3 3 3M2 10h8"    "merge" => "M3.5 2v8M3.5 4.5c0 2.5 5 1.5 5 4.5M8.5 9a1 1 0 100 .1"    "commit" => "M1 6h3M8 6h3M6 4a2 2 0 100 4 2 2 0 100-4"    "star" => "M6 1.8l1.3 2.7 3 .4-2.2 2.1.5 3L6 8.6 3.4 10l.5-3L1.7 4.9l3-.4z"    "check" => "M2.5 6.2l2.3 2.3 4.7-5"    "user" => "M6 6a2 2 0 100-4 2 2 0 100 4zM2.5 10.5c.5-2 2-3 3.5-3s3 1 3.5 3"    "mail" => "M1.8 3h8.4v6H1.8zM2 3.3l4 3 4-3"    "tag" => "M2 2h4l4.5 4.5-4 4L2 6zM4.2 4.2h.1"    _ => ""}fn activity_feed_tone(icon: str?) -> str = match icon {    "create" => "success"    "check" => "success"    "delete" => "danger"    "merge" => "accent"    "comment" => "info"    "star" => "warning"    _ => "neutral"}view ActivityFeed(items: list, compact: bool = false, label: str? = null, id: str? = null, class: str? = null) {    style global activity_feed_css    <ol class={ui_cls(["ui-activity", compact && "ui-activity-compact", class])} id={id} aria-label={label}>        {for [i, it] in enumerate(items) => {            let tone = it.tone ?? activity_feed_tone(it.icon)            let path = it.icon != null ? activity_feed_path(it.icon) : ""            [                it.day != null && (i == 0 || items[i - 1].day != it.day) && <li.ui-activity-day>{it.day}</li>,                <li.ui-activity-item data-icon={it.icon}>                    <span.ui-activity-pic aria-hidden="true">                        {!compact && activity_feed_initials(it.who ?? "")}                        {!compact && it.avatar != null && <img src={it.avatar} alt="" loading="lazy"/>}                        {(path != "" || compact) && <span class={`ui-activity-icon ui-activity-${tone}`}>                            <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">                                <path d={path != "" ? path : "M6 5.5h.1M6 6.5h.1"}/>                            </svg>                        </span>}                    </span>                    <div.ui-activity-text>                        <p.ui-activity-line>                            <span.ui-activity-who>{it.who}</span>                            {" "}{it.action}                            {it.target != null && [" ", it.href != null ? <a.ui-activity-target href={it.href}>{it.target}</a> : <span.ui-activity-target>{it.target}</span>]}                            {it.time != null && [" ", <time.ui-activity-time datetime={it.datetime}>{it.time}</time>]}                        </p>                        {it.body != null && !compact && <p.ui-activity-body>{it.body}</p>}                    </div>                </li>,            ]        }}    </ol>}
Thread
57Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.