├── .npmignore
├── demo
├── screenshot.png
└── style.css
├── .gitignore
├── .vscode
├── extensions.json
└── settings.json
├── .editorconfig
├── src
├── index.js
├── likes
│ ├── ui.js
│ └── index.js
├── autoload.js
├── likers
│ ├── ui.js
│ └── index.js
└── api.js
├── .prettierrc.json
├── LICENSE
├── package.json
├── logo.svg
├── index.html
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 | index.html
2 | demo/
3 | .github
4 | .vscode
5 | .editorconfig
6 | .prettierrc.json
7 |
--------------------------------------------------------------------------------
/demo/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LeaVerou/bluesky-likes/HEAD/demo/screenshot.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .DS_Store
3 | Thumbs.db
4 |
5 | # Build artifacts
6 | types/
7 | custom-elements.json
8 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "EditorConfig.EditorConfig",
4 | "esbenp.prettier-vscode"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | charset = utf-8
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 | end_of_line = lf
9 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default as BlueskyLikes } from "./likes/index.js";
2 | export { default as BlueskyLikers } from "./likers/index.js";
3 | export * as bsky from "./api.js";
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "prettier.enable": true,
5 | "debug.enableStatusBarColor": false
6 | }
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "prettier-plugin-brace-style",
4 | "prettier-plugin-space-before-function-paren",
5 | "prettier-plugin-merge"
6 | ],
7 | "braceStyle": "stroustrup",
8 | "arrowParens": "avoid",
9 | "bracketSpacing": true,
10 | "endOfLine": "auto",
11 | "semi": true,
12 | "singleQuote": false,
13 | "tabWidth": 4,
14 | "useTabs": true,
15 | "trailingComma": "all",
16 | "printWidth": 100
17 | }
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Lea Verou
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/likes/ui.js:
--------------------------------------------------------------------------------
1 | let url = import.meta.url;
2 |
3 | export const templates = {
4 | root () {
5 | let defaultIcon = url
6 | ? `
`
7 | : "🦋";
8 | return `
9 |
10 | ${defaultIcon}
11 | 0
12 |
13 | `;
14 | },
15 | };
16 |
17 | export const styles = `
18 | :host {
19 | display: inline-flex;
20 | gap: 0.25em;
21 | align-items: center;
22 | color: #1185fe;
23 | border: max(1px, .07em) solid currentColor;
24 | border-radius: 0.25em;
25 | padding: 0.25em 0.3em;
26 | text-decoration: none;
27 | font-weight: bold;
28 | line-height: 1;
29 | }
30 |
31 | @keyframes loading {
32 | from { opacity: 0.5; }
33 | to { opacity: 0.8; }
34 | }
35 |
36 | :host(:state(loading)) {
37 | animation: loading 1s infinite alternate;
38 | }
39 |
40 | a {
41 | color: inherit;
42 | text-decoration: none;
43 | }
44 |
45 | :host:has(a:hover) {
46 | background: lab(from currentColor l a b / 0.1);
47 | }
48 |
49 | [part=icon] {
50 | block-size: 1em;
51 | vertical-align: -0.16em;
52 | }
53 | `;
54 |
--------------------------------------------------------------------------------
/src/autoload.js:
--------------------------------------------------------------------------------
1 | export const components = ["likes", "likers"];
2 | let observer = null;
3 |
4 | if (globalThis.document) {
5 | discover(document.body);
6 | }
7 |
8 | /** @param {Node} node */
9 | export function observe (node) {
10 | observer ??= new MutationObserver(mutations => {
11 | for (const { addedNodes } of mutations) {
12 | for (const node of addedNodes) {
13 | if (node.nodeType === Node.ELEMENT_NODE) {
14 | discover(node);
15 | }
16 | }
17 | }
18 | });
19 | observer.observe(node, { childList: true, subtree: true });
20 | }
21 |
22 | /** @param {Node} node */
23 | export function unobserve (node) {
24 | observer.unobserve(node);
25 | }
26 |
27 | /** @param {Element} node */
28 | export function discover (node) {
29 | for (let i = 0; i < components.length; i++) {
30 | let component = components[i];
31 | let tag = `bluesky-${component}`;
32 | let isRegistered = Boolean(customElements.get(tag));
33 |
34 | if (isRegistered) {
35 | // Only check components that are not already defined
36 | components.splice(i, 1);
37 | i--;
38 | continue;
39 | }
40 |
41 | if (node.querySelector(tag) || node.matches?.(tag)) {
42 | import(`./${component}/index.js`);
43 | components.splice(i, 1);
44 | i--;
45 | }
46 | }
47 |
48 | if (components.length === 0 && observer) {
49 | // Nothing left to do here
50 | observer.disconnect();
51 | observer = null;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/demo/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-accent: #1185fe;
3 | --color-neutral: oklch(50% 0.01 220);
4 | }
5 |
6 | html {
7 | font:
8 | 1rem/1.5 ui-sans-serif,
9 | system-ui,
10 | sans-serif;
11 |
12 | accent-color: var(--color-accent);
13 | }
14 |
15 | a {
16 | color: var(--color-accent);
17 |
18 | &:not(:hover, :focus) {
19 | text-decoration: none;
20 | }
21 | }
22 |
23 | body {
24 | margin: 0;
25 | padding: 2rem;
26 | }
27 |
28 | header {
29 | display: grid;
30 | grid-template-columns: auto 1fr;
31 | gap: 0.25rem 1rem;
32 | margin-bottom: 2rem;
33 | }
34 |
35 | h1,
36 | h2 {
37 | line-height: 1.15;
38 | text-wrap: balance;
39 | }
40 |
41 | h1,
42 | .logo {
43 | font-size: calc(200% + 1vw);
44 | color: var(--color-accent);
45 | margin: 0;
46 | }
47 |
48 | .logo {
49 | grid-row: 1 / 3;
50 | height: 1.5em;
51 | margin-top: 0.15em;
52 | }
53 |
54 | h2 {
55 | font-size: 200%;
56 | }
57 |
58 | input,
59 | button {
60 | font: inherit;
61 | }
62 |
63 | form {
64 | align-items: end;
65 | }
66 |
67 | form,
68 | label {
69 | display: flex;
70 | gap: 0.5rem;
71 |
72 | label:has(#url),
73 | #url {
74 | flex: 1;
75 | }
76 |
77 | label {
78 | gap: 0;
79 | flex-flow: column;
80 | font-size: 80%;
81 |
82 | input {
83 | font-size: 125%;
84 | }
85 | }
86 | }
87 |
88 | footer {
89 | margin-top: 2rem;
90 | font-size: 70%;
91 | color: var(--color-neutral);
92 | font-weight: 500;
93 | }
94 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bluesky-likes",
3 | "version": "0.0.5",
4 | "description": "Description",
5 | "keywords": [],
6 | "homepage": "https://github.com/leaverou/bluesky-likes/#readme",
7 | "bugs": {
8 | "url": "https://github.com/leaverou/bluesky-likes/issues"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/leaverou/bluesky-likes.git"
13 | },
14 | "license": "MIT",
15 | "author": "Lea Verou",
16 | "contributors": [
17 | "Lea Verou",
18 | "Dmitry Sharabin"
19 | ],
20 | "funding": [
21 | {
22 | "type": "individual",
23 | "url": "https://github.com/sponsors/LeaVerou"
24 | }
25 | ],
26 | "type": "module",
27 | "main": "src/index.js",
28 | "exports": {
29 | ".": {
30 | "import": "./src/index.js",
31 | "types": "./types/index.d.ts"
32 | },
33 | "./likes": {
34 | "import": "./src/likes/index.js",
35 | "types": "./types/likes/index.d.ts"
36 | },
37 | "./likers": {
38 | "import": "./src/likers/index.js",
39 | "types": "./types/likers/index.d.ts"
40 | },
41 | "./api": {
42 | "import": "./src/api.js",
43 | "types": "./types/api.d.ts"
44 | },
45 | "./autoload": {
46 | "import": "./src/autoload.js",
47 | "types": "./types/autoload.d.ts"
48 | }
49 | },
50 | "types": "./types/index.d.ts",
51 | "sideEffects": true,
52 | "scripts": {
53 | "build:types": "tsc --allowJs --emitDeclarationOnly --declaration --outDir types src/index.js src/autoload.js",
54 | "build:cem": "cem analyze --litelement",
55 | "build": "npm run build:types && npm run build:cem",
56 | "prepublishOnly": "npm run build",
57 | "release": "release-it",
58 | "test": "echo \"Error: no test specified\" && exit 1"
59 | },
60 | "devDependencies": {
61 | "@custom-elements-manifest/analyzer": "^0.8.0",
62 | "prettier-plugin-brace-style": "latest",
63 | "prettier-plugin-merge": "latest",
64 | "prettier-plugin-space-before-function-paren": "latest",
65 | "release-it": "latest",
66 | "typescript": "^5.8.3"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
32 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Bluesky Likes Web Components
7 |
8 |
9 |
10 |
21 |
22 |
23 |
35 |
36 |
54 |
55 |
59 | likes on Bluesky
60 |
61 |
62 |
66 |
67 | Other examples
68 |
69 |
70 | Using inside a link suppresses its internal link, allowing you to link to a different
71 | place:
72 |
78 |
79 |
80 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/src/likes/index.js:
--------------------------------------------------------------------------------
1 | import { getPost } from "../api.js";
2 | import { styles, templates } from "./ui.js";
3 |
4 | /**
5 | * @customElement bluesky-likes
6 | * @element bluesky-likes
7 | *
8 | * Displays the number of likes on a post and links to the full list.
9 | *
10 | * @attr {string} src - The URL of the post to display likes for. This is the only required attribute and must be a valid Bluesky post URL.
11 | *
12 | * @prop {BlueskyPost | undefined} post - The Bluesky post data containing information about the post, including its author, content, and engagement metrics
13 | * @prop {number} likes - The number of likes on the post
14 | * @prop {string} likersUrl - The URL to view the post's likers
15 | *
16 | * @slot - Content added after the likes count, typically used for additional information or actions
17 | * @slot prefix - Custom icon to replace the default heart icon. The icon should be an inline SVG or image element.
18 | *
19 | * @csspart count - The element that displays the likes count, formatted according to the user's locale
20 | * @csspart link - The link to view the post's likers, automatically removed if the component is placed inside another link
21 | * @csspart icon - The default heart icon which is displayed if nothing is slotted in the prefix slot
22 | *
23 | * @state loading - Indicates that the component is currently loading data from the Bluesky API
24 | */
25 | export default class BlueskyLikes extends HTMLElement {
26 | static templates = templates;
27 | static styles = styles;
28 | static sheet;
29 |
30 | data = {};
31 | #dom = {};
32 |
33 | constructor () {
34 | super();
35 | this.attachShadow({ mode: "open" });
36 | this._internals = this.attachInternals?.();
37 |
38 | let { templates, styles, sheet } = this.constructor;
39 |
40 | if (this.shadowRoot.adoptedStyleSheets) {
41 | if (!sheet) {
42 | sheet = new CSSStyleSheet();
43 | sheet.replaceSync(styles);
44 | this.constructor.sheet = sheet;
45 | }
46 |
47 | this.shadowRoot.adoptedStyleSheets = [sheet];
48 | }
49 |
50 | this._setShadowHTML(templates.root());
51 |
52 | this.init();
53 | }
54 |
55 | init () {
56 | this.#dom.count = this.shadowRoot.querySelector("[part~=count]");
57 | this.#dom.link = this.shadowRoot.querySelector("[part~=link]");
58 | }
59 |
60 | /**
61 | * Set the shadow root’s innerHTML.
62 | * @protected
63 | * @param {string} html
64 | */
65 | _setShadowHTML (html) {
66 | if (!this.shadowRoot.adoptedStyleSheets && this.constructor.styles) {
67 | html = `\n${html}`;
68 | }
69 |
70 | this.shadowRoot.innerHTML = html;
71 | }
72 |
73 | connectedCallback () {
74 | // Polyfill for https://github.com/whatwg/html/issues/7039
75 | this._currentLang =
76 | this.lang ||
77 | this.parentNode.closest("[lang]")?.lang ||
78 | this.ownerDocument.documentElement.lang ||
79 | "en";
80 |
81 | // Only include a link iff the element is not within one
82 | this._isInLink = this.closest("a[href]") !== null;
83 |
84 | if (this._isInLink && this.#dom.link?.parentNode) {
85 | // Is inside a link, but we also have a link
86 | this.#dom.link.replaceWith(...this.#dom.link.childNodes);
87 | }
88 | else if (!this._isInLink && !this.#dom.link?.parentNode) {
89 | // Is not inside a link, but we don't have a link, (re-)insert it
90 | this._setShadowHTML(templates.root());
91 | this.init();
92 | }
93 |
94 | if (this.src) {
95 | this.render({ useCache: true });
96 | }
97 | }
98 |
99 | /**
100 | * @returns {import("../api.js").BlueskyPost | undefined}
101 | */
102 | get post () {
103 | return this.data.post;
104 | }
105 |
106 | /**
107 | * @returns {number}
108 | */
109 | get likes () {
110 | return this.data.post?.likeCount ?? 0;
111 | }
112 |
113 | get likersUrl () {
114 | return this.src + "/liked-by";
115 | }
116 |
117 | async fetch ({ force } = {}) {
118 | let postUrl = this.src;
119 |
120 | let post = await getPost(postUrl, { force });
121 |
122 | if (!post) {
123 | // Lazy loading?
124 | return;
125 | }
126 |
127 | this.data.post = post;
128 |
129 | return this.data;
130 | }
131 |
132 | async render ({ useCache = false } = {}) {
133 | this._internals.states?.add("loading");
134 |
135 | if (this._isInsideLink) {
136 | // Remove link from the DOM
137 | }
138 | else {
139 | this.#dom.link.href = this.likersUrl;
140 | }
141 |
142 | if (!this.data.post || !useCache) {
143 | await this.fetch({ force: !useCache });
144 | }
145 |
146 | if (this.data.post && this.#dom.count) {
147 | this.#dom.count.value = this.likes;
148 | this.#dom.count.textContent = this.likes.toLocaleString(this._currentLang, {
149 | notation: "compact",
150 | });
151 | }
152 | this._internals.states?.delete("loading");
153 | }
154 |
155 | get src () {
156 | return this.getAttribute("src");
157 | }
158 |
159 | set src (value) {
160 | this.setAttribute("src", value);
161 | }
162 |
163 | static get observedAttributes () {
164 | return ["src"];
165 | }
166 |
167 | attributeChangedCallback (name, oldValue, newValue) {
168 | if (name === "src") {
169 | this.render();
170 | }
171 | }
172 | }
173 |
174 | if (!customElements.get("bluesky-likes")) {
175 | customElements.define("bluesky-likes", BlueskyLikes);
176 | }
177 |
--------------------------------------------------------------------------------
/src/likers/ui.js:
--------------------------------------------------------------------------------
1 | export const templates = {
2 | root ({ likers, likes, hiddenCount, url, element } = {}) {
3 | if (likes === 0) {
4 | return this.empty({ url });
5 | }
6 |
7 | return `${this.description({ likers, likes, hiddenCount })}
8 | ${this.skipLink({ likers, likes, hiddenCount, url, element })}
9 | ${likers?.map(liker => this.user(liker)).join(" ")}
10 | ${hiddenCount > 0 ? this.more({ hiddenCount, url, element }) : ""}`;
11 | },
12 | skipLink ({ likes }) {
13 | if (likes <= 2) {
14 | return "";
15 | }
16 |
17 | return `
18 |
19 |
20 | Skip to end
21 |
22 | `;
23 | },
24 | description ({ likers, likes, hiddenCount }) {
25 | return `${likes} users liked this post${hiddenCount > 0 ? `, ${likers.length} shown` : ""}.`;
26 | },
27 | user ({ actor }) {
28 | let title = actor.displayName
29 | ? `${actor.displayName} (@${actor.handle})`
30 | : `@${actor.handle}`;
31 | let avatarSrc = actor.avatar?.replace("avatar", "avatar_thumbnail");
32 | return `
33 |
34 | ${avatarSrc ? `
` : ""}
35 | `;
36 | },
37 | more ({ hiddenCount, url, element }) {
38 | let hiddenCountFormatted = hiddenCount.toLocaleString(element._currentLang || "en", {
39 | notation: "compact",
40 | });
41 | let likedBy = url + "/liked-by";
42 | return `+${hiddenCountFormatted}`;
43 | },
44 | empty ({ url }) {
45 | return `No likes yet :( Be the first?`;
46 | },
47 | };
48 |
49 | export const styles = `
50 | :host {
51 | --avatar-size: calc(2em + 1vw);
52 | /* This is registered as a , so we can use smaller font sizes in more while keeping the same avatar size */
53 | --bluesky-likers-avatar-size: var(--avatar-size);
54 | --avatar-overlap-percentage: 0.3;
55 | --avatar-overlap-percentage-y: 0.2;
56 | --avatar-border: .15em solid canvas;
57 | --avatar-shadow: 0 .1em .4em -.3em rgb(0 0 0 / 0.4);
58 | --avatar-background: ${svg(`
59 |
60 |
61 | `)} center / cover canvas;
62 | --more-background: #1185fe;
63 | --more-color-text: white;
64 |
65 | --avatar-overlap: calc(var(--bluesky-likers-avatar-size) * var(--avatar-overlap-percentage));
66 | --avatar-overlap-y: calc(var(--bluesky-likers-avatar-size) * var(--avatar-overlap-percentage-y));
67 |
68 | display: block;
69 | padding-inline-start: var(--avatar-overlap);
70 | padding-block-start: var(--avatar-overlap-y);
71 | /* Avoid more being on a new line by itself */
72 | text-wrap: pretty;
73 | }
74 |
75 | @keyframes loading {
76 | from { opacity: 0.5; }
77 | to { opacity: 0.8; }
78 | }
79 |
80 | :host(:state(loading)) a {
81 | animation: loading 1s infinite alternate;
82 | }
83 |
84 | a {
85 | text-decoration: none;
86 | color: inherit;
87 | }
88 |
89 | [part~="link"] {
90 | display: inline-flex;
91 | margin-inline-start: calc(-1 * var(--avatar-overlap));
92 | margin-block-start: calc(-1 * var(--avatar-overlap-y));
93 |
94 | vertical-align: middle;
95 | position: relative;
96 |
97 | &:hover,
98 | &:focus {
99 | z-index: 1;
100 | }
101 | }
102 |
103 | img {
104 | display: block;
105 | }
106 |
107 | [part~="avatar"] {
108 | block-size: var(--bluesky-likers-avatar-size);
109 | inline-size: var(--bluesky-likers-avatar-size); /* fallback for Safari which doesn't respect aspect-ratio for some reason */
110 | aspect-ratio: 1;
111 | object-fit: cover;
112 | border-radius: 50%;
113 | box-shadow: var(--avatar-shadow);
114 | border: var(--avatar-border);
115 | background: var(--avatar-background);
116 | transition: scale 0.1s ease-in-out;
117 |
118 | &:hover,
119 | &:focus {
120 | scale: 1.1;
121 | }
122 | }
123 |
124 | [part~="more"] {
125 | display: inline-flex;
126 | align-items: center;
127 | justify-content: center;
128 | background: var(--more-background);
129 | color: var(--more-color-text);
130 | font-weight: 600;
131 | letter-spacing: -.03em;
132 | text-indent: -.2em; /* visual centering to account for + */
133 | font-size: calc(var(--bluesky-likers-avatar-size) / 3 - clamp(0, var(--content-length) - 3, 10) * .05em);
134 | }
135 |
136 | .visually-hidden,
137 | .visually-hidden-always {
138 | position: absolute;
139 | }
140 |
141 | .visually-hidden:not(:focus-within),
142 | .visually-hidden-always {
143 | display: block;
144 | width: 1px;
145 | height: 1px;
146 | clip: rect(0 0 0 0);
147 | clip-path: inset(50%);
148 | border: none;
149 | overflow: hidden;
150 | white-space: nowrap;
151 | padding: 0;
152 | }
153 |
154 | slot[name="skip-link"]:focus-within {
155 | display: block;
156 | z-index: 1;
157 | }
158 |
159 | slot[name="skip-link"]::slotted(a:focus),
160 | [part~="skip-link"]:focus {
161 | padding: 0.25em .5em;
162 | background: canvas;
163 | color: canvastext;
164 | }
165 | `;
166 |
167 | function svg (markup) {
168 | return `url('data:image/svg+xml,${encodeURIComponent(markup)}')`;
169 | }
170 |
--------------------------------------------------------------------------------
/src/likers/index.js:
--------------------------------------------------------------------------------
1 | import BlueskyLikes from "../likes/index.js";
2 | import { getPostLikes } from "../api.js";
3 | import { styles, templates } from "./ui.js";
4 |
5 | /**
6 | * @customElement bluesky-likers
7 | * @element bluesky-likers
8 | *
9 | * Displays the avatars of users who liked a post up to a max limit, and the number of additional users not shown.
10 | *
11 | * @attr {string} src - The URL of the post to display likes for. This is the only required attribute and must be a valid Bluesky post URL.
12 | * @attr {number} max - Maximum number of likers to display (default: 50). If the total number of likers exceeds this value, a "+N" indicator will be shown.
13 | *
14 | * @prop {BlueskyLike[]} likers - Array of users who liked the post, limited by the max attribute. Each user object contains their handle, display name, and avatar URL.
15 | * @prop {number} hiddenCount - The number of additional likers not shown in the list, calculated as the difference between total likes and the number of displayed likers
16 | *
17 | * @slot - Visually hidden content for screen reader users, providing additional context about the likers
18 | * @slot empty - Content displayed when there are no likers, defaults to a message with a link to the post
19 | * @slot skip-link - Skip link for keyboard and screen reader users. See Accessibility Notes.
20 | * @slot skip-text - Content for the skip link, defaults to "Skip to end". Ignored when skip-link has content.
21 | * @slot description - Visually hidden content for screen reader users, defaults to a description of the likers
22 | *
23 | * @csspart avatar - The circular element that displays a user's avatar or the "+N" indicator for additional likers
24 | * @csspart avatar-img - The img element for users with an avatar, with lazy loading enabled
25 | * @csspart link - The a element that wraps each entry, providing hover and focus states
26 | * @csspart profile-link - The a element that links to the user's profile on Bluesky
27 | * @csspart more - The a element that displays the hidden count and links to the full list of likers
28 | * @csspart skip-link - The a element that links to the end of the likers list for keyboard and screen reader users
29 | *
30 | * @cssproperty --avatar-size - The size of each avatar (default: calc(2em + 1vw))
31 | * @cssproperty --bluesky-likers-avatar-size - The size of the avatar images (inherits from --avatar-size)
32 | * @cssproperty --avatar-overlap-percentage - The percentage of horizontal overlap between avatars (default: 0.3)
33 | * @cssproperty --avatar-overlap-percentage-y - The percentage of vertical overlap between avatars (default: 0.2)
34 | * @cssproperty --avatar-border - The border style for each avatar (default: .15em solid canvas)
35 | * @cssproperty --avatar-shadow - The box-shadow applied to each avatar (default: 0 .1em .4em -.3em rgb(0 0 0 / 0.4))
36 | * @cssproperty --avatar-background - The background for avatars without a user image (default: SVG data URL with a default avatar)
37 | * @cssproperty --more-background - The background color for the "+N" (more) avatar (default: #1185fe)
38 | * @cssproperty --more-color-text - The text color for the "+N" (more) avatar (default: white)
39 | * @cssproperty --avatar-overlap - The actual horizontal overlap between avatars (calculated from --avatar-size and --avatar-overlap-percentage)
40 | * @cssproperty --avatar-overlap-y - The actual vertical overlap between avatars (calculated from --avatar-size and --avatar-overlap-percentage-y)
41 | *
42 | * @state loading - Indicates that the component is currently loading data from the Bluesky API
43 | * @state empty - Indicates that there are no likers to display, showing the empty slot content
44 | */
45 | export default class BlueskyLikers extends BlueskyLikes {
46 | static templates = templates;
47 | static styles = styles;
48 | static sheet;
49 |
50 | /**
51 | * Set to true once the first instance of the component is created.
52 | * @type {boolean}
53 | * @private
54 | */
55 | static _initialized = false;
56 | data = {};
57 |
58 | constructor () {
59 | super();
60 | this.constructor.init();
61 | this._internals.ariaDescribedBy = "description";
62 | this.addEventListener("keyup", this);
63 | }
64 |
65 | handleEvent (event) {
66 | if (event.type === "keyup") {
67 | if (event.key === "Home" || event.key === "End") {
68 | event.preventDefault?.();
69 | event.stopPropagation?.();
70 | let links = Array.from(this.shadowRoot.querySelectorAll("a"));
71 |
72 | if (links.length > 0) {
73 | if (event.key === "Home") {
74 | links.at(0)?.focus();
75 | }
76 | else {
77 | links.at(-1)?.focus();
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
84 | static init () {
85 | if (this._initialized) {
86 | return;
87 | }
88 |
89 | this._initialized = true;
90 |
91 | // Register the CSS custom property for the avatar size
92 | // @property in Shadow DOM is not yet supported, we need to register it in the light DOM
93 | globalThis?.CSS?.registerProperty?.({
94 | name: "--bluesky-likers-avatar-size",
95 | initialValue: "48px",
96 | syntax: "",
97 | inherits: true,
98 | });
99 | }
100 |
101 | /**
102 | * @returns {import("../api.js").BlueskyLike[]}
103 | */
104 | get likers () {
105 | return this.data.likers ?? [];
106 | }
107 |
108 | hiddenCount = 0;
109 |
110 | async fetch ({ force } = {}) {
111 | await super.fetch({ force });
112 |
113 | if (this.data.post) {
114 | this.data.likers = await getPostLikes(this.src, { force, limit: this.max });
115 | }
116 |
117 | return this.data;
118 | }
119 |
120 | async render ({ useCache = false } = {}) {
121 | this._internals.states?.add("loading");
122 | if (!this.data.likers || !useCache) {
123 | await this.fetch({ force: !useCache });
124 | }
125 |
126 | if (!this.data.likers) {
127 | return;
128 | }
129 |
130 | let likes = this.likes;
131 | let hasLikes = likes > 0;
132 |
133 | let { templates } = this.constructor;
134 |
135 | if (hasLikes) {
136 | this._internals.states?.delete("empty");
137 |
138 | // Render the likers
139 | let likers = this.data.likers ?? [];
140 | let max = this.max;
141 |
142 | if (likers.length > max) {
143 | likers = likers.slice(0, max);
144 | }
145 |
146 | this.hiddenCount = likes - likers.length;
147 |
148 | this._setShadowHTML(
149 | templates.root({
150 | likers,
151 | likes,
152 | hiddenCount: this.hiddenCount,
153 | post: this.data.post,
154 | url: this.src,
155 | element: this,
156 | }),
157 | );
158 | }
159 | else {
160 | this._internals.states?.add("empty");
161 |
162 | this._setShadowHTML(`${templates.empty({ url: this.src })}`);
163 | }
164 |
165 | this._internals.states?.delete("loading");
166 | }
167 |
168 | get max () {
169 | return Number(this.getAttribute("max") || 50);
170 | }
171 |
172 | set max (value) {
173 | this.setAttribute("max", value);
174 | }
175 |
176 | static get observedAttributes () {
177 | return [...super.observedAttributes, "max"];
178 | }
179 |
180 | attributeChangedCallback (name, oldValue, newValue) {
181 | super.attributeChangedCallback(name, oldValue, newValue);
182 |
183 | if (name === "max") {
184 | let oldMax = oldValue === null ? 50 : Number(oldValue);
185 | this.render({ useCache: oldMax <= newValue });
186 | }
187 | }
188 | }
189 |
190 | if (!customElements.get("bluesky-likers")) {
191 | customElements.define("bluesky-likers", BlueskyLikers);
192 | }
193 |
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | const BASE_ENDPOINT = "https://public.api.bsky.app/xrpc/app.bsky.";
2 |
3 | let endpoints = {
4 | profile: "actor.getProfile",
5 | posts: "feed.getPosts",
6 | likes: "feed.getLikes",
7 | };
8 |
9 | /**
10 | * Cached API responses by endpoint
11 | * @type {Record | null | undefined>>}
12 | */
13 | export const cacheByEndpoint = Object.fromEntries(
14 | Object.values(endpoints).map(endpoint => [endpoint, {}]),
15 | );
16 |
17 | /**
18 | * Parse a post like "https://bsky.app/profile/lea.verou.me/post/3lhygzakuic2n"
19 | * and return the handle and post ID
20 | * @param {string} url
21 | * @returns {{ handle: string | undefined, postId: string | undefined }}
22 | */
23 | export function parsePostUrl (url) {
24 | return {
25 | handle: url.match(/\/profile\/([^\/]+)/)?.[1],
26 | postId: url.match(/\/post\/([^\/]+)/)?.[1],
27 | };
28 | }
29 |
30 | /**
31 | * Get profile details by handle
32 | * @param {string} handle
33 | * @param {Object} [options]
34 | * @param {boolean} [options.force] - Bypass the cache and fetch the data again even if cached.
35 | * @returns {Promise}
36 | */
37 | export async function getProfile (handle, options = {}) {
38 | let endpoint = endpoints.profile;
39 | let cache = cacheByEndpoint[endpoint];
40 |
41 | if (cache[handle] && !options.force) {
42 | return cache[handle];
43 | }
44 |
45 | let profileUrl = `${BASE_ENDPOINT}${endpoint}?actor=${handle}`;
46 | let data = getJSON(profileUrl);
47 | cache[handle] = data;
48 | data = await data;
49 |
50 | return (cache[handle] = data);
51 | }
52 |
53 | /**
54 | * Get the DID of a user by their handle.
55 | * Does not send an API call if the handle is already a DID.
56 | * @param {string} handle
57 | * @returns {Promise}
58 | */
59 | export async function getDid (handle) {
60 | if (handle.startsWith("did:")) {
61 | return handle;
62 | }
63 |
64 | return (await getProfile(handle))?.did;
65 | }
66 |
67 | /**
68 | * Bluesky "at-uri" of the post
69 | * @param {string} postUrl
70 | * @returns {Promise}
71 | */
72 | export async function getPostUri (postUrl) {
73 | let post = parsePostUrl(postUrl);
74 |
75 | if (!post.handle || !post.postId) {
76 | return undefined;
77 | }
78 |
79 | let did = await getDid(post.handle);
80 |
81 | if (!did) {
82 | return undefined;
83 | }
84 |
85 | return `at://${did}/app.bsky.feed.post/${post.postId}`;
86 | }
87 |
88 | /**
89 | * Get post details by URL.
90 | * @param {string} postUrl
91 | * @param {Object} [options]
92 | * @param {boolean} [options.force] - Bypass the cache and fetch the data again even if cached.
93 | * @returns {Promise}
94 | */
95 | export async function getPost (postUrl, options = {}) {
96 | let endpoint = endpoints.posts;
97 | let cache = cacheByEndpoint[endpoint];
98 |
99 | if (cache[postUrl] && !options.force) {
100 | return cache[postUrl];
101 | }
102 |
103 | const postUri = await getPostUri(postUrl);
104 |
105 | if (!postUri) {
106 | return undefined;
107 | }
108 |
109 | const apiCall = `${BASE_ENDPOINT}${endpoint}?uris=${postUri}`;
110 | let data = getJSON(apiCall).then(data => data?.posts?.[0]);
111 | cache[postUrl] = data;
112 | data = await data;
113 |
114 | if (!data) {
115 | return null;
116 | }
117 |
118 | return (cache[postUrl] = data);
119 | }
120 |
121 | /**
122 | * Get the likers for a post by its URL.
123 | * @param {string} postUrl
124 | * @param {Object} [options]
125 | * @param {boolean} [options.force] - Bypass the cache and fetch the data again even if cached.
126 | * @param {number} [options.limit] - Limit the number of returned likes
127 | * @returns {Promise}
128 | */
129 | export async function getPostLikes (postUrl, options = {}) {
130 | let endpoint = endpoints.likes;
131 | let cache = cacheByEndpoint[endpoint];
132 |
133 | if (cache[postUrl] && !options.force) {
134 | return cache[postUrl];
135 | }
136 |
137 | const postUri = await getPostUri(postUrl);
138 |
139 | if (!postUri) {
140 | return undefined;
141 | }
142 |
143 | let apiCall = `${BASE_ENDPOINT}${endpoint}?uri=${postUri}`;
144 |
145 | if (options.limit) {
146 | let limit = Math.min(options.limit, 100);
147 | apiCall += `&limit=${limit}`;
148 | }
149 |
150 | let data = getJSON(apiCall).then(data => data?.likes);
151 | cache[postUrl] = data;
152 |
153 | data = await data;
154 |
155 | if (!data) {
156 | return null;
157 | }
158 |
159 | return (cache[postUrl] = data);
160 | }
161 |
162 | /**
163 | * @param {string} url
164 | * @returns {Promise}
165 | */
166 | function getJSON (url) {
167 | return fetch(url).then(res => res.json());
168 | }
169 |
170 | // Extracted from the @atproto/api package
171 |
172 | /**
173 | * @typedef {Object} BlueskyActor
174 | * @property {string} did - The DID (Decentralized Identifier) of the actor
175 | * @property {string} handle - The handle (username) of the actor
176 | * @property {string} [displayName] - Optional display name of the actor
177 | * @property {string} [avatar] - Optional URL to the actor's avatar
178 | */
179 |
180 | /**
181 | * @typedef {Object} BlueskyPostRecord
182 | * @property {string} text - The text content of the post
183 | * @property {string} createdAt - The creation timestamp of the post
184 | * @property {string[]} [langs] - Optional languages used
185 | * @property {any[]} [facets] - Optional facets
186 | * @property {any} [embed] - Optional embed object
187 | * @property {{ root: { cid: string, uri: string }, parent: { cid: string, uri: string } }} [reply] - Optional reply info
188 | * @property {any[]} [labels] - Optional labels
189 | */
190 |
191 | /**
192 | * @typedef {Object} BlueskyPost
193 | * @property {string} uri - The URI of the post
194 | * @property {string} cid - The CID of the post
195 | * @property {BlueskyActor} author - The author of the post
196 | * @property {BlueskyPostRecord} record - The post record
197 | * @property {number} likeCount - Number of likes
198 | * @property {number} repostCount - Number of reposts
199 | * @property {number} replyCount - Number of replies
200 | * @property {string} indexedAt - Index timestamp
201 | * @property {{ like?: string, repost?: string }} [viewer] - Viewer state
202 | */
203 |
204 | /**
205 | * @typedef {Object} BlueskyLike
206 | * @property {string} uri - The URI of the like
207 | * @property {string} cid - The CID of the like
208 | * @property {string} createdAt - Like timestamp
209 | * @property {string} indexedAt - Index timestamp
210 | * @property {BlueskyActor} actor - The actor who liked the post
211 | */
212 |
213 | /**
214 | * @typedef {Object} BlueskyProfile
215 | * @property {string} did - The DID of the profile
216 | * @property {string} handle - The handle of the profile
217 | * @property {string} [displayName] - Optional display name
218 | * @property {string} [description] - Optional profile description
219 | * @property {string} [avatar] - Optional avatar URL
220 | * @property {string} [banner] - Optional banner URL
221 | * @property {number} followersCount - Number of followers
222 | * @property {number} followsCount - Number of accounts followed
223 | * @property {number} postsCount - Number of posts
224 | * @property {string} indexedAt - When the profile was indexed
225 | * @property {{ muted: boolean, blockedBy: boolean }} [viewer] - Viewer state
226 | */
227 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
BlueSky Likes Components
2 |
3 | Components to display (and encourage) likes on [BlueSky](https://bsky.app) posts.
4 |
5 | - [``](#bluesky-likes): Displays the number of likes on a post.
6 | - [``](#bluesky-likers): Displays avatars of users who liked a post.
7 |
8 | For a demo, check out https://projects.verou.me/bluesky-likes/
9 |
10 | Can be used separately, or together.
11 |
12 | E.g. for something similar to [Salma Alam-Nayor’s](https://whitep4nth3r.com/blog/show-bluesky-likes-on-blog-posts/):
13 |
14 | ```html
15 |
16 |
17 | likes on Bluesky
18 |
19 |
20 |
21 | Like this post on Bluesky to see your face on this page
22 |
23 |
24 |
25 | ```
26 |
27 | 
28 |
29 | ## Features
30 |
31 | These components are designed to make common cases easy, and complex cases possible.
32 |
33 | - **Dynamic**: Components respond to changes in the URL of the post — or when it’s lazily set later
34 | - **Aggressive caching**: API calls are cached even across component instances, so you can have multiple components about the same post without making duplicate requests.
35 | - **Ultra-lightweight**: The whole package is [~2 KB minified & gzipped](https://bundlephobia.com/package/bluesky-likes) and dependency-free
36 | - [**Accessible**](#accessibility-notes) & [**i18n** friendly](#i18n-notes)
37 | - **Autoloading** is available to take the hassle out of figuring out when to load the components
38 | - **Highly customizable styling** via regular CSS properties, custom properties, states, and parts (but also beautiful by default so you don’t have to).
39 | - **Customizable content** via slots
40 | - **Hackable**: You can [replace the templates and styles](#replacing-templates-and-styles) of the components with your own, or even subclass them to create new components with different templates and styles
41 |
42 | ## Installation
43 |
44 | The easiest way is to use the [autoloader](#autoloader) and a CDN such as [unpkg](https://unpkg.com/).
45 | All it takes is pasting this into your HTML and you’re ready to use the components:
46 |
47 | ```html
48 |
49 | ```
50 |
51 | Or, if you know which ones you need, you can import them individually:
52 |
53 | ```html
54 |
55 |
56 | ```
57 |
58 | You can also install the components via npm and use with your toolchain of choice:
59 |
60 | ```bash
61 | npm install bluesky-likes
62 | ```
63 |
64 | Then import the components in your JavaScript.
65 | You can import everything:
66 |
67 | ```js
68 | import { BlueskyLikes, BlueskyLikers, bsky } from "bluesky-likes";
69 | ```
70 |
71 | Or you can use individual exports like `bluesky-likes/likes`.
72 |
73 | ## ``
74 |
75 | Displays the number of likes on a post and links to the full list.
76 |
77 | ```html
78 |
79 | ```
80 |
81 | To link to a different URL (e.g. the post itself), simply wrap the component in a link:
82 |
83 | ```html
84 |
85 |
86 |
87 | ```
88 |
89 | ### Attributes
90 |
91 | | Attribute | Type | Description |
92 | | --------- | -------- | ----------------------------------------- |
93 | | `src` | `string` | The URL of the post to display likes for. |
94 |
95 | ### Slots
96 |
97 | | Name | Description |
98 | | ----------- | ------------------------------ |
99 | | _(Default)_ | Content added after the count. |
100 | | `prefix` | Custom icon |
101 |
102 | ### Styling
103 |
104 | #### Custom properties
105 |
106 | None!
107 | Pretty much all styling is on the host element, so you can just override regular CSS properties such as `border`, `padding` or `color` to restyle the component.
108 |
109 | #### [Parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part)
110 |
111 | | Name | Description |
112 | | ------- | ------------------------------------------------------------------------------- |
113 | | `link` | The `` element that links to all likes. |
114 | | `count` | The `` that contains the like count. |
115 | | `icon` | The default icon which is displayed if nothing is slotted in the `prefix` slot. |
116 |
117 | #### [States](https://developer.mozilla.org/en-US/docs/Web/CSS/:state)
118 |
119 | | Name | Description |
120 | | --------- | ------------------------------------------------------- |
121 | | `loading` | Indicates that the component is currently loading data. |
122 |
123 | ## ``
124 |
125 | Displays the avatars of users who liked a post up to a max limit, and the number of additional users not shown.
126 |
127 | ```html
128 |
129 | ```
130 |
131 | ### Attributes
132 |
133 | | Attribute | Type | Description |
134 | | --------- | -------- | --------------------------------------------------------- |
135 | | `src` | `string` | The URL of the post to display likes for. |
136 | | `max` | `number` | The maximum number of avatars to display. Defaults to 50. |
137 |
138 | ### Slots
139 |
140 | | Name | Description |
141 | | ------------- | ------------------------------------------------------------------------------------------------------------------------ |
142 | | _(Default)_ | Fallback content to display before component is registered. |
143 | | `empty` | Content displayed when there are no likers. |
144 | | `skip-link` | Skip link. See [Accessibility Notes](#accessibility-notes). You don't need to implement event handling or hiding for it. |
145 | | `skip-text` | Content for the skip link. See [Accessibility Notes](#accessibility-notes). Ignored when `skip-link` has content. |
146 | | `description` | Visually hidden content for screen reader users. See [Accessibility Notes](#accessibility-notes). |
147 |
148 | ### Styling
149 |
150 | #### Recipes
151 |
152 | - Apply `text-wrap: balance` to the component to equalize the width of the rows.
153 |
154 | #### [States](https://developer.mozilla.org/en-US/docs/Web/CSS/:state)
155 |
156 | | Name | Description |
157 | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158 | | `loading` | Indicates that the component is currently loading data. Note that the state will be removed when data loads and the component is updated, not after all avatars load. |
159 | | `empty` | Indicates that there are no likers to display. |
160 |
161 | #### Custom properties
162 |
163 | | Name | Default Value | Description |
164 | | ------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
165 | | `--avatar-size` | `calc(2em + 1vw)` | The size of each avatar. |
166 | | `--avatar-overlap-percentage` | `0.3` | The percentage of horizontal overlap between avatars. |
167 | | `--avatar-overlap-percentage-y` | `0.2` | The percentage of vertical overlap between avatars. |
168 | | `--avatar-border` | `.15em solid canvas` | The border style for each avatar. |
169 | | `--avatar-shadow` | `0 .1em .4em -.3em rgb(0 0 0 / 0.4)` | The box-shadow applied to each avatar. |
170 | | `--avatar-background` | `url('data:image/svg+xml,…') center / cover canvas` | The background for avatars without a user image (default SVG, centered and covered). |
171 | | `--more-background` | `#1185fe` | The background color for the "+N" (more) avatar. |
172 | | `--more-color-text` | `white` | The text color for the "+N" (more) avatar. |
173 | | `--avatar-overlap` | `calc(var(--avatar-size) \* var(--avatar-overlap-percentage))` | The actual horizontal overlap between avatars (as a ``). |
174 | | `--avatar-overlap-y` | `calc(var(--avatar-size) \* var(--avatar-overlap-percentage-y))` | The actual vertical overlap between avatars (as a ``). |
175 |
176 | ### [Parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part)
177 |
178 | | Name | Description |
179 | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
180 | | `avatar` | The circular element that displays a user, or the +N for users not shown. Corresponds to an `
` element for users with an avatar, and an `` in other cases. |
181 | | `avatar-img` | The `
` element for users with an avatar. |
182 | | `link` | The `` element that wraps each entry (either links to the user's profile, or to all likers) |
183 | | `profile-link` | The `` element that links to the user's profile. |
184 | | `more` | The `` element that displays the hidden count. |
185 | | `skip-link` | The `` element to skip to the end that is visually hidden but available to keyboard users and screen readers. |
186 |
187 | ## Autoloader
188 |
189 | Due to its side effects, the autoloader is a separate export:
190 |
191 | ```js
192 | import "bluesky-likes/autoload";
193 | ```
194 |
195 | By default, the autoloader will not observe future changes: if the components are not available when the script runs, they will not be fetched.
196 | It will also not discover components that are in shadow roots of other components.
197 | This is done for performance reasons, since these features are slow and these components are mostly used on blogs and other content-focused websites that don’t need this.
198 |
199 | If, however, you do, you can use the `observe()` and `discover()` methods the autoloader exports:
200 |
201 | - `observe(root)` will observe `root` for changes and load components as they are added. You can use `unobserve()` to stop observing.
202 | - `discover(root)` will discover components in `root` and load them if they are not already loaded. `root` can be any DOM node, including documents and shadow roots.
203 |
204 | ## Replacing templates and styles
205 |
206 | For most common cases, slots should be sufficient for customizing the content of the components and regular CSS to for styling them.
207 | However, for more advanced use cases, you can completely gut them and replace their templates and styles with your own.
208 |
209 | Every component class includes the templates used to render it as a static `templates` property and its CSS styles as a `styles` property.
210 | For example, `BlueskyLikes.templates` is the templates used by the `` component, and `BlueskyLikers.styles` is the styles used by the `` component.
211 | Each template is a function that takes a `data` object and returns a string of HTML, while the styles are a string of CSS.
212 |
213 | You can either tweak the templates directly, or you can create a subclass with different values and register it as a new component.
214 | If you make changes after elements have already been initialized, you should call `element.render({useCache: true})` on these elements.
215 |
216 | ## API wrapper
217 |
218 | Since these components had to interface with the BlueSky API, they also implement a tiny wrapper for the relevant parts of it.
219 | While this library is absolutely not intended as a BlueSky API SDK, if you do need these functions, they are in [`src/api.js`](src/api.js) and have their own export too: `bluesky-likes/api`.
220 |
221 | The following functions are available:
222 |
223 | - `getProfile(handle)`: Fetches a user profile by handle.
224 | - `getPost(url)`: Fetches a post details by URL.
225 | - `getPostLikes(url)`: Fetches the likers for a post by its URL.
226 |
227 | Also these, though you probably won’t need them unless you’re making new API calls not covered by these endpoints:
228 |
229 | - `parsePostUrl(url)`: Parses a BlueSky post URL and returns the post's handle and URI. **Synchronous**.
230 | - `getDid(handle)`: Get the DID of a user by their handle.
231 | - `getPostUri(url)`: Fetches a post AT URI by its URL.
232 |
233 | Unless otherwise mentioned, all functions are async.
234 |
235 | ## Accessibility Notes
236 |
237 | These components are designed with accessibility in mind,
238 | in the sense that they use semantically appropriate HTML elements
239 | and have been tested with screen readers.
240 |
241 | However, the accessibility of the end result also depends on how you use them.
242 |
243 | ### ``
244 |
245 | By default, the icon’s alt text is empty, since it is considered presentational.
246 | To change this, you can slot in your own icon with a different alt text.
247 |
248 | By default, the link’s title is "View all Bluesky likes".
249 | To localize this, you can wrap the element in another link, with your own title.
250 |
251 | ### ``
252 |
253 | By default, the component includes a description for non-sighted users like "271 users liked this post, 50 shown".
254 | You can customize that content by providing your own content in the `description` slot.
255 |
256 | The component includes links to the profiles of the users who liked the post (with `rel="nofollow"`),
257 | and a skip link to skip to the end of the list.
258 | You can customize the content of the link via the `skip` slot and the styling of the link via the `skip-link` part.
259 |
260 | ## i18n Notes
261 |
262 | - Number formatting uses locale-aware formatting (via `Intl.NumberFormat`) using the element’s inherited language.
263 | - CSS uses logical properties where appropriate, so that the components can be used in right-to-left languages without changes
264 | - Any content that may need to be localized has a way to replace it (e.g. via slots)
265 |
266 | ## Credits
267 |
268 | - [Salma Alam-Nayor](https://whitep4nth3r.com/blog/show-bluesky-likes-on-blog-posts/) for the initial idea
269 | - [Dmitry Sharabin](https://d12n.me) for the [CodePen that sparked this](https://codepen.io/dmitrysharabin/pen/Jodbyqm)
270 |
271 | ## License
272 |
273 | These components are [MIT licensed](LICENSE).
274 | However, if you are using them in a way that helps you profit, there is a **social** (not legal) expectation that you [give back by funding their development](https://github.com/sponsors/LeaVerou).
275 |
--------------------------------------------------------------------------------