UNPKG

element-vir

Version:

Heroic. Reactive. Declarative. Type safe. Web components without compromise.

706 lines (558 loc) 22.9 kB
# element-vir Heroic. Reactive. Declarative. Type safe. Web components without compromise. A wrapper for [lit-element](http://lit.dev) that adds type-safe custom element usage and I/O with declarative element definition. No need for an extra build step,<br> no need for side effect imports, <br> no need for unique file extensions,<br> no need for more static analysis tooling,<br> no need for a dedicated, unique syntax.<br> _**It's just JavaScript.**_<br> <sup>Or TypeScript, if you're into that!</sup> Uses the power of _native_ JavaScript custom web elements, _native_ JavaScript template literals, _native_ JavaScript functions, _native_ HTML, and [lit-element](http://lit.dev). [Works in every major web browser except Internet Explorer.](https://caniuse.com/mdn-api_window_customelements) Try it out on CodePen! https://codepen.io/electrovir/pen/qBwQYxq # Install [Published on npm:](https://www.npmjs.com/package/element-vir) ```bash npm i element-vir ``` Make sure to install this as a normal dependency (not just a dev dependency) because it needs to exist at run time. # Usage Most usage of this package is done through the `defineElement` functions. See the [`DeclarativeElementInit`](https://github.com/electrovir/element-vir/blob/dev/packages/src/declarative-element/declarative-element-init.ts) type for that function's full inputs. The inputs are also described below with examples. All of [`lit`](https://lit.dev)'s syntax and functionality is available for use if you wish. ## Simple element definition Use `defineElement` to define your element. Here is a bare-minimum example custom element: <!-- example-link: src/readme-examples/my-simple.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html} from 'element-vir'; export const MySimple = defineElement()({ tagName: 'my-simple', render() { return html` <span>Hello there!</span> `; }, }); ``` Make sure to export your element definition if you need to use it in other files. ## Using in other elements To use already defined elements (like the example above), they must be interpolated into HTML templates like so: <!-- example-link: src/readme-examples/my-app.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html} from 'element-vir'; import {MySimple} from './my-simple.element.js'; export const MyApp = defineElement()({ tagName: 'my-app', render() { return html` <h1>My App</h1> <${MySimple}></${MySimple}> `; }, }); ``` This requirement ensures that the element is properly imported and registered with the browser. (Compare to pure [lit](http://lit.dev) where you must remember to import each element file as a side effect, or without actually referencing any of its exports in your code.) ## Adding styles Styles are added through the `styles` property when defining a declarative element (similar to [how they are defined in `lit`](https://lit.dev/docs/components/styles/)): <!-- example-link: src/readme-examples/my-with-styles.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {css, html} from 'element-vir'; export const MyWithStyles = defineElement()({ tagName: 'my-with-styles', styles: css` :host { display: flex; flex-direction: column; font-family: sans-serif; } span + span { margin-top: 16px; } `, render() { return html` <span>Hello there!</span> <span>How are you doing?</span> `; }, }); ``` ### Interpolated CSS tag selectors Declarative element definitions can be used in the `css` tagged template just like in the `html` tagged template. This will be replaced by the element's tag name: <!-- example-link: src/readme-examples/my-with-styles-and-interpolated-selector.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {css, html} from 'element-vir'; import {MySimple} from './my-simple.element.js'; export const MyWithStylesAndInterpolatedSelector = defineElement()({ tagName: 'my-with-styles-and-interpolated-selector', styles: css` ${MySimple} { background-color: blue; } `, render() { return html` <${MySimple}></${MySimple}> `; }, }); ``` ## Defining and using Inputs Define element inputs by using `defineElement` to define a declarative element. Pass your input type, if any, as a generic to the `defineElement` call. Then call _that_ with the normal definition input. To use an element's inputs for use in its template, grab `inputs` from `render`'s parameters and interpolate it into your HTML template: <!-- example-link: src/readme-examples/my-with-inputs.element.ts --> ```TypeScript import {defineElement, html} from 'element-vir'; export const MyWithInputs = defineElement<{ username: string; email: string; }>()({ tagName: 'my-with-inputs', render({inputs}) { return html` <span>Hello there ${inputs.username}!</span> `; }, }); ``` ## Defining internal state Define initial internal state values and types with the `stateInit` property when defining an element. Grab it with `state` in `render` to use state. Grab `updateState` in `render` to update state: <!-- example-link: src/readme-examples/my-with-update-state.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html, listen} from 'element-vir'; export const MyWithUpdateState = defineElement()({ tagName: 'my-with-update-state', state() { return { username: 'dev', /** * Use "as" to create state properties that can be types other than the initial value's * type. This is particularly useful when, as below, the initial value is undefined. */ email: undefined as string | undefined, }; }, render({state, updateState}) { return html` <span ${listen('click', () => { updateState({username: 'new name!'}); })} > Hello there ${state.username}! </span> `; }, }); ``` ### Assigning inputs Use the `assign` directive to assign values to child custom elements inputs: <!-- example-link: src/readme-examples/my-with-assignment.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html} from 'element-vir'; import {MyWithInputs} from './my-with-inputs.element.js'; export const MyWithAssignment = defineElement()({ tagName: 'my-with-assignment', render() { return html` <h1>My App</h1> <${MyWithInputs.assign({ email: 'user@example.com', username: 'user', })}></${MyWithInputs}> `; }, }); ``` ## Other callbacks There are two other callbacks you can define that are sort of similar to lifecycle callbacks. They are much simpler than lifecycle callbacks however. - `init`: called right before the first render and has all state and inputs setup. (This is similar to `connectedCallback` in standard HTMLElement classes but is fired much later, after inputs are assigned, to avoid race conditions.) - `cleanup`: called when an element is removed from the DOM. (This is the same as the `disconnectedCallback` in standard HTMLElement classes.) <!-- example-link: src/readme-examples/my-with-cleanup-callback.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html} from 'element-vir'; export const MyWithAssignmentCleanupCallback = defineElement()({ tagName: 'my-with-cleanup-callback', state() { return { intervalId: window.setInterval(() => console.info('hi'), 1000), }; }, render() { return html` <h1>My App</h1> `; }, cleanup({state}) { window.clearInterval(state.intervalId); }, }); ``` ## Element events (outputs) When defining a declarative element, use `events` to setup event names and types. Each event must be initialized with `defineElementEvent` and a type parameter but no run-time inputs. To dispatch an event, grab `dispatch` and `events` from `render`'s parameters. <!-- example-link: src/readme-examples/my-with-events.element.ts --> ```TypeScript import {randomInteger} from '@augment-vir/common'; import {defineElement} from 'element-vir'; import {defineElementEvent, html, listen} from 'element-vir'; export const MyWithEvents = defineElement()({ tagName: 'my-with-events', events: { logoutClick: defineElementEvent<void>(), randomNumber: defineElementEvent<number>(), }, render({dispatch, events}) { return html` <button ${listen('click', () => dispatch(new events.logoutClick()))}>log out</button> <button ${listen('click', () => dispatch(new events.randomNumber(randomInteger({min: 0, max: 1_000_000}))), )} > generate random number </button> `; }, }); ``` ### Listening to element events (outputs) Use the `listen` directive to listen to events emitted by your custom elements: <!-- example-link: src/readme-examples/my-with-event-listening.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html, listen} from 'element-vir'; import {MyWithEvents} from './my-with-events.element.js'; export const MyWithEventListening = defineElement()({ tagName: 'my-with-event-listening', state() { return { myNumber: -1, }; }, render({state, updateState}) { return html` <h1>My App</h1> <${MyWithEvents} ${listen(MyWithEvents.events.logoutClick, () => { console.info('logout triggered'); })} ${listen(MyWithEvents.events.randomNumber, (event) => { updateState({myNumber: event.detail}); })} ></${MyWithEvents}> <span>${state.myNumber}</span> `; }, }); ``` `listen` can also be used to listen to native DOM events (like `click`) and the proper event type will be provided for the listener callback. ## Typed events without an element Create a custom event type with `defineTypedEvent`. Make sure to include the type parameter and call it twice, the second time with the event type name string to ensure type safety when using your event. Note that event type names should be unique, or they will clash with each other. <!-- example-link: src/readme-examples/my-custom-action.event.ts --> ```TypeScript import {defineTypedEvent} from 'element-vir'; export const MyCustomActionEvent = defineTypedEvent<number>()('my-custom-action'); ``` ### Using a typed event Dispatching a custom event and listening to a custom event is the same as doing so for element events: <!-- example-link: src/readme-examples/my-with-custom-events.element.ts --> ```TypeScript import {randomInteger} from '@augment-vir/common'; import {defineElement} from 'element-vir'; import {html, listen} from 'element-vir'; import {MyCustomActionEvent} from './my-custom-action.event.js'; export const MyWithCustomEvents = defineElement()({ tagName: 'my-with-custom-events', render({dispatch}) { return html` <div ${listen(MyCustomActionEvent, (event) => { console.info(`Got a number! ${event.detail}`); })} > <div ${listen('click', () => { dispatch(new MyCustomActionEvent(randomInteger({min: 0, max: 1_000_000}))); })} ></div> </div> `; }, }); ``` ## Host classes ### Defining host classes Host classes can be defined and used with type safety. Host classes are used to provide alternative styles for custom elements. They are purely driven by CSS and are thus applied to the the `class` HTML attribute. Host classes are defined by passing an object to `hostClasses` at element definition time. Each property name in the `hostClasses` object creates a host class name (note that host class names must start with the element's tag name). Each value in the `hostClasses` object defines behavior for the host class: - if the value is a callback, that host class will automatically be applied if the callback returns true after a render is executed. - if the value is `false`, the host class is never automatically applied, it must be manually applied by consumers. Apply host classes in the element's stylesheet by using a callback for the styles property: <!-- example-link: src/readme-examples/my-with-host-class-definition.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {css, html} from 'element-vir'; export const MyWithHostClassDefinition = defineElement()({ tagName: 'my-with-host-class-definition', state() { return { myProp: 'hello there', }; }, hostClasses: { /** * Setting the value to false means this host class will never be automatically applied. It * will simply be a static member on the element for manual application in consumers. */ 'my-with-host-class-definition-a': false, /** * This host class will be automatically applied if the given callback is evaluated to true * after a call to render. */ 'my-with-host-class-definition-automatic': ({state}) => { return state.myProp === 'foo'; }, }, /** * Apply styles to the host classes by using a callback for "styles". The callback's argument * contains the host classes defined above in the "hostClasses" property. */ styles: ({hostClasses}) => css` ${hostClasses['my-with-host-class-definition-automatic'].selector} { color: blue; } ${hostClasses['my-with-host-class-definition-a'].selector} { color: red; } `, render({state}) { return html` ${state.myProp} `; }, }); ``` ### Applying host classes To apply a host class in a consumer, access the child element's `.hostClasses` property: <!-- example-link: src/readme-examples/my-with-host-class-usage.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html} from 'element-vir'; import {MyWithHostClassDefinition} from './my-with-host-class-definition.element.js'; export const MyWithHostClassUsage = defineElement()({ tagName: 'my-with-host-class-usage', render() { return html` <${MyWithHostClassDefinition} class=${MyWithHostClassDefinition.hostClasses['my-with-host-class-definition-a']} ></${MyWithHostClassDefinition}> `; }, }); ``` ## CSS Vars Typed CSS variables are created in a similar manner to host classes: <!-- example-link: src/readme-examples/my-with-css-vars.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {css, html} from 'element-vir'; export const MyWithCssVars = defineElement()({ tagName: 'my-with-css-vars', cssVars: { /** The value assigned here ('blue') becomes the fallback value for this CSS var. */ 'my-with-css-vars-my-var': 'blue', }, styles: ({cssVars}) => css` :host { /* Set CSS vars (or reference the name directly) via the ".name" property */ ${cssVars['my-with-css-vars-my-var'].name}: yellow; /* Use CSS vars with the ".value" property. This includes a "var" wrapper and the assigned fallback value (which in this case is 'blue'). */ color: ${cssVars['my-with-css-vars-my-var'].value}; } `, render() { return html``; }, }); ``` ## Custom Type Requirements Use `wrapDefineElement` to compose `defineElement`. This is particularly useful to adding restrictions on the element `tagName`, but it can be used for restricting any of the type parameters: <!-- example-link: src/readme-examples/my-custom-define.ts --> ```TypeScript import {wrapDefineElement} from 'element-vir'; export type VirTagName = `vir-${string}`; export const defineVirElement = wrapDefineElement<VirTagName>(); // add an optional assert callback export const defineVerifiedVirElement = wrapDefineElement<VirTagName>({ assertInputs: (inputs) => { if (!inputs.tagName.startsWith('vir-')) { throw new Error(`all custom elements must start with "vir-"`); } }, }); // add an optional transform callback export const defineTransformedVirElement = wrapDefineElement<VirTagName>({ transformInputs: (inputs) => { return { ...inputs, tagName: inputs.tagName.startsWith('vir-') ? `vir-${inputs.tagName}` : inputs.tagName, }; }, }); ``` ## Directives The following custom [`lit` directives](https://lit.dev/docs/templates/custom-directives/) are contained within this package. All [built-in `lit` directives](https://lit.dev/docs/templates/directives/) are also exported by `element-vir`. ### `onDomCreated` This triggers only once when the element it's attached to has actually been created in the DOM. If the attached element changes, the callback will be triggered again. <!-- example-link: src/readme-examples/my-with-on-dom-created.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html, onDomCreated} from 'element-vir'; export const MyWithOnDomCreated = defineElement()({ tagName: 'my-with-on-dom-created', render() { return html` <span ${onDomCreated((element) => { // logs a span element console.info(element); })} > Hello there! </span> `; }, }); ``` ### `onResize` This directive fires its callback whenever the element it's attached to resizes. The callback is passed an object with a portion of the [`ResizeObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) properties. <!-- example-link: src/readme-examples/my-with-on-resize.element.ts --> ```TypeScript import {defineElement} from 'element-vir'; import {html, onResize} from 'element-vir'; export const MyWithOnResize = defineElement()({ tagName: 'my-with-on-resize', render() { return html` <span ${onResize((entry) => { // this will track resizing of this span // the entry parameter contains target and contentRect properties console.info(entry); })} > Hello there! </span> `; }, }); ``` ### `listen` Listen to a specific event. This is explained in the **Listening to element events (outputs)** section earlier. ### `renderIf` Use the `renderIf` directive to easily render a template if a given condition is true. <!-- example-link: src/readme-examples/my-with-render-if.element.ts --> ```TypeScript import {defineElement, html, renderIf} from 'element-vir'; export const MyWithRenderIf = defineElement<{shouldRender: boolean}>()({ tagName: 'my-with-render-if', render({inputs}) { return html` ${renderIf( inputs.shouldRender, html` I'm conditionally rendered! `, )} `; }, }); ``` ### `asyncProp` Use `renderAsync` or `isResolved` in conjunction with `asyncProp` to seamlessly render and update element state based on async values: <!-- example-link: src/readme-examples/my-with-async-prop.element.ts --> ```TypeScript import {asyncProp, defineElement, html, listen, renderAsync} from 'element-vir'; type EndpointData = number[]; async function loadSomething(endpoint: string): Promise<EndpointData> { // load something from the network const data = await ( await fetch( [ '', 'api', endpoint, ].join('/'), ) ).json(); return data; } export const MyWithAsyncProp = defineElement<{endpoint: string}>()({ tagName: 'my-with-async-prop', state() { return { data: asyncProp({ async updateCallback({endpoint}: {endpoint: string}) { return loadSomething(endpoint); }, }), hi: '', }; }, render({inputs, state}) { /** * This causes the a promise which automatically updates the state.data prop once the * promise resolves. It only creates a new promise if the first input, the trigger, value * changes from previous calls. */ state.data.update(inputs); return html` Here's the data: <br /> ${renderAsync(state.data, 'Loading...', (loadedData) => { return html` Got the data: ${loadedData} `; })} <br /> <button ${listen('click', () => { /** You can force asyncProp to update by calling forceUpdate. */ state.data.forceUpdate(inputs); })} > Refresh </button> `; }, }); ``` ## Require all child custom elements to be declarative elements To require all child elements to be declarative elements defined by this package, call `requireAllCustomElementsToBeDeclarativeElements` anywhere in your app. This is a global setting so do not enable it unless you want it to be true _everywhere_ in your current run-time. This should not be used if you're using custom elements from other libraries (unless they happen to also use this package to define their custom elements). <!-- example-link: src/readme-examples/require-declarative-element.ts --> ```TypeScript import {requireAllCustomElementsToBeDeclarativeElements} from 'element-vir'; requireAllCustomElementsToBeDeclarativeElements(); ``` # Dev ## markdown out of date If you see this: `Code in Markdown file(s) is out of date. Run without --check to update. code-in-markdown failed.`, run `npm run docs:update` to fix it. ## Testing source map errors If you see ``` Error while reading source maps for ... ``` While running `npm test`, don't worry about it. Those only happen when tests fail and are not indicative of any problem beyond the test failure reasons.