diff --git a/CredentialManifest/README.md b/CredentialManifest/README.md new file mode 100644 index 0000000..c18931f --- /dev/null +++ b/CredentialManifest/README.md @@ -0,0 +1,29 @@ +# Credential Manifest + +A basic `` custom element that renders a [credential manifest](https://identity.foundation/credential-manifest/). + +Playground: <[https://tbd54566975.github.io/web5-components/demo/CredentialManifest/](https://tbd54566975.github.io/web5-components/demo/CredentialManifest/)> + +## HTML Attributes + +- `src` is a URL to the [credential manifest](https://identity.foundation/credential-manifest/) + +## JS Properties + +- `data` is the JSON payload of the [credential manifest](https://identity.foundation/credential-manifest/) + +## Layout + +The `` will contain a section for the `"issuer"` and each item in `"output_descriptors"`. + +The size of each section will be the minimum of the space made available for it by the parent and the size of the `"hero"` (if provided). Each section will also match the aspect ratio of the `"hero"` (if provided). + +The `"thumbnail"` of the [credential manifest](https://identity.foundation/credential-manifest/) is rendered in the top left corner of the section (if provided). + +The `"title"`, `"subtitle"`, `"description"`, and `"properties"` are rendered in the bottom left corner of the section (if provided). + +The "Description" label can be customized using a `<* slot="description-label">text goes here` inside the ``. Note that only the `textContent` will be used, not any matching/applied styles. + +## Style + +Any default inheritable properties (e.g. `font`, `border-radius`, etc.) will be inherited from the parent of the `` (though sequential sections will not have `border-radius` applied in between) unless overridden by a value in the [credential manifest](https://identity.foundation/credential-manifest/) (e.g. `"text"`, `"background",` etc.). diff --git a/CredentialManifest/index.mjs b/CredentialManifest/index.mjs new file mode 100644 index 0000000..abaa666 --- /dev/null +++ b/CredentialManifest/index.mjs @@ -0,0 +1,359 @@ +import { hideUntilLoad } from "../shared/DOM.mjs"; +import { verifyType } from "../shared/Type.mjs"; +import { applyEntityStyles } from "../shared/WalletRendering/EntityStyles.mjs"; + +const VERSION = "https://identity.foundation/credential-manifest/spec/v1.0.0/"; + +const STYLE_SPACING = "1.5em"; +const STYLE = ` +img { + max-width: 100%; + max-height: 100%; +} + +:host > :not(:first-of-type) { + padding-top: calc(${STYLE_SPACING} / 2); + border-top-right-radius: 0; + border-top-left-radius: 0; +} + +:host > :not(:last-of-type) { + padding-bottom: calc(${STYLE_SPACING} / 2); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.issuer { + box-sizing: border-box; + position: relative; + padding: ${STYLE_SPACING}; + border-radius: inherit; +} + +.descriptor { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-end; + position: relative; + padding: ${STYLE_SPACING}; + border-radius: inherit; +} + +:is(.issuer, .descriptor) .thumbnail { + z-index: 2; + max-width: 32px; + max-height: 32px; +} + +:is(.issuer, .descriptor) .hero { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + max-width: 100%; + max-height: 100%; +} + +.issuer .name { + position: relative; + z-index: 3; + margin: 0; + padding: 0; + font-size: 0.8em; +} + +.descriptor .title { + position: relative; + z-index: 3; + margin: 0; + padding: 0; + font-size: 1.5em; +} + +.descriptor .subtitle { + position: relative; + z-index: 3; + margin: 0; + padding: 0; + font-size: 1em; + font-weight: normal; +} + +.descriptor .description { + position: relative; + z-index: 3; + margin: 0; + padding: 0; +} + +.descriptor .description:not(:first-child) { + padding-top: ${STYLE_SPACING}; +} + +.descriptor .properties { + display: flex; + flex-wrap: wrap; + gap: ${STYLE_SPACING}; + z-index: 3; + margin: 0; + padding: 0; +} + +.descriptor .properties:not(:first-child) { + padding-top: ${STYLE_SPACING}; +} + +.descriptor .properties .property { + width: calc(50% - (${STYLE_SPACING} / 2)); + list-style-type: none; +} + +.descriptor .description .label, +.descriptor .properties .property .label { + display: block; + margin-bottom: 0.25em; + font-size: 0.5em; + text-transform: uppercase; + opacity: 0.5; +} + +.placeholder { + font-style: italic; + opacity: 0.5; +} + +slot { + display: none; +} +`; + +export class CredentialManifest extends HTMLElement { + static #style = null; + + #data = null; + #srcFetchAbortController = null; + + #root; + + constructor() { + super(); + + this.#root = this.attachShadow({ mode: "closed" }); + + if (this.#root.adoptedStyleSheets) { + if (!CredentialManifest.#style) { + CredentialManifest.#style = new CSSStyleSheet; + CredentialManifest.#style.replaceSync(STYLE); + } + this.#root.adoptedStyleSheets = [ CredentialManifest.#style ]; + } else { + if (!CredentialManifest.#style) { + CredentialManifest.#style = document.createElement("style"); + CredentialManifest.#style.textContent = STYLE; + } + } + } + + get src() { + return this.getAttribute("src"); + } + set src(src) { + if (typeof src === "string") + this.setAttribute("src", src); + } + + get data() { return this.#data; } + set data(data) { + if (!data || typeof data !== "object") + return; + + if (this.#srcFetchAbortController) { + this.#srcFetchAbortController.abort(); + this.#srcFetchAbortController = null; + } + + this.#data = data; + this.#update(); + } + + static observedAttributes = [ + "src", + ]; + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case "src": + this.#srcFetchAbortController?.abort(); + this.#srcFetchAbortController = new AbortController; + + fetch(newValue, { signal: this.#srcFetchAbortController.signal }) + .then((response) => response.json()) + .then((json) => { + this.data = json; + }) + .catch((error) => { + if (error.name !== "AbortError") + throw error; // surface `fetch` errors for developers + }); + return; + } + } + + #update() { + this.#root.textContent = ""; // remove all children + + if (!this.#root.adoptedStyleSheets) + this.#root.appendChild(CredentialManifest.#style.cloneNode(true)); + + if (!this.#data || typeof this.#data !== "object") + return; + + // + if (this.#data["spec_version"] !== VERSION) + return; + + // Copy only the raw text of the `<* slot="description-label">` to ensure that no outside CSS leaks in. + let descriptionLabelElements = [ ]; + function updateDescriptionLabels() { + let descriptionLabel = descriptionLabelSlotElement.assignedNodes().map((slottedNode) => slottedNode.textContent).join("") || "Description"; + for (let descriptionLabelElement of descriptionLabelElements) + descriptionLabelElement.textContent = descriptionLabel; + } + let descriptionLabelSlotMutationObserver = new MutationObserver((records) => { + updateDescriptionLabels(); + }); + let descriptionLabelSlotElement = this.#root.appendChild(document.createElement("slot")); + descriptionLabelSlotElement.name = "description-label"; + descriptionLabelSlotElement.addEventListener("slotchange", (event) => { + updateDescriptionLabels(); + + descriptionLabelSlotMutationObserver.disconnect(); + for (let slottedNode of descriptionLabelSlotElement.assignedNodes()) { + descriptionLabelSlotMutationObserver.observe(slottedNode, { + subtree: true, + childList: true, + characterData: true, + }); + } + }); + + // + + let issuer = verifyType(this.#data["issuer"], "object"); + if (issuer) { + let issuerElement = this.#root.appendChild(document.createElement("section")); + issuerElement.classList.add("issuer"); + + applyEntityStyles(issuer["styles"], issuerElement); + + let name = verifyType(issuer["name"], "string"); + if (name) { + let nameElement = issuerElement.appendChild(document.createElement("h1")); + nameElement.classList.add("name"); + nameElement.textContent = name; + } + } + + // + let descriptors = verifyType(this.#data["output_descriptors"], Array.isArray) ?? [ ]; + for (let descriptor of descriptors) { + if (!descriptor) + continue; + + let descriptorElement = this.#root.appendChild(document.createElement("section")); + descriptorElement.classList.add("descriptor"); + + applyEntityStyles(descriptor["styles"], descriptorElement); + + // + + let title = this.#placeholderForDisplayMappingObject(descriptor["display"]?.["title"], "title"); + if (title) { + let titleElement = descriptorElement.appendChild(document.createElement("h1")); + titleElement.classList.add("title"); + titleElement.textContent = title.text; + titleElement.classList.add(...title.classes); + } + + let subtitle = this.#placeholderForDisplayMappingObject(descriptor["display"]?.["subtitle"], "subtitle"); + if (subtitle) { + let subtitleElement = descriptorElement.appendChild(document.createElement("h2")); + subtitleElement.classList.add("subtitle"); + subtitleElement.textContent = subtitle.text; + subtitleElement.classList.add(...subtitle.classes); + } + + let description = this.#placeholderForDisplayMappingObject(descriptor["display"]?.["description"], "description"); + if (description) { + let descriptionElement = descriptorElement.appendChild(document.createElement("p")); + descriptionElement.classList.add("description"); + + let labelElement = descriptionElement.appendChild(document.createElement("span")); + labelElement.classList.add("label"); + descriptionLabelElements.push(labelElement); + + descriptionElement.append(" "); + + let textElement = descriptionElement.appendChild(document.createElement("span")); + textElement.textContent = description.text; + textElement.classList.add(...description.classes); + } + + let propertiesElement = descriptorElement.appendChild(document.createElement("ul")); + propertiesElement.classList.add("properties"); + + let properties = verifyType(descriptor["display"]?.["properties"], Array.isArray) ?? [ ]; + for (let property of properties) { + if (!property) + continue; + + let value = this.#placeholderForDisplayMappingObject(property, "property"); + if (!value) + continue; + + let propertyElement = propertiesElement.appendChild(document.createElement("li")); + propertyElement.classList.add("property"); + + // + let label = verifyType(property["label"], "string"); + if (label) { + let labelElement = propertyElement.appendChild(document.createElement("span")); + labelElement.classList.add("label"); + labelElement.textContent = label; + + propertyElement.append(" "); + } + + let valueElement = propertyElement.appendChild(document.createElement("span")); + valueElement.classList.add("value"); + valueElement.textContent = value.text; + valueElement.classList.add(...value.classes); + } + } + + updateDescriptionLabels(); + } + + // + #placeholderForDisplayMappingObject(displayMappingObject, name) { + if (!displayMappingObject) + return undefined; + + // + if ("path" in displayMappingObject) { + let fallback = verifyType(displayMappingObject["fallback"], "string"); + if (fallback) + return { text: fallback, classes: [ ] }; + return { text: name, classes: [ "placeholder" ] }; + } + + // + if ("text" in displayMappingObject) + return { text: verifyType(displayMappingObject["text"], "string"), classes: [ ] }; + + return undefined; + } +} +customElements.define("credential-manifest", CredentialManifest); diff --git a/VerifiableCredential/index.mjs b/VerifiableCredential/index.mjs index 7e49fd3..7a4d552 100644 --- a/VerifiableCredential/index.mjs +++ b/VerifiableCredential/index.mjs @@ -1,9 +1,8 @@ import QRCode from "qrcode"; -import { isHexColor } from "../shared/Color.mjs"; import { convertBooleanAttribute, hideUntilLoad } from "../shared/DOM.mjs"; import { verifyType } from "../shared/Type.mjs"; -import { isURL } from "../shared/URL.mjs"; -import { resolveDisplayMappingObject } from "../shared/WalletRendering.mjs"; +import { resolveDisplayMappingObject } from "../shared/WalletRendering/DisplayMappingObject.mjs"; +import { applyEntityStyles } from "../shared/WalletRendering/EntityStyles.mjs"; const VERSION = "https://identity.foundation/credential-manifest/spec/v1.0.0/"; @@ -302,7 +301,7 @@ export class VerifiableCredential extends HTMLElement { let issuerElement = this.#root.appendChild(document.createElement("section")); issuerElement.classList.add("issuer"); - this.#applyEntityStyles(issuer["styles"], issuerElement); + applyEntityStyles(issuer["styles"], issuerElement); let name = verifyType(issuer["name"], "string"); if (name) { @@ -321,7 +320,7 @@ export class VerifiableCredential extends HTMLElement { let descriptorElement = this.#root.appendChild(document.createElement("section")); descriptorElement.classList.add("descriptor"); - this.#applyEntityStyles(descriptor["styles"], descriptorElement); + applyEntityStyles(descriptor["styles"], descriptorElement); // @@ -399,47 +398,5 @@ export class VerifiableCredential extends HTMLElement { updateDescriptionLabels(); } - - // - #applyEntityStyles(data, containerElement) { - if (!data) - return; - - let textColor = verifyType(data["text"]?.["color"], isHexColor); - if (textColor) - containerElement.style.setProperty("color", textColor); - - let backgroundColor = verifyType(data["background"]?.["color"], isHexColor); - if (backgroundColor) - containerElement.style.setProperty("background-color", backgroundColor); - - let thumbnailURI = verifyType(data["thumbnail"]?.["uri"], "string"); - if (isURL(thumbnailURI)) { - let thumbnailElement = containerElement.appendChild(document.createElement("img")); - thumbnailElement.classList.add("thumbnail"); - thumbnailElement.src = thumbnailURI; - hideUntilLoad(thumbnailElement); - - let thumbnailAlt = verifyType(data["thumbnail"]?.["alt"], "string"); - if (thumbnailAlt) - thumbnailElement.alt = thumbnailAlt; - } - - let heroURI = verifyType(data["hero"]?.["uri"], "string"); - if (isURL(heroURI)) { - let heroElement = containerElement.appendChild(document.createElement("img")); - heroElement.classList.add("hero"); - heroElement.src = heroURI; - hideUntilLoad(heroElement, () => { - containerElement.style.setProperty("max-width", `min(100%, ${heroElement.naturalWidth}px)`); - containerElement.style.setProperty("max-height", `min(100%, ${heroElement.naturalHeight}px)`); - containerElement.style.setProperty("aspect-ratio", `${heroElement.naturalWidth} / ${heroElement.naturalHeight}`); - }); - - let heroAlt = verifyType(data["hero"]?.["alt"], "string"); - if (heroAlt) - heroElement.alt = heroAlt; - } - } } customElements.define("verifiable-credential", VerifiableCredential); diff --git a/demo/CredentialManifest/index.html b/demo/CredentialManifest/index.html new file mode 100644 index 0000000..4c5c5fa --- /dev/null +++ b/demo/CredentialManifest/index.html @@ -0,0 +1,324 @@ + +
+
+
+ +
+
+

Preview

+
+
+ + diff --git a/demo/VerifiableCredential/index.html b/demo/VerifiableCredential/index.html index af065d7..96e67f9 100644 --- a/demo/VerifiableCredential/index.html +++ b/demo/VerifiableCredential/index.html @@ -134,7 +134,7 @@

Preview

}