<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitHub explorer · REALM examples</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: start center; padding: 3rem 1rem; box-sizing: border-box; }
main { width: 100%; display: grid; justify-items: center; }
</style>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/unicons@alpha/dist/unicons.html"></import-element>
<import-element from="./github-explorer.html"></import-element>
<main><github-explorer></github-explorer></main>
</body>
</html>
<import-element from="./user-search.html"></import-element>
<import-element from="./user-profile.html"></import-element>
<import-element from="./repo-list.html"></import-element>
<custom-element name="github-explorer">
<script type="module">
import { github } from "./github.js";
</script>
<style>
:host {
display: grid;
gap: calc(var(--spacing) * 6);
width: min(calc(var(--spacing) * 280), 100%);
}
header {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: calc(var(--spacing) * 3);
}
h1 {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 2);
margin: 0;
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
header p {
margin: calc(var(--spacing) * 1) 0 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.rate {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.layout {
display: grid;
grid-template-columns: calc(var(--spacing) * 72) minmax(0, 1fr);
align-items: start;
gap: calc(var(--spacing) * 8);
}
.main {
display: grid;
gap: calc(var(--spacing) * 8);
min-width: 0;
}
@media (max-width: 800px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<header>
<div>
<h1><u-icon name="github"></u-icon> GitHub explorer</h1>
<p>Users and repositories from the public GitHub API, loaded with fetch attributes.</p>
</div>
<p class="rate" role="status"><slot name="#github.rate"></slot></p>
</header>
<div class="layout">
<user-search></user-search>
<div class="main">
<user-profile></user-profile>
<repo-list></repo-list>
</div>
</div>
</custom-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/alert.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/avatar.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-group.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/spinner.html"></import-element>
<custom-element name="user-search">
<script type="module">
import { describeFailure, github, noteRate, selectUser } from "./github.js";
function typed(field) {
github.query = field.value;
}
function starting(event) {
github.searchError = "";
if (github.query.trim()) return;
event.preventDefault();
github.results = null;
}
function found(event) {
noteRate(event.detail.headers);
}
function failed(event) {
github.results = null;
github.searchError = describeFailure(event, github.query);
}
function choose(user) {
selectUser(user.login);
}
</script>
<style>
:host {
display: grid;
align-content: start;
gap: calc(var(--spacing) * 3);
}
.summary {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
ul {
display: grid;
gap: calc(var(--spacing) * 1);
margin: 0;
padding: 0;
list-style: none;
}
button {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 3);
width: 100%;
padding: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: inherit;
font: inherit;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
text-align: left;
cursor: pointer;
}
button:hover {
background: var(--accent);
}
button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
button[aria-current="true"] {
background: var(--secondary);
}
</style>
<search>
<form
fetch="https://api.github.com/search/users"
trigger="input"
debounce="400ms"
timeout="10s"
headers="#github.headers"
params="per_page:#github.perPage"
into="#github.results"
busy="#github.searching"
onloadstart="starting(event)"
onload="found(event)"
onerror="failed(event)"
ontimeout="failed(event)">
<ui-input-group name="q" type="search" aria-label="Search GitHub users" placeholder="Search users…" autocomplete="off" oninput="typed(this)">
<ui-input-group-addon><u-icon name="search"></u-icon></ui-input-group-addon>
<ui-input-group-addon align="inline-end"><is-visible when="#github.searching"><ui-spinner label="Searching"></ui-spinner></is-visible></ui-input-group-addon>
</ui-input-group>
</form>
</search>
<p class="summary" role="status"><slot name="#github.searchSummary"></slot></p>
<is-visible when="#github.searchError">
<ui-alert variant="destructive">
<ui-alert-title>Search failed</ui-alert-title>
<ui-alert-description><slot name="#github.searchError"></slot></ui-alert-description>
</ui-alert>
</is-visible>
<ul aria-label="Users">
<li repeat="#github.users" as="user" key="id">
<button type="button" _aria-current="#user.current" onclick="choose(user)">
<ui-avatar size="sm" aria-hidden="true">
<ui-avatar-image _src="#user.avatar_url"></ui-avatar-image>
<ui-avatar-fallback><slot name="#user.initials"></slot></ui-avatar-fallback>
</ui-avatar>
<slot name="#user.login"></slot>
</button>
</li>
</ul>
</custom-element>
import { defineStore } from "https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js";
const API = "https://api.github.com";
const PER_PAGE = 10;
const count = new Intl.NumberFormat("en", { notation: "compact" });
const date = new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
const time = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "2-digit" });
const plural = (value, word, words = `${word}s`) =>
`${count.format(value)} ${value === 1 ? word : words}`;
export const SORTS = [
{ id: "updated", label: "Recently updated" },
{ id: "created", label: "Newest" },
{ id: "full_name", label: "Name" },
];
const initialLogin = () => new URLSearchParams(location.search).get("user")?.trim() ?? "";
const host = (url) => url.replace(/^https?:\/\//, "").replace(/\/$/, "");
export const github = defineStore("example-github", {
headers: { Accept: "application/vnd.github+json" },
perPage: PER_PAGE,
query: "",
results: null,
searching: false,
searchError: "",
login: initialLogin(),
user: null,
userBusy: false,
userError: "",
repos: [],
reposBusy: false,
reposError: "",
sort: "updated",
page: 1,
remaining: null,
attempt: 0,
get userUrl() {
return `${API}/users/${encodeURIComponent(this.login)}`;
},
get reposUrl() {
return `${this.userUrl}/repos`;
},
get userRequest() {
return [this.login, this.attempt];
},
get reposRequest() {
return [this.sort, this.page, this.attempt];
},
get users() {
return (this.results?.items ?? []).map((item) => ({
...item,
initials: item.login.slice(0, 2).toUpperCase(),
current: item.login.toLowerCase() === this.login.toLowerCase(),
}));
},
get searchSummary() {
if (!this.results) return "";
return this.results.total_count
? `${plural(this.results.total_count, "user")} found`
: "No users match that search.";
},
get loadingProfile() {
return this.userBusy && this.user?.login?.toLowerCase() !== this.login.toLowerCase();
},
get profile() {
const user = this.user;
if (!user || this.loadingProfile || this.userError) return null;
return {
login: user.login,
name: user.name || user.login,
avatar: user.avatar_url,
initials: user.login.slice(0, 2).toUpperCase(),
url: user.html_url,
bio: user.bio ?? "",
company: user.company ?? "",
location: user.location ?? "",
blog: user.blog
? {
href: /^https?:/.test(user.blog) ? user.blog : `https://${user.blog}`,
label: host(user.blog),
}
: null,
followers: plural(user.followers, "follower"),
following: `${count.format(user.following)} following`,
repos: plural(user.public_repos, "public repository", "public repositories"),
joined: `Joined ${date.format(new Date(user.created_at))}`,
};
},
get pages() {
return Math.max(1, Math.ceil((this.user?.public_repos ?? 0) / PER_PAGE));
},
get paged() {
return this.pages > 1;
},
get rows() {
return this.repos.map((repo) => ({
id: repo.id,
name: repo.name,
url: repo.html_url,
description: repo.description ?? "",
language: repo.language ?? "",
stars: plural(repo.stargazers_count, "star"),
forks: plural(repo.forks_count, "fork"),
updated: `Updated ${date.format(new Date(repo.updated_at))}`,
fork: repo.fork,
}));
},
get noRepos() {
return Boolean(this.profile) && !this.reposBusy && !this.reposError && this.repos.length === 0;
},
get rate() {
return this.remaining === null
? ""
: `${plural(this.remaining.left, "request")} left until ${this.remaining.reset}`;
},
});
export const selectUser = (login) => {
github.page = 1;
github.userError = "";
github.reposError = "";
github.login = login;
history.replaceState(null, "", `?user=${encodeURIComponent(login)}`);
};
export const retry = () => {
github.userError = "";
github.reposError = "";
github.attempt++;
};
export const noteRate = (headers) => {
const left = headers?.get("x-ratelimit-remaining");
if (left === null || left === undefined) return;
const reset = new Date(Number(headers.get("x-ratelimit-reset")) * 1000);
github.remaining = { left: Number(left), reset: time.format(reset) };
};
const FAILURES = {
0: () => "GitHub could not be reached. Check your connection and try again.",
403: (detail) => detail.data?.message ?? "GitHub refused the request.",
404: (_, subject) => `There is no GitHub user called “${subject}”.`,
422: (detail) => detail.data?.message ?? "GitHub could not run that search.",
};
// GitHub answers 403 or 429 with x-ratelimit-remaining: 0 when the anonymous limit is used up.
export const describeFailure = (event, subject) => {
if (event.type === "timeout") return "GitHub took too long to answer. Try again in a moment.";
const { detail } = event;
noteRate(detail.headers);
if (detail.headers?.get("x-ratelimit-remaining") === "0")
return `You have used GitHub's limit of requests without signing in. It resets at ${github.remaining.reset}.`;
const failure =
FAILURES[detail.status] ?? (() => `GitHub answered with an error (${detail.status}).`);
return failure(detail, subject);
};
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/alert.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/avatar.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/empty.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/skeleton.html"></import-element>
<custom-element name="user-profile">
<script type="module">
import { describeFailure, github, noteRate, retry } from "./github.js";
function starting(event) {
if (!github.login) event.preventDefault();
}
function loaded(event) {
noteRate(event.detail.headers);
}
function failed(event) {
github.userError = describeFailure(event, github.login);
}
</script>
<style>
:host {
display: block;
}
section {
display: grid;
gap: calc(var(--spacing) * 4);
}
.profile,
.loading {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: calc(var(--spacing) * 5);
}
.profile ui-avatar {
width: calc(var(--spacing) * 20);
height: calc(var(--spacing) * 20);
}
.lines {
display: grid;
gap: calc(var(--spacing) * 2);
}
h2 {
margin: 0;
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
.login {
color: var(--muted-foreground);
text-underline-offset: calc(var(--spacing) * 1);
}
.bio {
margin: 0;
line-height: var(--leading-normal);
}
.facts {
display: flex;
flex-wrap: wrap;
gap: calc(var(--spacing) * 2) calc(var(--spacing) * 4);
margin: 0;
padding: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
list-style: none;
}
.facts li {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1.5);
}
.facts li:has(slot:empty) {
display: none;
}
.facts a {
color: inherit;
}
@media (max-width: 480px) {
.profile,
.loading {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<section
aria-label="Profile"
_fetch="#github.userUrl"
trigger="load #github.userRequest"
timeout="10s"
headers="#github.headers"
into="#github.user"
busy="#github.userBusy"
onloadstart="starting(event)"
onload="loaded(event)"
onerror="failed(event)"
ontimeout="failed(event)">
<is-hidden when="#github.login">
<ui-empty>
<ui-empty-header>
<ui-empty-media variant="icon"><u-icon name="github"></u-icon></ui-empty-media>
<ui-empty-title level="2">Find someone on GitHub</ui-empty-title>
<ui-empty-description>Search for a user to see their profile and public repositories.</ui-empty-description>
</ui-empty-header>
</ui-empty>
</is-hidden>
<is-visible when="#github.loadingProfile">
<div class="loading" role="status" aria-label="Loading profile">
<ui-skeleton shape="circle" width="20" height="20"></ui-skeleton>
<div class="lines">
<ui-skeleton width="56" height="7"></ui-skeleton>
<ui-skeleton width="32" height="4"></ui-skeleton>
<ui-skeleton height="4"></ui-skeleton>
<ui-skeleton width="72" height="4"></ui-skeleton>
</div>
</div>
</is-visible>
<is-visible when="#github.userError">
<ui-alert variant="destructive">
<ui-alert-title>Couldn't load this profile</ui-alert-title>
<ui-alert-description>
<p><slot name="#github.userError"></slot></p>
<ui-button size="sm" variant="outline" onclick="retry()">Try again</ui-button>
</ui-alert-description>
</ui-alert>
</is-visible>
<is-visible when="#github.profile">
<div class="profile">
<ui-avatar size="lg">
<ui-avatar-image _src="#github.profile.avatar" alt=""></ui-avatar-image>
<ui-avatar-fallback><slot name="#github.profile.initials"></slot></ui-avatar-fallback>
</ui-avatar>
<div class="lines">
<h2><slot name="#github.profile.name"></slot></h2>
<a class="login" _href="#github.profile.url"><slot name="#github.profile.login"></slot> on GitHub</a>
<p class="bio"><slot name="#github.profile.bio"></slot></p>
<ul class="facts" aria-label="Details">
<li><u-icon name="users"></u-icon><slot name="#github.profile.followers"></slot> · <slot name="#github.profile.following"></slot></li>
<li><u-icon name="book-marked"></u-icon><slot name="#github.profile.repos"></slot></li>
<li><u-icon name="building"></u-icon><slot name="#github.profile.company"></slot></li>
<li><u-icon name="map-pin"></u-icon><slot name="#github.profile.location"></slot></li>
<li _hidden="!#github.profile.blog"><u-icon name="link"></u-icon><a _href="#github.profile.blog.href"><slot name="#github.profile.blog.label"></slot></a></li>
<li><u-icon name="calendar"></u-icon><slot name="#github.profile.joined"></slot></li>
</ul>
</div>
</div>
</is-visible>
</section>
</custom-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/alert.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/badge.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/pagination.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/select.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/skeleton.html"></import-element>
<custom-element name="repo-list">
<script type="module">
import { describeFailure, github, noteRate, retry, SORTS } from "./github.js";
const options = useState({ sorts: SORTS, placeholders: [1, 2, 3] });
function loaded(event) {
noteRate(event.detail.headers);
}
function failed(event) {
github.reposError = describeFailure(event, github.login);
}
function sortBy(select) {
github.sort = select.value;
github.page = 1;
}
function paginate(event) {
github.page = event.detail.page;
}
</script>
<style>
:host {
display: block;
}
section {
display: grid;
gap: calc(var(--spacing) * 4);
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing) * 3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
ul,
.placeholders {
display: grid;
margin: 0;
padding: 0;
list-style: none;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
li,
.placeholders > div {
display: grid;
gap: calc(var(--spacing) * 2);
padding: calc(var(--spacing) * 4);
}
li + li,
.placeholders > div + div {
border-top: 1px solid var(--border);
}
.name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: calc(var(--spacing) * 2);
}
h3 {
margin: 0;
font-size: var(--text-base);
}
h3 a {
color: var(--primary);
text-underline-offset: calc(var(--spacing) * 1);
}
.description {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.description:has(slot:empty) {
display: none;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: calc(var(--spacing) * 4);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.meta span {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1);
}
.meta span:has(slot:empty) {
display: none;
}
.empty {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
ui-pagination {
justify-self: center;
}
</style>
<is-visible when="#github.profile">
<section
aria-labelledby="repositories"
_fetch="#github.reposUrl"
trigger="load #github.reposRequest"
timeout="10s"
headers="#github.headers"
params="sort:#github.sort, page:#github.page, per_page:#github.perPage"
into="#github.repos"
busy="#github.reposBusy"
onload="loaded(event)"
onerror="failed(event)"
ontimeout="failed(event)">
<div class="toolbar">
<h2 id="repositories">Repositories</h2>
<ui-select aria-label="Sort repositories" width="48" size="sm" _value="#github.sort" onchange="sortBy(this)">
<option repeat="#options.sorts" as="sort" key="id" _value="#sort.id" _label="#sort.label"></option>
</ui-select>
</div>
<is-visible when="#github.reposError">
<ui-alert variant="destructive">
<ui-alert-title>Couldn't load repositories</ui-alert-title>
<ui-alert-description>
<p><slot name="#github.reposError"></slot></p>
<ui-button size="sm" variant="outline" onclick="retry()">Try again</ui-button>
</ui-alert-description>
</ui-alert>
</is-visible>
<is-visible when="#github.reposBusy">
<div class="placeholders" role="status" aria-label="Loading repositories">
<div repeat="#options.placeholders" as="placeholder">
<ui-skeleton width="48" height="5"></ui-skeleton>
<ui-skeleton height="4"></ui-skeleton>
<ui-skeleton width="64" height="3"></ui-skeleton>
</div>
</div>
</is-visible>
<is-hidden when="#github.reposBusy">
<is-visible when="#github.rows.length">
<ul aria-label="Repositories">
<li repeat="#github.rows" as="repo" key="id">
<div class="name">
<h3><a _href="#repo.url"><slot name="#repo.name"></slot></a></h3>
<is-visible when="#repo.fork"><ui-badge variant="outline">Fork</ui-badge></is-visible>
</div>
<p class="description"><slot name="#repo.description"></slot></p>
<div class="meta">
<span><u-icon name="code"></u-icon><slot name="#repo.language"></slot></span>
<span><u-icon name="star"></u-icon><slot name="#repo.stars"></slot></span>
<span><u-icon name="git-fork"></u-icon><slot name="#repo.forks"></slot></span>
<span><slot name="#repo.updated"></slot></span>
</div>
</li>
</ul>
</is-visible>
</is-hidden>
<is-visible when="#github.noRepos">
<p class="empty">No public repositories yet.</p>
</is-visible>
<is-visible when="#github.paged">
<ui-pagination label="Repository pages" _page="#github.page" _total="#github.pages" onchange="paginate(event)"></ui-pagination>
</is-visible>
</section>
</is-visible>
</custom-element>