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).
302 lines (297 loc) • 12 kB
JavaScript
import { check } from '@augment-vir/assert';
import { extractErrorMessage, makeWritable } from '@augment-vir/common';
import { waitForAnimationFrame } from '@augment-vir/web';
import { css, defineElement, defineElementEvent, html, listen } from 'element-vir';
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 { colorThemeCssVars, 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, scrollSelectedNavElementIntoView } from '../book-nav/book-nav.element.js';
import { BookError } from '../common/book-error.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 { ElementBookSlotName } from './element-book-app-slots.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,
};
},
events: {
pathUpdate: defineElementEvent(),
},
styles: css `
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
font-family: sans-serif;
background-color: ${colorThemeCssVars['element-book-page-background-color'].value};
color: ${colorThemeCssVars['element-book-page-foreground-color'].value};
}
.error {
color: red;
}
.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%);
}
`,
init({ host, state }) {
setTimeout(async () => {
await scrollNav(host, extractSearchQuery(state.currentRoute.paths), state.currentRoute);
}, 500);
},
cleanup({ state, updateState }) {
if (state.router) {
state.router.destroy();
updateState({ router: undefined });
}
},
render: ({ state, inputs, host, updateState, dispatch, events }) => {
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);
}
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 `
<${BookError.assign({
message: 'Failed to generate page controls.',
})}></${BookError}>
`;
}
if (inputs._debug) {
console.info({ currentControls });
}
return html `
<div
class="root"
${listen(ChangeRouteEvent, async (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}'`);
}
await scrollNav(host, searchQuery, state.currentRoute);
})}
${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,
},
});
})}
>
<${BookNav.assign({
flattenedNodes: originalTree.flattenedNodes,
router: state.router,
selectedPath: searchQuery ? undefined : state.currentRoute.paths.slice(1),
})}>
<slot
name=${ElementBookSlotName.NavHeader}
slot=${ElementBookSlotName.NavHeader}
></slot>
</${BookNav}>
<${BookEntryDisplay.assign({
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=${ElementBookSlotName.Footer}
slot=${ElementBookSlotName.Footer}
></slot>
</${BookEntryDisplay}>
</div>
`;
}
catch (error) {
console.error(error);
return html `
<p class="error">${extractErrorMessage(error)}</p>
`;
}
},
});
async function scrollNav(host, searchQuery, currentRoutes) {
/** If there is a search query, then there will be no selected nav to scroll to. */
if (searchQuery) {
return;
}
if (currentRoutes.paths.length <= 1) {
return;
}
const navElement = host.shadowRoot.querySelector(BookNav.tagName);
if (!(navElement instanceof BookNav)) {
throw new TypeError(`Failed to find child '${BookNav.tagName}'`);
}
await scrollSelectedNavElementIntoView(navElement);
}