Improvements to a11y and scripts

This commit is contained in:
Bad Manners 2024-08-18 22:52:45 -03:00
parent a335aff2d3
commit d022fab5d6
17 changed files with 384 additions and 214 deletions

View file

@ -53,30 +53,34 @@
<script>
(function () {
if (localStorage.getItem("ageVerified") !== "true") {
document.body.appendChild(
document
.querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
.content.cloneNode(true),
const fragment = document
.querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
.content.cloneNode(true) as DocumentFragment;
const modal = fragment.firstElementChild as HTMLElementTagNameMap["div"];
const controller = new AbortController();
const { signal } = controller;
modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!.addEventListener(
"click",
(e: MouseEvent) => {
e.preventDefault();
location.href = "about:blank";
},
{ signal },
);
modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!.addEventListener(
"click",
(e: MouseEvent) => {
e.preventDefault();
controller.abort();
localStorage.setItem("ageVerified", "true");
document.body.style.overflow = "auto";
modal.remove();
},
{ signal },
);
const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!;
const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!;
function onRejectButtonClick(e: MouseEvent) {
e.preventDefault();
location.href = "about:blank";
}
function onAcceptButtonClick(e: MouseEvent) {
e.preventDefault();
rejectButton.removeEventListener("click", onRejectButtonClick);
acceptButton.removeEventListener("click", onAcceptButtonClick);
localStorage.setItem("ageVerified", "true");
document.body.style.overflow = "auto";
modal.remove();
}
rejectButton.addEventListener("click", onRejectButtonClick);
acceptButton.addEventListener("click", onAcceptButtonClick);
document.body.style.overflow = "hidden";
rejectButton.focus();
document.body.appendChild(fragment);
document.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")?.focus();
}
})();
</script>

View file

@ -0,0 +1,4 @@
---
---
<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>

View file

@ -1,8 +1,8 @@
---
import DarkModeInline from "./AutoDarkMode.astro";
---
---
<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>
<DarkModeInline />
<script>
(function () {
@ -12,6 +12,7 @@
}
document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
button.classList.remove("hidden");
button.style.removeProperty("display");
button.setAttribute("aria-hidden", "false");
button.addEventListener("click", (e) => {
e.preventDefault();

View file

@ -13,55 +13,70 @@ const { link, instance, user, postId } = Astro.props;
---
<section
id="comment-section"
id="comments-section"
class="px-2 font-serif"
aria-describedby="title-comment-section"
aria-describedby="title-comments-section"
data-link={link}
data-instance={instance}
data-user={user}
data-post-id={postId}
>
<h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
<h2 id="title-comments-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Comments
</h2>
<div class="text-stone-800 dark:text-stone-100" id="comments">
<p class="my-1">
<a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>.
</p>
</div>
</section>
<template id="template-button">
<p id="comments-description" class="my-1 text-stone-800 dark:text-stone-100">
<span data-noscript
><a class="u-syndication text-link underline" href={link} target="_blank">View comments on Mastodon</a>.</span
>
<span style={{ display: "none" }} data-no-comments
>No comments yet. <a class="text-link underline" href={link} target="_blank"
>Be the first to join the conversation on Mastodon</a
>.</span
>
<span style={{ display: "none" }} data-comments
>Join the conversation <a class="text-link underline" href={link} target="_blank">by replying on Mastodon</a
>.</span
>
<span style={{ display: "none" }} data-error>Unable to load comments. Please try again later.</span>
</p>
<button
class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900"
style={{ display: "none" }}
class="group mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 text-stone-800 disabled:bg-bm-400 dark:bg-green-800 dark:text-stone-100 dark:disabled:bg-green-900"
id="load-comments-button"
>
<span>Click to load comments</span>
<span class="block underline group-disabled:hidden">Click to load comments</span>
<span class="hidden group-disabled:block">
<svg
style={{ width: "1.25rem", height: "1.25rem", display: "inline" }}
class="-mt-1 mr-1 animate-spin"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-100"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Loading...
</span>
</button>
</template>
<div id="comments" style={{ display: "none" }}></div>
</section>
<template id="template-button-loading">
<svg
style={{ width: "1.25rem", height: "1.25rem", display: "inline" }}
class="-mt-1 mr-1 animate-spin"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-100"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Loading...</span>
<template id="template-comment-emoji">
<picture>
<source media="(prefers-reduced-motion: reduce)" />
<img width={20} class="mx-[1px] inline" />
</picture>
</template>
<template id="template-comment-box">
<div
role="article"
class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 dark:border-stone-600 dark:bg-stone-800"
class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 text-stone-800 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100"
>
<div class="ml-1">
<a
@ -69,18 +84,27 @@ const { link, instance, user, postId } = Astro.props;
class="p-author h-card u-url text-link flex items-center text-lg hover:underline focus:underline"
target="_blank"
>
<img data-avatar class="u-photo mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
<span data-display-name class="p-nickname"></span>
<picture>
<source data-avatar-static media="(prefers-reduced-motion: reduce)" />
<img data-avatar width={40} class="u-photo mr-2 rounded-full border border-stone-400 dark:border-stone-600" />
</picture>
<span data-display-name class="p-nickname" aria-label="Display name"></span>
</a>
<a
data-post-link
class="u-url text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
target="_blank"
>
<time class="dt-published mr-1" data-publish-date aria-label="Publish date"></time>
<time class="dt-published mr-1" data-published-date aria-label="Publish date"></time>
<time style={{ display: "none" }} class="italic" data-edited-date>(edited)</time>
</a>
</div>
<div data-content class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
<div
data-content
class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"
aria-label="Comment body"
>
</div>
<div class="ml-1 flex flex-row pb-2 pt-1">
<div class="flex" aria-label="Favorites">
<span data-favorites></span>
@ -99,25 +123,25 @@ const { link, instance, user, postId } = Astro.props;
</svg>
</div>
</div>
<div data-comment-thread class="-mb-2" aria-hidden="true"></div>
<div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div>
</div>
</template>
<script>
interface Post {
interface MastodonPost {
link: string;
instance: string;
user: string;
postId: string;
}
interface Emoji {
interface CustomEmoji {
shortcode: string;
url: string;
static_url: string;
}
interface Comment {
interface Status {
id: string;
in_reply_to_id: string;
url: string;
@ -132,85 +156,107 @@ const { link, instance, user, postId } = Astro.props;
url: string;
avatar: string;
avatar_static: string;
emojis: Emoji[];
emojis: CustomEmoji[];
};
content: string;
emojis: Emoji[];
emojis: CustomEmoji[];
}
interface ApiResponse {
ancestors: Comment[];
descendants: Comment[];
interface Context {
ancestors: Status[];
descendants: Status[];
}
(function () {
function replaceEmojis(text: string, emojis: Emoji[]) {
return emojis.reduce(
(acc, emoji) =>
acc.replaceAll(
`:${emoji.shortcode}:`,
`<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
),
text,
);
}
async function renderComments(section: Element, post: Post) {
async function renderComments(section: Element, post: MastodonPost) {
const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
try {
const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
if (!response.ok) {
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
}
const data: ApiResponse = await response.json();
const data: Context = await response.json();
const commentsList: HTMLElement[] = [];
const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
"template#template-comment-emoji",
)!;
const emojiMap: Record<string, string> = {};
const replaceEmojis = (text: string, emojis: CustomEmoji[]) =>
emojis.reduce((acc, emoji) => {
let emojiHTML = emojiMap[emoji.url];
if (!emojiHTML) {
const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment;
const emojiStatic = emojiPicture.querySelector("source")!;
emojiStatic.srcset = emoji.static_url;
const emojiImg = emojiPicture.querySelector("img")!;
emojiImg.src = emoji.url;
emojiImg.alt = `:${emoji.shortcode}: emoji`;
emojiHTML = emojiPicture.firstElementChild!.outerHTML;
emojiMap[emoji.url] = emojiHTML;
}
return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML);
}, text);
const commentsList: DocumentFragment[] = [];
const commentMap: Record<string, number> = {};
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
"template#template-comment-box",
)!;
data.descendants.forEach((comment) => {
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
commentBox.id = `comment-${comment.id}`;
const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment;
commentBox.firstElementChild!.id = `comment-${comment.id}`;
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
commentBoxAuthor.href = comment.account.url;
commentBoxAuthor.title = comment.account.acct;
commentBoxAuthor.setAttribute("aria-label", comment.account.acct);
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
avatar.src = comment.account.avatar;
avatar.alt = `Avatar of ${comment.account.username}`;
avatar.alt = `Avatar of @${comment.account.acct}`;
const avatarStatic =
commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!;
avatarStatic.srcset = comment.account.avatar_static;
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis);
displayName.insertAdjacentHTML(
"afterbegin",
replaceEmojis(comment.account.display_name, comment.account.emojis),
);
const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
commentBoxPostLink.href = comment.url;
const publishDate =
commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-publish-date]")!;
publishDate.setAttribute("datetime", comment.created_at);
publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!;
publishDate.dateTime = comment.created_at;
publishDate.insertAdjacentText(
"afterbegin",
new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
);
if (comment.edited_at) {
const edited = document.createElement("time");
edited.className = "dt-updated italic";
edited.setAttribute("datetime", comment.edited_at);
edited.setAttribute("title", comment.edited_at);
edited.innerText = "(edited)";
commentBoxPostLink.appendChild(edited);
const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!;
edited.dateTime = comment.edited_at;
edited.title = comment.edited_at;
edited.classList.remove("hidden");
edited.classList.add("dt-updated");
edited.style.removeProperty("display");
}
const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis));
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
commentBoxFavorites.innerText = comment.favourites_count.toString();
commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString());
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
commentBoxReblogs.innerText = comment.reblogs_count.toString();
commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString());
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
commentMap[comment.id] = commentsList.length;
@ -221,24 +267,36 @@ const { link, instance, user, postId } = Astro.props;
const parentThreadDiv =
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
parentThreadDiv.setAttribute("aria-hidden", "false");
parentThreadDiv.setAttribute("aria-label", "Replies");
parentThreadDiv.appendChild(commentBox);
}
});
if (commentsList.length === 0) {
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">Be the first to join the conversation on Mastodon</a>.</p>`;
if (commentsList.length) {
const fragment = document.createDocumentFragment();
commentsList.forEach((comment) => fragment.appendChild(comment));
commentsDiv.appendChild(fragment);
commentsDescription
.querySelector<HTMLElementTagNameMap["span"]>("span[data-comments]")!
.style.removeProperty("display");
commentsDiv.style.removeProperty("display");
} else {
commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`;
commentsDiv.append(...commentsList);
commentsDescription
.querySelector<HTMLElementTagNameMap["span"]>("span[data-no-comments]")!
.style.removeProperty("display");
}
} catch (e) {
commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
console.error("Fetch Mastodon comments error", e);
commentsDescription
.querySelector<HTMLElementTagNameMap["span"]>("span[data-error]")!
.style.removeProperty("display");
} finally {
loadCommentsButton.style.display = "none";
loadCommentsButton.blur();
commentsDescription.style.removeProperty("display");
}
}
function initCommentSection() {
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section");
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section");
if (!commentSection) {
return;
}
@ -251,21 +309,21 @@ const { link, instance, user, postId } = Astro.props;
if (!post.link || !post.instance || !post.user || !post.postId) {
return;
}
const commentsContainer = commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
commentsContainer.replaceChildren(
document.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!.content.cloneNode(true),
const loadCommentsButton =
commentSection.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
loadCommentsButton.addEventListener(
"click",
(e) => {
e.preventDefault();
loadCommentsButton.disabled = true;
renderComments(commentSection, post as MastodonPost);
},
{ once: true },
);
const loadCommentsButton = commentsContainer.querySelector("button")!;
loadCommentsButton.addEventListener("click", (e) => {
e.preventDefault();
loadCommentsButton.setAttribute("disabled", "true");
loadCommentsButton.replaceChildren(
document
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
.content.cloneNode(true),
);
renderComments(commentSection, post as Post);
});
const commentsDescription = commentSection.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
commentsDescription.style.display = "none";
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-noscript]")!.style.display = "none";
loadCommentsButton.style.removeProperty("display");
}
initCommentSection();