AvatarStack
Overlapping avatars with names in tooltips, links to each person, and a +N that names the rest.
Demo
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/avatar_stack.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/avatar_stack.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/avatar_stack.qwe
let avatar_stack_demo_team = [ { name: "Ada Lovelace", href: "#avatar_stack" }, { name: "Grace Hopper", src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%23c9b8f5'/%3E%3Ccircle cx='20' cy='16' r='7' fill='%236e56cf'/%3E%3Cpath d='M6 40c2-9 8-13 14-13s12 4 14 13z' fill='%236e56cf'/%3E%3C/svg%3E" }, { name: "Alan Turing" }, { name: "Katherine Johnson" }, { name: "Linus Torvalds" }, { name: "Margaret Hamilton" }, { name: "Dennis Ritchie" },]view AvatarStackDemo() = <div.demo-stack> <div.demo-row style="gap: 28px"> <AvatarStack people={avatar_stack_demo_team} label="Project members"/> <AvatarStack people={take(avatar_stack_demo_team, 3)} size="sm" label="Reviewers"/> <AvatarStack people={take(avatar_stack_demo_team, 5)} max={3} total={128} size="xs" label="Viewing now"/> </div> <div.demo-row style="gap: 28px"> <AvatarStack people={avatar_stack_demo_team} max={5} size="lg" shape="square" label="Owners"/> <AvatarStack people={drop(avatar_stack_demo_team, 2)} max={2} size="xl" tooltips={false}/> </div> </div>Source
ui/avatar_stack.qwe
// AvatarStack: a row of overlapping avatars, each naming its person in a tooltip,// and a "+3" for the rest that lists them: who is on a team, in a thread, viewing.// It draws avatars as Avatar does (the same initials and colour for a name), and// needs no other file than the theme.//// <AvatarStack people={team} max={4} label="Project members"/>// <AvatarStack people={viewers} total={128} size="sm"/> "+124"// <AvatarStack people={[{ name: "Ada Lovelace", href: "/u/ada" }, { name: "Alan Turing", src: "/a.jpg" }]}/>//// people: a list of { name, src?, href? }: src a picture (the initials show while it// loads, or if it fails), href makes the avatar a link to them. max: how many to// show (4); past it the rest are counted in a "+N", whose tooltip names them (the// first eight). total: how many there are in all, when people is only some of them.// size: "xs" (24px), "sm" (32px), "md" (40px, default), "lg" (56px) or "xl" (72px).// shape: "circle" (default) or "square". tooltips: names on hover and focus (true).// label: the group's name for screen readers.import ui/theme.qwe use ui_clslet avatar_stack_css = css`.ui-avs { display: inline-flex; align-items: center; vertical-align: middle; --s: 40px; }.ui-avs-xs { --s: 24px; } .ui-avs-sm { --s: 32px; } .ui-avs-lg { --s: 56px; } .ui-avs-xl { --s: 72px; }.ui-avs-item { position: relative; display: inline-flex; border-radius: 50%; }.ui-avs-item + .ui-avs-item { margin-left: calc(var(--s) * -.28); }.ui-avs-item:hover, .ui-avs-item:focus-within { z-index: 2; }.ui-avs-av { position: relative; display: inline-grid; place-items: center; width: var(--s); height: var(--s); overflow: hidden; border-radius: 50%; box-shadow: 0 0 0 2px var(--ui-bg); --h: #6e56cf; background: color-mix(in srgb, var(--h) 24%, var(--ui-bg)); color: color-mix(in srgb, var(--h) 70%, var(--ui-fg)); font: 600 calc(var(--s) * .38)/1 var(--ui-font); letter-spacing: .02em; text-decoration: none; user-select: none; transition: transform .12s ease;}.ui-avs-square .ui-avs-item, .ui-avs-square .ui-avs-av { border-radius: calc(var(--s) * .22); }a.ui-avs-av:hover { transform: translateY(-2px); }a.ui-avs-av:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ui-bg), 0 0 0 4px var(--ui-accent); }.ui-avs-av img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; color: transparent; font-size: 0; }.ui-avs-av img::after { content: attr(data-initials); position: absolute; inset: 0; display: grid; place-items: center; background: color-mix(in srgb, var(--h) 24%, var(--ui-bg)); color: color-mix(in srgb, var(--h) 70%, var(--ui-fg)); font: 600 calc(var(--s) * .38)/1 var(--ui-font); }.ui-avs-c0 { --h: #e5484d; } .ui-avs-c1 { --h: #f76b15; } .ui-avs-c2 { --h: #ad7f00; }.ui-avs-c3 { --h: #30a46c; } .ui-avs-c4 { --h: #12a594; } .ui-avs-c5 { --h: #0090ff; }.ui-avs-c6 { --h: #6e56cf; } .ui-avs-c7 { --h: #d6409f; }.ui-avs-more { background: var(--ui-subtle); color: var(--ui-muted); font-size: calc(var(--s) * .32); }.ui-avs-more[tabindex]:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ui-bg), 0 0 0 4px var(--ui-accent); }.ui-avs-tip { position: absolute; z-index: 50; bottom: calc(100% + 8px); left: 50%; display: none; width: max-content; max-width: min(220px, 70vw); padding: 5px 8px; border-radius: var(--ui-radius-sm); background: var(--ui-fg); color: var(--ui-bg); box-shadow: var(--ui-shadow); font: 500 12px/1.4 var(--ui-font); text-align: center; transform: translateX(-50%); pointer-events: none; }.ui-avs-tip::after { content: ""; position: absolute; top: 100%; left: 50%; margin: -4px 0 0 -4px; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }.ui-avs-item:hover > .ui-avs-tip, .ui-avs-item:focus-within > .ui-avs-tip { display: block; }@media (hover: none) { .ui-avs-item:hover:not(:focus-within) > .ui-avs-tip { display: none; } }@media (prefers-reduced-motion: reduce) { .ui-avs-av { transition: none; } }`// The initials of a name: "Ada Lovelace" is "AL", "Plato" is "PL", "" is "?".fn avatar_stack_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]))}// One of eight colours, from the name's letters: the same one Avatar gives it.fn avatar_stack_hue(name: str) -> int { let chars = "abcdefghijklmnopqrstuvwxyz0123456789" let codes = split(lower(name), "") | map((c) => (index_of(chars, c) ?? 36) + 1) floor(reduce(codes, 5381, (h, c) => (h * 33 + c) % 1000003) / 13) % 8}view AvatarStack(people: list, max: int = 4, total: int? = null, size: str = "md", shape: str = "circle", tooltips: bool = true, label: str? = null, class: str? = null) { style global avatar_stack_css let shown = len(people) > max ? take(people, max) : people let hidden = drop(people, len(shown)) let count_all = total != null && total > len(people) ? total : len(people) let rest = count_all - len(shown) let names = hidden | map((p) => p.name ?? "") | filter((n) => n != "") let listed = take(names, 8) let others = rest - len(listed) let more = len(listed) == 0 ? `${rest} more` : join(listed, ", ") + (others > 0 ? ` and ${others} more` : "") <span class={ui_cls(["ui-avs", size != "md" && `ui-avs-${size}`, shape == "square" && "ui-avs-square", class])} role="group" aria-label={label}> {for p in shown => { let name = p.name ?? "" let face = [<span aria-hidden="true">{avatar_stack_initials(name)}</span>, p.src != null && <img src={p.src} alt="" loading="lazy" data-initials={avatar_stack_initials(name)}/>] let cls = `ui-avs-av ui-avs-c${avatar_stack_hue(name)}` <span.ui-avs-item> {p.href != null ? <a class={cls} href={p.href} aria-label={name}>{face}</a> : <span class={cls} role="img" aria-label={name}>{face}</span>} {tooltips && name != "" && <span.ui-avs-tip aria-hidden="true">{name}</span>} </span> }} {rest > 0 && <span.ui-avs-item> <span class="ui-avs-av ui-avs-more" role="img" aria-label={`and ${more}`} tabindex={tooltips && len(listed) > 0 ? "0" : null}>+{rest}</span> {tooltips && len(listed) > 0 && <span.ui-avs-tip aria-hidden="true">{more}</span>} </span>} </span>}Related
AvatarA picture, or initials on a colour of the person's own, with a status dot; and overlapping groups with +N.6 views · 5 downloadsLeaderboardPeople ranked by score with medals for the top three, shared places for ties, and your own row kept in view.7 views · 7 downloadsOrgChartA tree of people as boxes joined by lines, drawn with CSS on nested lists, with pictures, titles, notes and the reader's place marked.7 views · 5 downloadsAvatarUploadA round picture with Change and Remove, showing the picture picked before it is sent, on a real file input.6 views · 6 downloadsDirectoryNavA long list of names grouped under sticky letter headers, with a row of letters at the top that jumps to each group.5 views · 5 downloadsActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloads
Thread
Thread 0 posts
On AvatarStack: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.