element-book
Version:
An [`element-vir`](https://npmjs.com/package/element-vir) drop-in element for building, testing, and demonstrating a collection of elements (or, in other words, a design system).
331 lines (327 loc) • 12.9 kB
JavaScript
import { check } from '@augment-vir/assert';
import { extractErrorMessage, makeWritable } from '@augment-vir/common';
import { waitForAnimationFrame } from '@augment-vir/web';
import { colorCss } from '@electrovir/color';
import { css, defineElement, defineElementEvent, html, listen, nothing } from 'element-vir';
import { applyCssVarsViaStyleElement } from 'lit-css-vars';
import { themeDefaultKey } from 'theme-vir';
import { ViraError, viraTheme, viraThemeDarkOverride } from 'vira';
import { createNewControls, updateTreeControls, } from '../../../data/book-entry/book-page/controls-wrapper.js';
import { createBookTreeFromEntries } from '../../../data/book-tree/book-tree.js';
import { searchFlattenedNodes } from '../../../data/book-tree/search-nodes.js';
import { createBookRouter } from '../../../routing/book-router.js';
import { defaultBookFullRoute, extractSearchQuery, } from '../../../routing/book-routing.js';
import { setThemeCssVars } from '../../color-theme/color-theme.js';
import { createTheme } from '../../color-theme/create-color-theme.js';
import { ChangeRouteEvent } from '../../events/change-route.event.js';
import { BookNav } from '../book-nav/book-nav.element.js';
import { BookPageControls } from '../entry-display/book-page/book-page-controls.element.js';
import { BookEntryDisplay } from '../entry-display/entry-display/book-entry-display.element.js';
import { getCurrentNodes } from './get-current-nodes.js';
/**
* The element-book app itself. Instantiate one of these where you want your element-book pages to
* render. Make sure to also provide an array of pages to actually render!
*
* @category Main
*/
export const ElementBookApp = defineElement()({
tagName: 'element-book-app',
state() {
return {
currentRoute: defaultBookFullRoute,
router: undefined,
loading: true,
colors: {
config: undefined,
theme: createTheme(undefined),
},
treeBasedControls: undefined,
originalWindowTitle: undefined,
isDarkMode: globalThis.matchMedia('(prefers-color-scheme: dark)').matches,
/** Cleanup callback for the dark mode media query listener. */
darkModeCleanup: undefined,
};
},
events: {
pathUpdate: defineElementEvent(),
},
slotNames: [
/**
* Used to specify a footer for the main element example viewer. It always appears at the
* bottom of the viewer's scroll area.
*/
'footer',
/**
* Used to specify a header above the navigation sidebar. This is a particularly good place
* for branding.
*/
'navHeader',
],
styles: css `
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
font-family: sans-serif;
${colorCss(viraTheme.colors[themeDefaultKey])}
}
.root {
flex-grow: 1;
width: 100%;
display: flex;
position: relative;
}
${BookEntryDisplay} {
flex-grow: 1;
max-height: 100%;
}
${BookNav} {
flex-shrink: 0;
overflow-x: hidden;
overflow-y: auto;
max-height: 100%;
top: 0;
max-width: min(400px, 40%);
}
`,
cleanup({ state, updateState }) {
if (state.router) {
state.router.destroy();
}
if (state.darkModeCleanup) {
state.darkModeCleanup();
}
updateState({
router: undefined,
darkModeCleanup: undefined,
});
},
render: ({ state, inputs, host, updateState, dispatch, events, slotNames }) => {
if (inputs._debug) {
console.info('rendering element-book app');
}
function mergeRoutes(newRouteInput) {
return {
...state.currentRoute,
...newRouteInput,
};
}
function areRoutesNew(newRouteInput) {
const newRoute = mergeRoutes(newRouteInput);
return !check.jsonEquals(state.currentRoute, newRoute);
}
function updateWindowTitle(topNodeTitle) {
if (!inputs.preventWindowTitleChange) {
if (!state.originalWindowTitle) {
updateState({
originalWindowTitle: document.title,
});
}
document.title = [
state.originalWindowTitle,
topNodeTitle,
]
.filter(check.isTruthy)
.join(' - ');
}
}
function updateRoutes(newRouteInput) {
if (!areRoutesNew(newRouteInput)) {
return;
}
const newRoute = mergeRoutes(newRouteInput);
if (state.router) {
state.router.setRoute(newRoute);
}
else {
updateState({
currentRoute: {
...state.currentRoute,
...newRoute,
},
});
}
if (inputs.elementBookRoutePaths &&
!check.jsonEquals(inputs.elementBookRoutePaths, state.currentRoute.paths)) {
dispatch(new events.pathUpdate(newRoute.paths));
}
}
try {
if (inputs.elementBookRoutePaths &&
!check.jsonEquals(inputs.elementBookRoutePaths, state.currentRoute.paths)) {
updateRoutes({
paths: makeWritable(inputs.elementBookRoutePaths),
});
}
if (inputs.internalRouterConfig?.useInternalRouter && !state.router) {
const router = createBookRouter(inputs.internalRouterConfig.basePath);
updateState({
router,
});
router.listen(true, (fullRoute) => {
updateState({
currentRoute: fullRoute,
});
});
}
else if (!inputs.internalRouterConfig?.useInternalRouter && state.router) {
state.router.destroy();
}
const inputThemeConfig = {
themeColor: inputs.themeColor,
};
if (!check.jsonEquals(inputThemeConfig, state.colors.config)) {
const newTheme = createTheme(inputThemeConfig);
updateState({
colors: {
config: inputThemeConfig,
theme: newTheme,
},
});
setThemeCssVars(host, newTheme);
}
if (!state.darkModeCleanup) {
const query = globalThis.matchMedia('(prefers-color-scheme: dark)');
const listener = (event) => {
updateState({
isDarkMode: event.matches,
});
};
query.addEventListener('change', listener);
updateState({
isDarkMode: query.matches,
darkModeCleanup: () => {
query.removeEventListener('change', listener);
},
});
}
const isDarkMode = inputs.darkMode ?? state.isDarkMode;
applyCssVarsViaStyleElement(isDarkMode ? viraThemeDarkOverride.overrides : {}, 'element-book-dark-mode');
const debug = inputs._debug ?? false;
const originalTree = createBookTreeFromEntries({
entries: inputs.pages,
debug,
});
if (!state.treeBasedControls ||
state.treeBasedControls.pages !== inputs.pages ||
state.treeBasedControls.lastGlobalInputs !== inputs.globalValues) {
if (inputs._debug) {
console.info('regenerating global controls');
}
updateState({
treeBasedControls: {
pages: inputs.pages,
lastGlobalInputs: inputs.globalValues ?? {},
controls: updateTreeControls(originalTree.tree, {
children: state.treeBasedControls?.controls.children,
controls: inputs.globalValues,
}),
},
});
}
const searchQuery = extractSearchQuery(state.currentRoute.paths);
const searchedNodes = searchQuery
? searchFlattenedNodes({
flattenedNodes: originalTree.flattenedNodes,
searchQuery,
})
: undefined;
const currentNodes = searchedNodes ??
getCurrentNodes(originalTree.flattenedNodes, state.currentRoute.paths, updateRoutes);
updateWindowTitle(currentNodes[0]?.entry.title);
const currentControls = state.treeBasedControls?.controls;
if (!currentControls) {
return html `
<${ViraError}>Failed to generate page controls.</${ViraError}>
`;
}
if (inputs._debug) {
console.info({
currentControls,
});
}
return html `
<div
class="root"
${listen(ChangeRouteEvent, (event) => {
const newRoute = event.detail;
if (!areRoutesNew(newRoute)) {
return;
}
updateState({
loading: true,
});
updateRoutes(newRoute);
const navElement = host.shadowRoot.querySelector(BookNav.tagName);
if (!(navElement instanceof BookNav)) {
throw new TypeError(`Failed to find child '${BookNav.tagName}'`);
}
})}
${listen(BookPageControls.events.controlValueChange, (event) => {
if (!state.treeBasedControls) {
return;
}
const newControls = createNewControls(currentControls, event.detail.fullUrlBreadcrumbs, event.detail.newValues);
updateState({
treeBasedControls: {
...state.treeBasedControls,
controls: newControls,
},
});
})}
>
${inputs.blockNavigation
? nothing
: html `
<${BookNav.assign({
flattenedNodes: originalTree.flattenedNodes,
router: state.router,
selectedPath: searchQuery
? undefined
: state.currentRoute.paths.slice(1),
})}>
<slot name=${slotNames.navHeader}></slot>
</${BookNav}>
`}
<${BookEntryDisplay.assign({
blockNavigation: !!inputs.blockNavigation,
controls: currentControls,
currentNodes,
currentRoute: state.currentRoute,
debug,
originalTree: originalTree.tree,
router: state.router,
showLoading: state.loading,
})}
${listen(BookEntryDisplay.events.loadingRender, async (event) => {
await waitForAnimationFrame();
const entryDisplay = host.shadowRoot.querySelector(BookEntryDisplay.tagName);
if (entryDisplay) {
entryDisplay.scroll({
top: 0,
behavior: 'instant',
});
}
else {
console.error(`Failed to find '${BookEntryDisplay.tagName}' for scrolling.`);
}
await waitForAnimationFrame();
updateState({
loading: !event.detail,
});
})}
>
<slot name=${slotNames.footer}></slot>
</${BookEntryDisplay}>
</div>
`;
}
catch (error) {
console.error(error);
return html `
<${ViraError}>${extractErrorMessage(error)}</${ViraError}>
`;
}
},
});