element-book 26.12.1 → 26.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ export type BookEntry = BookPage | BookRoot | BookElementExample;
10
10
  /**
11
11
  * Check if the input is a book entry of the given type.
12
12
  *
13
- * @internal
13
+ * @category Internal
14
14
  */
15
15
  export declare function isBookEntry<const SpecificType extends BookEntryType>(entry: unknown, entryType: SpecificType): entry is Extract<BookEntry, {
16
16
  entryType: SpecificType;
@@ -2,7 +2,7 @@ import { check } from '@augment-vir/assert';
2
2
  /**
3
3
  * Check if the input is a book entry of the given type.
4
4
  *
5
- * @internal
5
+ * @category Internal
6
6
  */
7
7
  export function isBookEntry(entry, entryType) {
8
8
  return check.hasKey(entry, 'entryType') && entry.entryType === entryType;
@@ -21,7 +21,7 @@ export type BookPageControlInit<ControlType extends BookPageControlType> = Omit<
21
21
  /**
22
22
  * Checks and type guards that the input page control init is of the given type.
23
23
  *
24
- * @internal
24
+ * @category Internal
25
25
  */
26
26
  export declare function isControlInitType<const SpecificControlType extends BookPageControlType>(controlInit: BookPageControlInit<any>, specificType: SpecificControlType): controlInit is BookPageControlInit<SpecificControlType>;
27
27
  /**
@@ -87,6 +87,6 @@ export type BookPageControlValueType = typeof controlValueTypes;
87
87
  /**
88
88
  * Checks that the given control init object is valid.
89
89
  *
90
- * @internal
90
+ * @category Internal
91
91
  */
92
92
  export declare function checkControls(controlsInit: BookPageControlsInitBase | undefined, pageName: string): Error[];
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Checks and type guards that the input page control init is of the given type.
3
3
  *
4
- * @internal
4
+ * @category Internal
5
5
  */
6
6
  export function isControlInitType(controlInit, specificType) {
7
7
  return controlInit.controlType === specificType;
@@ -47,7 +47,7 @@ export const controlValueTypes = {
47
47
  /**
48
48
  * Checks that the given control init object is valid.
49
49
  *
50
- * @internal
50
+ * @category Internal
51
51
  */
52
52
  export function checkControls(controlsInit, pageName) {
53
53
  if (!controlsInit) {
@@ -2,6 +2,7 @@
2
2
  import { check } from '@augment-vir/assert';
3
3
  import { BookEntryType } from '../book-entry-type.js';
4
4
  import { titleToUrlBreadcrumb } from '../url-breadcrumbs.js';
5
+ import { getPageTitleError } from '../verify-book-entry.js';
5
6
  /**
6
7
  * A variant of {@link defineBookPage} that allows you specify what the expected global element-book
7
8
  * values are for the page that you are defining.
@@ -41,6 +42,7 @@ export function defineBookPage(pageInit) {
41
42
  errors: [
42
43
  alreadyTakenElementExampleNames.has(elementExampleInit.title) &&
43
44
  new Error(`Example title '${elementExampleInit.title}' in page '${pageInit.title}' is already taken.`),
45
+ getPageTitleError(elementExampleInit.title),
44
46
  ].filter(check.isTruthy),
45
47
  };
46
48
  alreadyTakenElementExampleNames.add(elementExampleInit.title);
@@ -2,20 +2,20 @@ import { type BookEntry } from './book-entry.js';
2
2
  /**
3
3
  * Create a list of URL breadcrumbs for the given element-book entry.
4
4
  *
5
- * @internal
5
+ * @category Internal
6
6
  */
7
7
  export declare function listUrlBreadcrumbs(entry: BookEntry, includeSelf: boolean): string[];
8
8
  /**
9
9
  * Convert an element-book entry's title to a URL-safe breadcrumb title.
10
10
  *
11
- * @internal
11
+ * @category Internal
12
12
  */
13
13
  export declare function titleToUrlBreadcrumb(title: string): string;
14
14
  /**
15
15
  * Check if a full list of URL breadcrumbs (`searchIn`) starts with the subset list of URL
16
16
  * breadcrumbs (`searchFor`).
17
17
  *
18
- * @internal
18
+ * @category Internal
19
19
  */
20
20
  export declare function doBreadcrumbsStartWith({ searchFor, searchIn, }: {
21
21
  searchIn: ReadonlyArray<string>;
@@ -2,7 +2,7 @@ import { collapseWhiteSpace } from '@augment-vir/common';
2
2
  /**
3
3
  * Create a list of URL breadcrumbs for the given element-book entry.
4
4
  *
5
- * @internal
5
+ * @category Internal
6
6
  */
7
7
  export function listUrlBreadcrumbs(entry, includeSelf) {
8
8
  const entryBreadcrumb = titleToUrlBreadcrumb(entry.title);
@@ -22,7 +22,7 @@ export function listUrlBreadcrumbs(entry, includeSelf) {
22
22
  /**
23
23
  * Convert an element-book entry's title to a URL-safe breadcrumb title.
24
24
  *
25
- * @internal
25
+ * @category Internal
26
26
  */
27
27
  export function titleToUrlBreadcrumb(title) {
28
28
  return collapseWhiteSpace(title).toLowerCase().replaceAll(/\s/g, '-');
@@ -31,7 +31,7 @@ export function titleToUrlBreadcrumb(title) {
31
31
  * Check if a full list of URL breadcrumbs (`searchIn`) starts with the subset list of URL
32
32
  * breadcrumbs (`searchFor`).
33
33
  *
34
- * @internal
34
+ * @category Internal
35
35
  */
36
36
  export function doBreadcrumbsStartWith({ searchFor, searchIn, }) {
37
37
  return searchFor.every((breadcrumb, index) => {
@@ -1,3 +1,4 @@
1
1
  import { BookEntryType } from './book-entry-type.js';
2
2
  import { type BookEntry } from './book-entry.js';
3
+ export declare function getPageTitleError(title: string): Error | undefined;
3
4
  export declare const bookEntryVerifiers: { [EntryType in BookEntryType]: (entry: BookEntry) => Error[]; };
@@ -1,14 +1,27 @@
1
1
  import { check } from '@augment-vir/assert';
2
2
  import { BookEntryType } from './book-entry-type.js';
3
3
  import { checkControls } from './book-page/book-page-controls.js';
4
+ import { titleToUrlBreadcrumb } from './url-breadcrumbs.js';
5
+ /** Characters that are not allowed in book entry titles because they would break URL routing. */
6
+ const invalidTitleCharacters = /[/?#&=]/;
7
+ export function getPageTitleError(title) {
8
+ const invalidMatch = title.match(invalidTitleCharacters);
9
+ return title.trim()
10
+ ? titleToUrlBreadcrumb(title)
11
+ ? invalidMatch
12
+ ? new Error(`Book page title has invalid character '${invalidMatch[0]}'.`)
13
+ : undefined
14
+ : new Error(`Book page title resolved to empty breadcrumb.`)
15
+ : new Error(`Cannot define an element-book page with an empty title.`);
16
+ }
4
17
  export const bookEntryVerifiers = {
5
18
  [BookEntryType.ElementExample]: () => {
6
- // currently all element example checking happens on page definition
19
+ /** Currently all element example checking happens on page definition. */
7
20
  return [];
8
21
  },
9
22
  [BookEntryType.Page]: (bookPage) => {
10
23
  return [
11
- !bookPage.title && new Error(`Cannot define an element-book page with an empty title.`),
24
+ getPageTitleError(bookPage.title),
12
25
  ...checkControls(bookPage.controls, bookPage.title),
13
26
  ].filter(check.isTruthy);
14
27
  },
@@ -0,0 +1,13 @@
1
+ import { type HtmlInterpolation } from 'element-vir';
2
+ export declare const BookLazyEntry: import("element-vir").DeclarativeElementDefinition<"book-lazy-entry", {
3
+ /**
4
+ * The content to lazily render. This cannot be rendered via a `<slot>` because then they won't
5
+ * be lazily rendered.
6
+ */
7
+ content: HtmlInterpolation;
8
+ }, {
9
+ /** Whether the content has been rendered (and should stay rendered). */
10
+ hasRendered: boolean;
11
+ /** Reference to the placeholder element for cleanup. */
12
+ placeholderElement: Element | undefined;
13
+ }, {}, "book-lazy-entry-", "book-lazy-entry-", readonly [], readonly []>;
@@ -0,0 +1,115 @@
1
+ import { convertDuration } from 'date-vir';
2
+ import { css, html, onDomCreated } from 'element-vir';
3
+ import { defineBookElement } from '../../define-book-element.js';
4
+ /** Debounce time in milliseconds before rendering an entry that becomes visible. */
5
+ const visibilityDebounce = {
6
+ milliseconds: 10,
7
+ };
8
+ /**
9
+ * Shared IntersectionObserver instance for all BookLazyEntry elements. Using a single observer is
10
+ * more performant than creating one per element.
11
+ */
12
+ let sharedObserver;
13
+ /** Map of observed elements to their callbacks. */
14
+ const observedElements = new Map();
15
+ /** Map of elements to their debounce timeout IDs. */
16
+ const debounceTimeouts = new Map();
17
+ function getSharedObserver() {
18
+ if (!sharedObserver) {
19
+ sharedObserver = new IntersectionObserver((entries) => {
20
+ for (const entry of entries) {
21
+ const element = entry.target;
22
+ const callback = observedElements.get(element);
23
+ if (!callback) {
24
+ continue;
25
+ }
26
+ if (entry.isIntersecting) {
27
+ // Element became visible - start debounce timer
28
+ if (!debounceTimeouts.has(element)) {
29
+ const timeoutId = globalThis.setTimeout(() => {
30
+ debounceTimeouts.delete(element);
31
+ callback();
32
+ // Stop observing after rendering
33
+ sharedObserver?.unobserve(element);
34
+ observedElements.delete(element);
35
+ }, convertDuration(visibilityDebounce, {
36
+ milliseconds: true,
37
+ }).milliseconds);
38
+ debounceTimeouts.set(element, timeoutId);
39
+ }
40
+ }
41
+ else {
42
+ // Element left viewport - cancel debounce if pending
43
+ const existingTimeout = debounceTimeouts.get(element);
44
+ if (existingTimeout) {
45
+ clearTimeout(existingTimeout);
46
+ debounceTimeouts.delete(element);
47
+ }
48
+ }
49
+ }
50
+ }, {
51
+ /** Use a small margin to start loading slightly before the element is visible. */
52
+ rootMargin: '100px',
53
+ });
54
+ }
55
+ return sharedObserver;
56
+ }
57
+ function unobserveElement(element) {
58
+ const timeout = debounceTimeouts.get(element);
59
+ if (timeout) {
60
+ clearTimeout(timeout);
61
+ debounceTimeouts.delete(element);
62
+ }
63
+ observedElements.delete(element);
64
+ sharedObserver?.unobserve(element);
65
+ }
66
+ export const BookLazyEntry = defineBookElement()({
67
+ tagName: 'book-lazy-entry',
68
+ state() {
69
+ return {
70
+ /** Whether the content has been rendered (and should stay rendered). */
71
+ hasRendered: false,
72
+ /** Reference to the placeholder element for cleanup. */
73
+ placeholderElement: undefined,
74
+ };
75
+ },
76
+ styles: css `
77
+ :host {
78
+ display: contents;
79
+ }
80
+
81
+ .placeholder {
82
+ /* Minimum height to ensure the placeholder is observable */
83
+ min-height: 50px;
84
+ display: block;
85
+ }
86
+ `,
87
+ cleanup({ state }) {
88
+ if (state.placeholderElement) {
89
+ unobserveElement(state.placeholderElement);
90
+ }
91
+ },
92
+ render({ inputs, state, updateState }) {
93
+ if (state.hasRendered) {
94
+ return inputs.content;
95
+ }
96
+ return html `
97
+ <div
98
+ class="placeholder"
99
+ ${onDomCreated((element) => {
100
+ // Clean up previous observation if any
101
+ if (state.placeholderElement) {
102
+ unobserveElement(state.placeholderElement);
103
+ }
104
+ updateState({ placeholderElement: element });
105
+ observedElements.set(element, () => {
106
+ updateState({ hasRendered: true });
107
+ });
108
+ getSharedObserver().observe(element);
109
+ })}
110
+ >
111
+ &nbsp;
112
+ </div>
113
+ `;
114
+ },
115
+ });
@@ -8,6 +8,7 @@ import { BookError } from '../../common/book-error.element.js';
8
8
  import { BookPageControls } from '../book-page/book-page-controls.element.js';
9
9
  import { BookPageWrapper } from '../book-page/book-page-wrapper.element.js';
10
10
  import { BookElementExampleWrapper } from '../element-example/book-element-example-wrapper.element.js';
11
+ import { BookLazyEntry } from './book-lazy-entry.element.js';
11
12
  function getFlattenedControlsFromHiddenParents(currentNodes, currentControls, currentNode, originalTree) {
12
13
  const parent = traverseToImmediateParent(currentNode, originalTree);
13
14
  const allControls = [];
@@ -81,7 +82,7 @@ export function createNodeTemplates({ currentNodes, isTopLevel, router, isSearch
81
82
  }
82
83
  else if (isBookTreeNode(currentNode, BookEntryType.ElementExample)) {
83
84
  const controlsForElementExample = traverseControls(controls, currentNode.fullUrlBreadcrumbs.slice(0, -1));
84
- return html `
85
+ const content = html `
85
86
  <${BookElementExampleWrapper.assign({
86
87
  elementExampleNode: currentNode,
87
88
  currentPageControls: controlsForElementExample,
@@ -92,18 +93,28 @@ export function createNodeTemplates({ currentNodes, isTopLevel, router, isSearch
92
93
  })}"
93
94
  ></${BookElementExampleWrapper}>
94
95
  `;
96
+ return html `
97
+ <${BookLazyEntry.assign({
98
+ content,
99
+ })}></${BookLazyEntry}>
100
+ `;
95
101
  }
96
102
  else if (isBookTreeNode(currentNode, BookEntryType.Root)) {
97
103
  return nothing;
98
104
  }
99
105
  else {
100
- return html `
106
+ const content = html `
101
107
  <${BookError.assign({
102
108
  message: `Unknown entry type for rendering: '${currentNode.entry.entryType}'`,
103
109
  })}
104
110
  class="block-entry"
105
111
  ></${BookError}>
106
112
  `;
113
+ return html `
114
+ <${BookLazyEntry.assign({
115
+ content,
116
+ })}></${BookLazyEntry}>
117
+ `;
107
118
  }
108
119
  });
109
120
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "element-book",
3
- "version": "26.12.1",
3
+ "version": "26.13.0",
4
4
  "keywords": [
5
5
  "book",
6
6
  "design system",
@@ -41,28 +41,29 @@
41
41
  "test:docs": "virmator docs check"
42
42
  },
43
43
  "dependencies": {
44
- "@augment-vir/assert": "^31.54.3",
45
- "@augment-vir/common": "^31.54.3",
46
- "@augment-vir/web": "^31.54.3",
44
+ "@augment-vir/assert": "^31.57.3",
45
+ "@augment-vir/common": "^31.57.3",
46
+ "@augment-vir/web": "^31.57.3",
47
47
  "colorjs.io": "0.5.2",
48
+ "date-vir": "^8.1.0",
48
49
  "lit-css-vars": "^3.0.11",
49
50
  "spa-router-vir": "^6.4.1",
50
51
  "typed-event-target": "^4.1.0"
51
52
  },
52
53
  "devDependencies": {
53
- "@augment-vir/test": "^31.54.3",
54
+ "@augment-vir/test": "^31.57.3",
54
55
  "@web/dev-server-esbuild": "^1.0.4",
55
56
  "@web/test-runner": "^0.20.2",
56
57
  "@web/test-runner-commands": "^0.9.0",
57
58
  "@web/test-runner-playwright": "^0.11.1",
58
59
  "@web/test-runner-visual-regression": "^0.10.0",
59
- "element-vir": "^26.12.1",
60
+ "element-vir": "^26.13.0",
60
61
  "istanbul-smart-text-reporter": "^1.1.5",
61
62
  "markdown-code-example-inserter": "^3.0.3",
62
63
  "type-fest": "^5.3.1",
63
64
  "typedoc": "^0.28.15",
64
65
  "typescript": "5.9.3",
65
- "vira": "^28.11.0"
66
+ "vira": "^28.13.0"
66
67
  },
67
68
  "peerDependencies": {
68
69
  "element-vir": ">=17",