import {Task} from '@lit/task';
import {LitElement, PropertyValues, css} from 'lit';
import {customElement, property} from 'lit/decorators.js';
const LINK_LOAD_SUPPORTED = 'onload' in HTMLLinkElement.prototype;
const isLinkLoaded = (link: HTMLLinkElement) => {
try {
// Firefox may throw when accessing a not-yet-loaded cssRules property, so
// we access it inside a try-catch block.
link.sheet?.cssRules;
} catch (e) {
const {name} = e as Error;
if (name === 'InvalidAccessError' || name === 'SecurityError') {
return false;
} else {
throw e;
}
}
return link.sheet !== null;
};
/**
* Resolves when a `` element has loaded its resource.
* Gracefully degrades for browsers that don't support the `load` event on links.
* in which case, it immediately resolves, causing a FOUC, but displaying content.
* resolves immediately if the stylesheet has already been loaded.
*/
const linkLoaded = async (
link: HTMLLinkElement,
): Promise => {
return new Promise((resolve, reject) => {
if (!LINK_LOAD_SUPPORTED) {
resolve();
} else if (isLinkLoaded(link)) {
resolve(link.sheet!);
} else {
link.addEventListener('load', () => resolve(link.sheet!), {once: true});
link.addEventListener('error', reject, {once: true});
}
});
};
/**
* Embeds HTML into a document.
*
* The HTML is fetched from the URL contained in the `src` attribute, using the
* fetch() API. A 'load' event is fired when the HTML is updated.
*
* The request is made using CORS by default. This can be chaned with the `mode`
* attribute.
*
* By default, the HTML is embedded into a shadow root. If the `no-shadow`
* attribute is present, the HTML will be embedded into the child content.
*
*/
@customElement('h-include')
export class HeximalInclude extends LitElement {
static override shadowRootOptions = {
mode: 'open',
delegatesFocus: true,
} as const;
static override styles = css`
:host {
display: block;
}
`;
// @ts-expect-error unused property
#fetchTask = new Task(this, {
task: async ([src, mode], {signal}) => {
if (src === undefined) {
return '';
}
const response = await fetch(src, {mode, signal});
if (!response.ok) {
throw new Error(`h-include fetch failed: ${response.statusText}`);
}
return response.text();
},
args: () => [this.src, this.mode] as const,
onComplete: async (value) => {
if (this.noShadow) {
this.innerHTML = value;
// When not using shadow DOM then the consumer is responsible for
// waiting its own resources to load.
} else {
this.shadowRoot!.innerHTML = value;
// Wait for sub resources to load
// TODO (justinfagnani): do we need to wait for iframes and images?
await Promise.all(
[...this.shadowRoot!.querySelectorAll('link')].map(linkLoaded),
);
}
this.dispatchEvent(new Event('load'));
},
onError: (error) => {
console.error(error);
// TODO (justinfagnani): dispatch an error event and write error to the
// element content.
},
});
/**
* The URL to fetch an HTML document from.
*
* Setting this property causes a fetch the HTML from the URL.
*/
@property({reflect: true})
accessor src: string | undefined;
/**
* The fetch mode to use: "cors", "no-cors", or "same-origin".
* See the fetch() documents for more information.
*
* Setting this property does not re-fetch the HTML.
*/
@property({reflect: true})
accessor mode: RequestMode | undefined;
/**
* If true, replaces the innerHTML of this element with the text response
* fetch. Setting this property does not re-fetch the HTML.
*/
@property({type: Boolean, attribute: 'no-shadow', reflect: true})
accessor noShadow: boolean = false;
override update(changedProperties: PropertyValues) {
super.update(changedProperties);
if (changedProperties.has('noShadow')) {
// TODO (justinfagnani): re-fetch? clear innerHTML?
if (this.noShadow) {
this.shadowRoot!.innerHTML = '';
} else {
this.shadowRoot!.innerHTML = '';
}
}
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
super.createRenderRoot();
return document.createDocumentFragment();
}
}
declare global {
interface HTMLElementTagNameMap {
'h-include': HeximalInclude;
}
}