shelving 1.239.0 → 1.240.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.239.0",
3
+ "version": "1.240.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import type { ReactElement } from "react";
2
+ import type { ImmutableArray } from "../../util/array.js";
2
3
  import { type TagProps } from "../misc/Tag.js";
3
4
  import type { UIColor } from "../style/Color.js";
4
5
  /**
@@ -30,3 +31,28 @@ export declare function getDocumentationKindColor(kind: string): UIColor | undef
30
31
  * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationKind/DocumentationKind
31
32
  */
32
33
  export declare function DocumentationKind({ kind, ...props }: DocumentationKindProps): ReactElement;
34
+ /**
35
+ * Props for `DocumentationKindChips` — the kinds to offer plus the currently-selected kind.
36
+ *
37
+ * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationKind/DocumentationKindChipsProps
38
+ */
39
+ export interface DocumentationKindChipsProps {
40
+ /** The kinds to show as chips, in display order. */
41
+ readonly kinds: ImmutableArray<string>;
42
+ /** The currently-selected kind, or `undefined` when none is selected. */
43
+ readonly value?: string | undefined;
44
+ /** Called with the clicked kind, or `undefined` when the active chip is clicked again to clear it. */
45
+ readonly onValue?: ((kind: string | undefined) => void) | undefined;
46
+ }
47
+ /**
48
+ * Row of clickable kind-filter chips — clicking a chip selects that kind, clicking the active chip clears it.
49
+ * - Exclusive: at most one kind is selected at a time. The selected chip is shown bold.
50
+ * - Renders `null` when there are no `kinds`.
51
+ *
52
+ * @kind component
53
+ * @param props The `kinds` to offer, the selected `value`, and an `onValue` callback.
54
+ * @returns A `<Row>` of colour-coded `<Tag>` chips, or `null` when there are no kinds.
55
+ * @example <DocumentationKindChips kinds={["function", "class"]} value={kind} onValue={setKind} />
56
+ * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationKind/DocumentationKindChips
57
+ */
58
+ export declare function DocumentationKindChips({ kinds, value, onValue }: DocumentationKindChipsProps): ReactElement | null;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Tag } from "../misc/Tag.js";
3
+ import { Row } from "../style/Flex.js";
3
4
  /**
4
5
  * Mapping from a documented symbol's `kind` to its raw colour variant.
5
6
  * - Related kinds share a hue: component/class (purple), function/method (blue), interface/type (aqua).
@@ -40,3 +41,19 @@ export function getDocumentationKindColor(kind) {
40
41
  export function DocumentationKind({ kind = "unknown", ...props }) {
41
42
  return (_jsx(Tag, { color: getDocumentationKindColor(kind), ...props, children: kind }));
42
43
  }
44
+ /**
45
+ * Row of clickable kind-filter chips — clicking a chip selects that kind, clicking the active chip clears it.
46
+ * - Exclusive: at most one kind is selected at a time. The selected chip is shown bold.
47
+ * - Renders `null` when there are no `kinds`.
48
+ *
49
+ * @kind component
50
+ * @param props The `kinds` to offer, the selected `value`, and an `onValue` callback.
51
+ * @returns A `<Row>` of colour-coded `<Tag>` chips, or `null` when there are no kinds.
52
+ * @example <DocumentationKindChips kinds={["function", "class"]} value={kind} onValue={setKind} />
53
+ * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationKind/DocumentationKindChips
54
+ */
55
+ export function DocumentationKindChips({ kinds, value, onValue }) {
56
+ if (!kinds.length)
57
+ return null;
58
+ return (_jsx(Row, { left: true, wrap: true, children: kinds.map(kind => (_jsx(Tag, { color: getDocumentationKindColor(kind), weight: kind === value ? "strong" : undefined, onClick: () => onValue?.(kind === value ? undefined : kind), children: kind }, kind))) }));
59
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ReactElement } from "react";
2
+ import type { ImmutableArray } from "../../util/array.js";
2
3
  import { Tag, type TagProps } from "../misc/Tag.js";
3
4
  import type { UIColor } from "../style/Color.js";
5
+ import { Row } from "../style/Flex.js";
4
6
 
5
7
  /**
6
8
  * Props for `DocumentationKind` — a `TagProps` plus the documented symbol's `kind`.
@@ -58,3 +60,46 @@ export function DocumentationKind({ kind = "unknown", ...props }: DocumentationK
58
60
  </Tag>
59
61
  );
60
62
  }
63
+
64
+ /**
65
+ * Props for `DocumentationKindChips` — the kinds to offer plus the currently-selected kind.
66
+ *
67
+ * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationKind/DocumentationKindChipsProps
68
+ */
69
+ export interface DocumentationKindChipsProps {
70
+ /** The kinds to show as chips, in display order. */
71
+ readonly kinds: ImmutableArray<string>;
72
+ /** The currently-selected kind, or `undefined` when none is selected. */
73
+ readonly value?: string | undefined;
74
+ /** Called with the clicked kind, or `undefined` when the active chip is clicked again to clear it. */
75
+ readonly onValue?: ((kind: string | undefined) => void) | undefined;
76
+ }
77
+
78
+ /**
79
+ * Row of clickable kind-filter chips — clicking a chip selects that kind, clicking the active chip clears it.
80
+ * - Exclusive: at most one kind is selected at a time. The selected chip is shown bold.
81
+ * - Renders `null` when there are no `kinds`.
82
+ *
83
+ * @kind component
84
+ * @param props The `kinds` to offer, the selected `value`, and an `onValue` callback.
85
+ * @returns A `<Row>` of colour-coded `<Tag>` chips, or `null` when there are no kinds.
86
+ * @example <DocumentationKindChips kinds={["function", "class"]} value={kind} onValue={setKind} />
87
+ * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationKind/DocumentationKindChips
88
+ */
89
+ export function DocumentationKindChips({ kinds, value, onValue }: DocumentationKindChipsProps): ReactElement | null {
90
+ if (!kinds.length) return null;
91
+ return (
92
+ <Row left wrap>
93
+ {kinds.map(kind => (
94
+ <Tag
95
+ key={kind}
96
+ color={getDocumentationKindColor(kind)}
97
+ weight={kind === value ? "strong" : undefined}
98
+ onClick={() => onValue?.(kind === value ? undefined : kind)}
99
+ >
100
+ {kind}
101
+ </Tag>
102
+ ))}
103
+ </Row>
104
+ );
105
+ }
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment } from "react";
3
- import { queryElements } from "../../util/element.js";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, useMemo, useState } from "react";
3
+ import { walkElements } from "../../util/element.js";
4
+ import { searchTree } from "../../util/tree.js";
4
5
  import { Block } from "../block/Block.js";
5
6
  import { Definitions } from "../block/Definitions.js";
6
7
  import { Heading } from "../block/Heading.js";
@@ -10,6 +11,7 @@ import { Preformatted } from "../block/Preformatted.js";
10
11
  import { Prose } from "../block/Prose.js";
11
12
  import { Header, Section } from "../block/Section.js";
12
13
  import { Title } from "../block/Title.js";
14
+ import { TextInput } from "../form/TextInput.js";
13
15
  import { Code } from "../inline/Code.js";
14
16
  import { Markup } from "../misc/Markup.js";
15
17
  import { Page } from "../page/Page.js";
@@ -17,7 +19,7 @@ import { Row } from "../style/Flex.js";
17
19
  import { TreeBreadcrumbs } from "../tree/TreeBreadcrumbs.js";
18
20
  import { TreeCards } from "../tree/TreeCards.js";
19
21
  import { DocumentationButtons } from "./DocumentationButtons.js";
20
- import { DocumentationKind, getDocumentationKindColor } from "./DocumentationKind.js";
22
+ import { DocumentationKind, DocumentationKindChips, getDocumentationKindColor } from "./DocumentationKind.js";
21
23
  import { DocumentationSignatures } from "./DocumentationSignatures.js";
22
24
  const DEFAULT_TYPE = "unknown";
23
25
  /** Documentation `kind`s grouped into card sections, in display order — pluralised, sentence-case headings. */
@@ -31,6 +33,46 @@ const KIND_SECTIONS = {
31
33
  method: "Methods",
32
34
  property: "Properties",
33
35
  };
36
+ /** Render a list of tree elements grouped into kind-based card sections, in `KIND_SECTIONS` order. */
37
+ function _renderSections(elements) {
38
+ return Object.entries(KIND_SECTIONS).map(([kind, label]) => {
39
+ const group = elements.filter(el => el.props.kind === kind);
40
+ return group.length ? (_jsxs(Section, { wide: true, children: [_jsx(Heading, { children: label }), _jsx(TreeCards, { children: group })] }, kind)) : null;
41
+ });
42
+ }
43
+ /**
44
+ * Interactive children listing for a documentation page — a filter input, kind chips, and grouped cards.
45
+ *
46
+ * - Filters this page's own children as you type (ranked via `searchTree`, capped at 20); the kind chips narrow to a single `kind`.
47
+ * - With an empty filter and no chip selected, shows the normal grouped listing of `children`.
48
+ * - Renders nothing when the page has no children (e.g. a leaf symbol) — no point showing an empty filter.
49
+ *
50
+ * @param props The page's child elements.
51
+ * @returns The filter controls plus the grouped card sections, or `null` when there are no children.
52
+ */
53
+ function DocumentationChildren({ elements }) {
54
+ const [query, setQuery] = useState("");
55
+ const [chip, setChip] = useState(undefined);
56
+ const childElements = useMemo(() => Array.from(walkElements(elements)), [elements]);
57
+ // Kinds present in this page's children, in section order, for the chip row.
58
+ const kinds = useMemo(() => Object.keys(KIND_SECTIONS).filter(k => childElements.some(el => el.props.kind === k)), [childElements]);
59
+ // No children → nothing to list or filter.
60
+ if (!childElements.length)
61
+ return null;
62
+ const trimmed = query.trim();
63
+ const active = !!trimmed || !!chip;
64
+ // Inactive → normal grouped listing. Active → filter this page's children, grouped the same way.
65
+ let listing;
66
+ if (!active) {
67
+ listing = _renderSections(childElements);
68
+ }
69
+ else {
70
+ const scope = { type: "tree-element", key: "", props: { name: "", children: elements } };
71
+ const filter = chip ? { kind: chip } : undefined;
72
+ listing = _renderSections(searchTree(scope, trimmed, { limit: 20, filter }));
73
+ }
74
+ return (_jsxs(_Fragment, { children: [_jsxs(Section, { wide: true, children: [_jsx(TextInput, { name: "filter", title: "Filter", placeholder: "Filter\u2026", value: query, onValue: v => setQuery(v ?? "") }), _jsx(DocumentationKindChips, { kinds: kinds, value: chip, onValue: setChip })] }), listing] }));
75
+ }
34
76
  /**
35
77
  * Page renderer for a `tree-documentation` element — the full detail page for a documented symbol.
36
78
  * - Renders breadcrumbs, title (with kind + `readonly` tags), relational links (`member of`, `extends`, `implements`), signatures (one per overload), content, parameters, returns, throws, and examples.
@@ -44,9 +86,5 @@ const KIND_SECTIONS = {
44
86
  * @see https://dhoulb.github.io/shelving/ui/docs/DocumentationPage/DocumentationPage
45
87
  */
46
88
  export function DocumentationPage({ title, name, kind = "unknown", description, content, signatures, params, returns, throws, examples, children, ...props }) {
47
- return (_jsx(Page, { title: title ?? name, description: description, children: _jsxs(Block, { color: getDocumentationKindColor(kind), children: [_jsx(Panel, { children: _jsxs(Header, { wide: true, children: [_jsx(TreeBreadcrumbs, {}), _jsx(Title, { children: _jsxs(Row, { left: true, wrap: true, children: [title ?? name, kind && _jsx(DocumentationKind, { kind: kind, size: "normal" })] }) }), _jsx(DocumentationButtons, { ...props })] }) }), signatures?.length || params?.length || returns?.length || throws?.length ? (_jsxs(Section, { wide: true, children: [_jsx(DocumentationSignatures, { signatures: signatures }), params?.length && (_jsxs(Section, { wide: true, children: [_jsx(Label, { children: "Parameters" }), _jsx(Definitions, { children: params.map(({ name, type = DEFAULT_TYPE, description = "", optional }) => (_jsxs(Fragment, { children: [_jsx("dt", { children: _jsxs(Code, { size: "normal", children: [name, optional ? "?" : "", ": ", type] }) }), _jsx("dd", { children: description })] }, `${name}-${type}-${description}`))) })] })), returns?.length && (_jsxs(Section, { wide: true, children: [_jsx(Label, { children: "Returns" }), _jsx(Definitions, { children: returns.map(({ type = DEFAULT_TYPE, description = "" }) => (_jsxs(Fragment, { children: [_jsx("dt", { children: _jsx(Code, { size: "normal", children: type }) }), _jsx("dd", { children: description })] }, `${type}-${description}`))) })] })), throws?.length && (_jsxs(Section, { wide: true, children: [_jsx(Label, { children: "Throws" }), _jsx(Definitions, { children: throws.map(({ type = DEFAULT_TYPE, description = "" }) => (_jsxs(Fragment, { children: [_jsx("dt", { children: _jsx(Code, { size: "normal", children: type }) }), _jsx("dd", { children: description })] }, `${type}-${description}`))) })] }))] })) : null, content && (_jsx(Section, { wide: true, children: _jsx(Prose, { children: _jsx(Markup, { children: content }) }) })), examples?.length && (_jsxs(Section, { wide: true, children: [_jsx(Heading, { children: "Examples" }), examples.map(({ description }) => (_jsx(Preformatted, { children: description }, description)))] })), Object.entries(KIND_SECTIONS).map(([kind, label]) => {
48
- // Pre-filter the children for this kind; only render the section when it has cards.
49
- const group = Array.from(queryElements(children, { "props.kind": kind }));
50
- return group.length ? (_jsxs(Section, { wide: true, children: [_jsx(Heading, { children: label }), _jsx(TreeCards, { children: group })] }, kind)) : null;
51
- })] }) }));
89
+ return (_jsx(Page, { title: title ?? name, description: description, children: _jsxs(Block, { color: getDocumentationKindColor(kind), children: [_jsx(Panel, { children: _jsxs(Header, { wide: true, children: [_jsx(TreeBreadcrumbs, {}), _jsx(Title, { children: _jsxs(Row, { left: true, wrap: true, children: [title ?? name, kind && _jsx(DocumentationKind, { kind: kind, size: "normal" })] }) }), _jsx(DocumentationButtons, { ...props })] }) }), signatures?.length || params?.length || returns?.length || throws?.length ? (_jsxs(Section, { wide: true, children: [_jsx(DocumentationSignatures, { signatures: signatures }), params?.length && (_jsxs(Section, { wide: true, children: [_jsx(Label, { children: "Parameters" }), _jsx(Definitions, { children: params.map(({ name, type = DEFAULT_TYPE, description = "", optional }) => (_jsxs(Fragment, { children: [_jsx("dt", { children: _jsxs(Code, { size: "normal", children: [name, optional ? "?" : "", ": ", type] }) }), _jsx("dd", { children: description })] }, `${name}-${type}-${description}`))) })] })), returns?.length && (_jsxs(Section, { wide: true, children: [_jsx(Label, { children: "Returns" }), _jsx(Definitions, { children: returns.map(({ type = DEFAULT_TYPE, description = "" }) => (_jsxs(Fragment, { children: [_jsx("dt", { children: _jsx(Code, { size: "normal", children: type }) }), _jsx("dd", { children: description })] }, `${type}-${description}`))) })] })), throws?.length && (_jsxs(Section, { wide: true, children: [_jsx(Label, { children: "Throws" }), _jsx(Definitions, { children: throws.map(({ type = DEFAULT_TYPE, description = "" }) => (_jsxs(Fragment, { children: [_jsx("dt", { children: _jsx(Code, { size: "normal", children: type }) }), _jsx("dd", { children: description })] }, `${type}-${description}`))) })] }))] })) : null, content && (_jsx(Section, { wide: true, children: _jsx(Prose, { children: _jsx(Markup, { children: content }) }) })), examples?.length && (_jsxs(Section, { wide: true, children: [_jsx(Heading, { children: "Examples" }), examples.map(({ description }) => (_jsx(Preformatted, { children: description }, description)))] })), _jsx(DocumentationChildren, { elements: children })] }) }));
52
90
  }
@@ -1,7 +1,8 @@
1
- import { Fragment, type ReactNode } from "react";
2
- import { type Element, queryElements } from "../../util/element.js";
1
+ import { Fragment, type ReactNode, useMemo, useState } from "react";
2
+ import { walkElements } from "../../util/element.js";
3
3
  import type { Query } from "../../util/query.js";
4
- import type { DocumentationElementProps, TreeElement } from "../../util/tree.js";
4
+ import type { DocumentationElementProps, TreeElement, TreeElements } from "../../util/tree.js";
5
+ import { searchTree } from "../../util/tree.js";
5
6
  import { Block } from "../block/Block.js";
6
7
  import { Definitions } from "../block/Definitions.js";
7
8
  import { Heading } from "../block/Heading.js";
@@ -11,6 +12,7 @@ import { Preformatted } from "../block/Preformatted.js";
11
12
  import { Prose } from "../block/Prose.js";
12
13
  import { Header, Section } from "../block/Section.js";
13
14
  import { Title } from "../block/Title.js";
15
+ import { TextInput } from "../form/TextInput.js";
14
16
  import { Code } from "../inline/Code.js";
15
17
  import { Markup } from "../misc/Markup.js";
16
18
  import { Page } from "../page/Page.js";
@@ -18,7 +20,7 @@ import { Row } from "../style/Flex.js";
18
20
  import { TreeBreadcrumbs } from "../tree/TreeBreadcrumbs.js";
19
21
  import { TreeCards } from "../tree/TreeCards.js";
20
22
  import { DocumentationButtons } from "./DocumentationButtons.js";
21
- import { DocumentationKind, getDocumentationKindColor } from "./DocumentationKind.js";
23
+ import { DocumentationKind, DocumentationKindChips, getDocumentationKindColor } from "./DocumentationKind.js";
22
24
  import { DocumentationSignatures } from "./DocumentationSignatures.js";
23
25
 
24
26
  const DEFAULT_TYPE = "unknown";
@@ -35,6 +37,68 @@ const KIND_SECTIONS = {
35
37
  property: "Properties",
36
38
  };
37
39
 
40
+ /** Render a list of tree elements grouped into kind-based card sections, in `KIND_SECTIONS` order. */
41
+ function _renderSections(elements: readonly TreeElement[]): ReactNode {
42
+ return Object.entries(KIND_SECTIONS).map(([kind, label]) => {
43
+ const group = elements.filter(el => (el.props as DocumentationElementProps).kind === kind);
44
+ return group.length ? (
45
+ <Section wide key={kind}>
46
+ <Heading>{label}</Heading>
47
+ <TreeCards>{group}</TreeCards>
48
+ </Section>
49
+ ) : null;
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Interactive children listing for a documentation page — a filter input, kind chips, and grouped cards.
55
+ *
56
+ * - Filters this page's own children as you type (ranked via `searchTree`, capped at 20); the kind chips narrow to a single `kind`.
57
+ * - With an empty filter and no chip selected, shows the normal grouped listing of `children`.
58
+ * - Renders nothing when the page has no children (e.g. a leaf symbol) — no point showing an empty filter.
59
+ *
60
+ * @param props The page's child elements.
61
+ * @returns The filter controls plus the grouped card sections, or `null` when there are no children.
62
+ */
63
+ function DocumentationChildren({ elements }: { readonly elements?: TreeElements }): ReactNode {
64
+ const [query, setQuery] = useState("");
65
+ const [chip, setChip] = useState<string | undefined>(undefined);
66
+
67
+ const childElements = useMemo(() => Array.from(walkElements<TreeElement>(elements)), [elements]);
68
+
69
+ // Kinds present in this page's children, in section order, for the chip row.
70
+ const kinds = useMemo(
71
+ () => Object.keys(KIND_SECTIONS).filter(k => childElements.some(el => (el.props as DocumentationElementProps).kind === k)),
72
+ [childElements],
73
+ );
74
+
75
+ // No children → nothing to list or filter.
76
+ if (!childElements.length) return null;
77
+
78
+ const trimmed = query.trim();
79
+ const active = !!trimmed || !!chip;
80
+
81
+ // Inactive → normal grouped listing. Active → filter this page's children, grouped the same way.
82
+ let listing: ReactNode;
83
+ if (!active) {
84
+ listing = _renderSections(childElements);
85
+ } else {
86
+ const scope: TreeElement = { type: "tree-element", key: "", props: { name: "", children: elements } };
87
+ const filter = chip ? ({ kind: chip } as Query) : undefined;
88
+ listing = _renderSections(searchTree(scope, trimmed, { limit: 20, filter }));
89
+ }
90
+
91
+ return (
92
+ <>
93
+ <Section wide>
94
+ <TextInput name="filter" title="Filter" placeholder="Filter…" value={query} onValue={v => setQuery(v ?? "")} />
95
+ <DocumentationKindChips kinds={kinds} value={chip} onValue={setChip} />
96
+ </Section>
97
+ {listing}
98
+ </>
99
+ );
100
+ }
101
+
38
102
  /**
39
103
  * Page renderer for a `tree-documentation` element — the full detail page for a documented symbol.
40
104
  * - Renders breadcrumbs, title (with kind + `readonly` tags), relational links (`member of`, `extends`, `implements`), signatures (one per overload), content, parameters, returns, throws, and examples.
@@ -144,16 +208,7 @@ export function DocumentationPage({
144
208
  ))}
145
209
  </Section>
146
210
  )}
147
- {Object.entries(KIND_SECTIONS).map(([kind, label]) => {
148
- // Pre-filter the children for this kind; only render the section when it has cards.
149
- const group = Array.from(queryElements(children, { "props.kind": kind } as Query<Element>)) as TreeElement[];
150
- return group.length ? (
151
- <Section wide key={kind}>
152
- <Heading>{label}</Heading>
153
- <TreeCards>{group}</TreeCards>
154
- </Section>
155
- ) : null;
156
- })}
211
+ <DocumentationChildren elements={children} />
157
212
  </Block>
158
213
  </Page>
159
214
  );
@@ -21,6 +21,7 @@ export interface TreeAppProps extends PossibleMeta {
21
21
  * - Wraps `<App>` with error catching and a sidebar layout.
22
22
  * - The sidebar shows a `<TreeSidebar>` (root as a home link + a menu of its children).
23
23
  * - `/` renders the root via `<TreePage>`; `/**` catches every deeper path and feeds the full sub-path into `<TreePage>`.
24
+ * - URLs that don't match a tree element fall through to a `<Router>` carrying the built-in `<TreeIndexPage>` (`/all`) plus any extra `routes`.
24
25
  * - Element rendering uses the default mappings on `<TreePage>`, `<TreeMenu>`, `<TreeCards>`.
25
26
  * Override by wrapping with `<TreePageMapping>`, `<TreeMenuMapping>`, or `<TreeCardMapping>`.
26
27
  *
@@ -2,6 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { App } from "../app/App.js";
3
3
  import { SidebarLayout } from "../layout/SidebarLayout.js";
4
4
  import { PageCatcher } from "../misc/Catcher.js";
5
+ import { Router } from "../router/Router.js";
6
+ import { TREE_INDEX_PATH, TreeIndexPage } from "./TreeIndexPage.js";
5
7
  import { TreeRouter } from "./TreeRouter.js";
6
8
  import { TreeSidebar } from "./TreeSidebar.js";
7
9
  /**
@@ -10,6 +12,7 @@ import { TreeSidebar } from "./TreeSidebar.js";
10
12
  * - Wraps `<App>` with error catching and a sidebar layout.
11
13
  * - The sidebar shows a `<TreeSidebar>` (root as a home link + a menu of its children).
12
14
  * - `/` renders the root via `<TreePage>`; `/**` catches every deeper path and feeds the full sub-path into `<TreePage>`.
15
+ * - URLs that don't match a tree element fall through to a `<Router>` carrying the built-in `<TreeIndexPage>` (`/all`) plus any extra `routes`.
13
16
  * - Element rendering uses the default mappings on `<TreePage>`, `<TreeMenu>`, `<TreeCards>`.
14
17
  * Override by wrapping with `<TreePageMapping>`, `<TreeMenuMapping>`, or `<TreeCardMapping>`.
15
18
  *
@@ -20,5 +23,7 @@ import { TreeSidebar } from "./TreeSidebar.js";
20
23
  * @see https://dhoulb.github.io/shelving/ui/tree/TreeApp/TreeApp
21
24
  */
22
25
  export function TreeApp({ tree, routes: extraRoutes, ...meta }) {
23
- return (_jsx(App, { ...meta, children: _jsx(PageCatcher, { children: _jsx(SidebarLayout, { sidebar: _jsx(TreeSidebar, { tree: tree }), children: _jsx(TreeRouter, { tree: tree }) }) }) }));
26
+ // URLs that don't resolve to a tree element fall through to this router: the built-in index page plus any extra routes.
27
+ const fallback = _jsx(Router, { routes: { [TREE_INDEX_PATH]: TreeIndexPage, ...extraRoutes } });
28
+ return (_jsx(App, { ...meta, children: _jsx(PageCatcher, { children: _jsx(SidebarLayout, { sidebar: _jsx(TreeSidebar, { tree: tree }), children: _jsx(TreeRouter, { tree: tree, fallback: fallback }) }) }) }));
24
29
  }
@@ -3,8 +3,10 @@ import type { TreeElement } from "../../util/index.js";
3
3
  import { App } from "../app/App.js";
4
4
  import { SidebarLayout } from "../layout/SidebarLayout.js";
5
5
  import { PageCatcher } from "../misc/Catcher.js";
6
+ import { Router } from "../router/Router.js";
6
7
  import type { Routes } from "../router/Routes.js";
7
8
  import type { PossibleMeta } from "../util/index.js";
9
+ import { TREE_INDEX_PATH, TreeIndexPage } from "./TreeIndexPage.js";
8
10
  import { TreeRouter } from "./TreeRouter.js";
9
11
  import { TreeSidebar } from "./TreeSidebar.js";
10
12
 
@@ -28,6 +30,7 @@ export interface TreeAppProps extends PossibleMeta {
28
30
  * - Wraps `<App>` with error catching and a sidebar layout.
29
31
  * - The sidebar shows a `<TreeSidebar>` (root as a home link + a menu of its children).
30
32
  * - `/` renders the root via `<TreePage>`; `/**` catches every deeper path and feeds the full sub-path into `<TreePage>`.
33
+ * - URLs that don't match a tree element fall through to a `<Router>` carrying the built-in `<TreeIndexPage>` (`/all`) plus any extra `routes`.
31
34
  * - Element rendering uses the default mappings on `<TreePage>`, `<TreeMenu>`, `<TreeCards>`.
32
35
  * Override by wrapping with `<TreePageMapping>`, `<TreeMenuMapping>`, or `<TreeCardMapping>`.
33
36
  *
@@ -38,11 +41,13 @@ export interface TreeAppProps extends PossibleMeta {
38
41
  * @see https://dhoulb.github.io/shelving/ui/tree/TreeApp/TreeApp
39
42
  */
40
43
  export function TreeApp({ tree, routes: extraRoutes, ...meta }: TreeAppProps): ReactElement {
44
+ // URLs that don't resolve to a tree element fall through to this router: the built-in index page plus any extra routes.
45
+ const fallback = <Router routes={{ [TREE_INDEX_PATH]: TreeIndexPage, ...extraRoutes }} />;
41
46
  return (
42
47
  <App {...meta}>
43
48
  <PageCatcher>
44
49
  <SidebarLayout sidebar={<TreeSidebar tree={tree} />}>
45
- <TreeRouter tree={tree} />
50
+ <TreeRouter tree={tree} fallback={fallback} />
46
51
  </SidebarLayout>
47
52
  </PageCatcher>
48
53
  </App>
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from "react";
2
+ import type { AbsolutePath } from "../../util/path.js";
3
+ /** Canonical URL path of the `TreeIndexPage`, wired as a `<TreeApp>` fallback route. */
4
+ export declare const TREE_INDEX_PATH: AbsolutePath;
5
+ /**
6
+ * Page listing every element in the system in one flat, searchable view.
7
+ *
8
+ * - A `<TextInput>` filters as you type; a row of kind chips narrows by `kind` via `searchTree`'s `filter`.
9
+ * - An empty query lists everything (capped at 100); a non-empty query ranks with `searchTree` and caps at 20.
10
+ * - Reads the whole tree from the surrounding `<TreeProvider>` (the flattened map's root), so it works on every page.
11
+ * - Wired as a `<TreeApp>` fallback route at `TREE_INDEX_PATH` (`/all`) — it's not a node in the tree.
12
+ *
13
+ * @kind component
14
+ * @returns A `<Page>` with a search input, kind chips, and a flat card listing of results.
15
+ * @example <Router routes={{ [TREE_INDEX_PATH]: TreeIndexPage }} />
16
+ * @see https://dhoulb.github.io/shelving/ui/tree/TreeIndexPage/TreeIndexPage
17
+ */
18
+ export declare function TreeIndexPage(): ReactNode;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { searchTree } from "../../util/tree.js";
4
+ import { Header, Section } from "../block/Section.js";
5
+ import { Title } from "../block/Title.js";
6
+ import { DocumentationKindChips } from "../docs/DocumentationKind.js";
7
+ import { TextInput } from "../form/TextInput.js";
8
+ import { Page } from "../page/Page.js";
9
+ import { TreeCards } from "./TreeCards.js";
10
+ import { useTreeMap } from "./TreeContext.js";
11
+ /** Canonical URL path of the `TreeIndexPage`, wired as a `<TreeApp>` fallback route. */
12
+ export const TREE_INDEX_PATH = "/all";
13
+ /** Title shown for the index page. */
14
+ const INDEX_TITLE = "All elements";
15
+ /** Description shown for the index page (page `<meta>` description). */
16
+ const INDEX_DESCRIPTION = "Search every documented element in the system.";
17
+ /** Kinds offered as filter chips, in display order — mirrors `DocumentationPage`'s sections. */
18
+ const INDEX_KINDS = ["component", "function", "class", "interface", "type", "constant", "method", "property"];
19
+ /** Cap on the flat listing when there's no query — keeps "show everything" sane. */
20
+ const INDEX_LIMIT = 100;
21
+ /**
22
+ * Page listing every element in the system in one flat, searchable view.
23
+ *
24
+ * - A `<TextInput>` filters as you type; a row of kind chips narrows by `kind` via `searchTree`'s `filter`.
25
+ * - An empty query lists everything (capped at 100); a non-empty query ranks with `searchTree` and caps at 20.
26
+ * - Reads the whole tree from the surrounding `<TreeProvider>` (the flattened map's root), so it works on every page.
27
+ * - Wired as a `<TreeApp>` fallback route at `TREE_INDEX_PATH` (`/all`) — it's not a node in the tree.
28
+ *
29
+ * @kind component
30
+ * @returns A `<Page>` with a search input, kind chips, and a flat card listing of results.
31
+ * @example <Router routes={{ [TREE_INDEX_PATH]: TreeIndexPage }} />
32
+ * @see https://dhoulb.github.io/shelving/ui/tree/TreeIndexPage/TreeIndexPage
33
+ */
34
+ export function TreeIndexPage() {
35
+ const [query, setQuery] = useState("");
36
+ const [chip, setChip] = useState(undefined);
37
+ const root = useTreeMap().get("/");
38
+ // Kinds actually present anywhere in the tree, in display order, for the chip row.
39
+ const kinds = useMemo(() => {
40
+ if (!root)
41
+ return [];
42
+ const all = searchTree(root, "", { limit: Number.POSITIVE_INFINITY });
43
+ return INDEX_KINDS.filter(kind => all.some(el => el.props.kind === kind));
44
+ }, [root]);
45
+ const trimmed = query.trim();
46
+ const filter = chip ? { kind: chip } : undefined;
47
+ // Each element's `key` is its unique canonical path (stamped by `flattenTree()`), so this flat cross-tree listing reconciles correctly.
48
+ const cards = root ? searchTree(root, trimmed, { limit: trimmed ? 20 : INDEX_LIMIT, filter }) : [];
49
+ return (_jsxs(Page, { title: INDEX_TITLE, description: INDEX_DESCRIPTION, children: [_jsx(Header, { wide: true, children: _jsx(Title, { children: INDEX_TITLE }) }), _jsxs(Section, { wide: true, children: [_jsx(TextInput, { name: "search", title: "Search", placeholder: "Search\u2026", value: query, onValue: v => setQuery(v ?? "") }), _jsx(DocumentationKindChips, { kinds: kinds, value: chip, onValue: setChip })] }), _jsx(Section, { wide: true, children: _jsx(TreeCards, { children: cards }) })] }));
50
+ }
@@ -0,0 +1,74 @@
1
+ import { type ReactNode, useMemo, useState } from "react";
2
+ import type { AbsolutePath } from "../../util/path.js";
3
+ import type { Query } from "../../util/query.js";
4
+ import type { DocumentationElementProps } from "../../util/tree.js";
5
+ import { searchTree } from "../../util/tree.js";
6
+ import { Header, Section } from "../block/Section.js";
7
+ import { Title } from "../block/Title.js";
8
+ import { DocumentationKindChips } from "../docs/DocumentationKind.js";
9
+ import { TextInput } from "../form/TextInput.js";
10
+ import { Page } from "../page/Page.js";
11
+ import { TreeCards } from "./TreeCards.js";
12
+ import { useTreeMap } from "./TreeContext.js";
13
+
14
+ /** Canonical URL path of the `TreeIndexPage`, wired as a `<TreeApp>` fallback route. */
15
+ export const TREE_INDEX_PATH = "/all" as AbsolutePath;
16
+
17
+ /** Title shown for the index page. */
18
+ const INDEX_TITLE = "All elements";
19
+
20
+ /** Description shown for the index page (page `<meta>` description). */
21
+ const INDEX_DESCRIPTION = "Search every documented element in the system.";
22
+
23
+ /** Kinds offered as filter chips, in display order — mirrors `DocumentationPage`'s sections. */
24
+ const INDEX_KINDS = ["component", "function", "class", "interface", "type", "constant", "method", "property"];
25
+
26
+ /** Cap on the flat listing when there's no query — keeps "show everything" sane. */
27
+ const INDEX_LIMIT = 100;
28
+
29
+ /**
30
+ * Page listing every element in the system in one flat, searchable view.
31
+ *
32
+ * - A `<TextInput>` filters as you type; a row of kind chips narrows by `kind` via `searchTree`'s `filter`.
33
+ * - An empty query lists everything (capped at 100); a non-empty query ranks with `searchTree` and caps at 20.
34
+ * - Reads the whole tree from the surrounding `<TreeProvider>` (the flattened map's root), so it works on every page.
35
+ * - Wired as a `<TreeApp>` fallback route at `TREE_INDEX_PATH` (`/all`) — it's not a node in the tree.
36
+ *
37
+ * @kind component
38
+ * @returns A `<Page>` with a search input, kind chips, and a flat card listing of results.
39
+ * @example <Router routes={{ [TREE_INDEX_PATH]: TreeIndexPage }} />
40
+ * @see https://dhoulb.github.io/shelving/ui/tree/TreeIndexPage/TreeIndexPage
41
+ */
42
+ export function TreeIndexPage(): ReactNode {
43
+ const [query, setQuery] = useState("");
44
+ const [chip, setChip] = useState<string | undefined>(undefined);
45
+
46
+ const root = useTreeMap().get("/");
47
+
48
+ // Kinds actually present anywhere in the tree, in display order, for the chip row.
49
+ const kinds = useMemo(() => {
50
+ if (!root) return [];
51
+ const all = searchTree(root, "", { limit: Number.POSITIVE_INFINITY });
52
+ return INDEX_KINDS.filter(kind => all.some(el => (el.props as DocumentationElementProps).kind === kind));
53
+ }, [root]);
54
+
55
+ const trimmed = query.trim();
56
+ const filter = chip ? ({ kind: chip } as Query) : undefined;
57
+ // Each element's `key` is its unique canonical path (stamped by `flattenTree()`), so this flat cross-tree listing reconciles correctly.
58
+ const cards = root ? searchTree(root, trimmed, { limit: trimmed ? 20 : INDEX_LIMIT, filter }) : [];
59
+
60
+ return (
61
+ <Page title={INDEX_TITLE} description={INDEX_DESCRIPTION}>
62
+ <Header wide>
63
+ <Title>{INDEX_TITLE}</Title>
64
+ </Header>
65
+ <Section wide>
66
+ <TextInput name="search" title="Search" placeholder="Search…" value={query} onValue={v => setQuery(v ?? "")} />
67
+ <DocumentationKindChips kinds={kinds} value={chip} onValue={setChip} />
68
+ </Section>
69
+ <Section wide>
70
+ <TreeCards>{cards}</TreeCards>
71
+ </Section>
72
+ </Page>
73
+ );
74
+ }
@@ -1,6 +1,6 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import type { AbsolutePath } from "../../util/path.js";
3
- import type { TreeElement } from "../../util/tree.js";
3
+ import { type TreeElement } from "../../util/tree.js";
4
4
  /**
5
5
  * Props for the `TreeSidebar` component — the root tree element plus its URL path.
6
6
  *
@@ -13,15 +13,17 @@ export interface TreeSidebarProps {
13
13
  readonly path?: AbsolutePath | undefined;
14
14
  }
15
15
  /**
16
- * Sidebar built from a tree element.
16
+ * Sidebar built from a tree element, in three sections separated by dividers.
17
17
  *
18
- * - Renders a single "home" `<MenuItem>` for the root element itself, then the root's children as a `<TreeMenuMapper>` underneath.
19
- * - The home link uses `path` as its href (defaulting to `/`). The children's hrefs are computed by appending their `name` to the root's path.
20
- * - To customise child renderers wrap in `<TreeMenuMapping mapping={…}>` (same context as `<TreeMenu>`).
18
+ * - **Top:** a "Home" link to the root and an "All elements" link to the `<TreeIndexPage>` (`/all`).
19
+ * - **Middle:** a `<TextInput>` search-as-you-type filter.
20
+ * - **Bottom:** the root's children as a `<TreeMenuMapper>` — swapped for a flat ranked list of results (capped at 20) while the search holds a query.
21
+ *
22
+ * Child and result hrefs use each element's canonical `path` (or `joinPath(parent, name)` as a fallback). To customise child renderers wrap in `<TreeMenuMapping mapping={…}>` (same context as `<TreeMenu>`).
21
23
  *
22
24
  * @kind component
23
25
  * @param props The root `tree` element and optional root `path`.
24
- * @returns A `<Menu>` with a home link plus the root's children as navigation items.
26
+ * @returns The sectioned sidebar home/index links, a search input, and either the tree menu or search results.
25
27
  * @example <TreeSidebar tree={tree} />
26
28
  * @see https://dhoulb.github.io/shelving/ui/tree/TreeSidebar/TreeSidebar
27
29
  */
@@ -1,20 +1,32 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
2
3
  import { filterElements } from "../../util/element.js";
4
+ import { flattenTree, searchTree } from "../../util/tree.js";
5
+ import { Divider } from "../block/Divider.js";
6
+ import { TextInput } from "../form/TextInput.js";
3
7
  import { Menu, MenuItem } from "../menu/Menu.js";
8
+ import { TREE_INDEX_PATH } from "./TreeIndexPage.js";
4
9
  import { matchMenuElement, TreeMenuMapper } from "./TreeMenu.js";
5
10
  /**
6
- * Sidebar built from a tree element.
11
+ * Sidebar built from a tree element, in three sections separated by dividers.
7
12
  *
8
- * - Renders a single "home" `<MenuItem>` for the root element itself, then the root's children as a `<TreeMenuMapper>` underneath.
9
- * - The home link uses `path` as its href (defaulting to `/`). The children's hrefs are computed by appending their `name` to the root's path.
10
- * - To customise child renderers wrap in `<TreeMenuMapping mapping={…}>` (same context as `<TreeMenu>`).
13
+ * - **Top:** a "Home" link to the root and an "All elements" link to the `<TreeIndexPage>` (`/all`).
14
+ * - **Middle:** a `<TextInput>` search-as-you-type filter.
15
+ * - **Bottom:** the root's children as a `<TreeMenuMapper>` — swapped for a flat ranked list of results (capped at 20) while the search holds a query.
16
+ *
17
+ * Child and result hrefs use each element's canonical `path` (or `joinPath(parent, name)` as a fallback). To customise child renderers wrap in `<TreeMenuMapping mapping={…}>` (same context as `<TreeMenu>`).
11
18
  *
12
19
  * @kind component
13
20
  * @param props The root `tree` element and optional root `path`.
14
- * @returns A `<Menu>` with a home link plus the root's children as navigation items.
21
+ * @returns The sectioned sidebar home/index links, a search input, and either the tree menu or search results.
15
22
  * @example <TreeSidebar tree={tree} />
16
23
  * @see https://dhoulb.github.io/shelving/ui/tree/TreeSidebar/TreeSidebar
17
24
  */
18
25
  export function TreeSidebar({ tree, path = "/" }) {
19
- return (_jsxs(Menu, { children: [_jsx(MenuItem, { href: path, children: tree.props.title ?? tree.props.name }), _jsx(TreeMenuMapper, { path: path, children: filterElements(tree.props.children, matchMenuElement) })] }));
26
+ const [query, setQuery] = useState("");
27
+ const trimmed = query.trim();
28
+ // Flatten once so search results carry a canonical `path` (and a unique `key`) for their links (the sidebar sits outside the router's `<TreeProvider>`).
29
+ const root = useMemo(() => flattenTree(tree).get("/") ?? tree, [tree]);
30
+ const results = trimmed ? searchTree(root, trimmed, { limit: 20 }) : null;
31
+ return (_jsxs(_Fragment, { children: [_jsxs(Menu, { children: [_jsx(MenuItem, { href: path, children: "Home" }), _jsx(MenuItem, { href: TREE_INDEX_PATH, children: "All elements" })] }), _jsx(Divider, {}), _jsx(TextInput, { name: "search", title: "Search", placeholder: "Search\u2026", value: query, onValue: v => setQuery(v ?? "") }), _jsx(Divider, {}), _jsx(Menu, { children: results ? (results.map(el => (_jsx(MenuItem, { href: el.props.path ?? path, children: el.props.title ?? el.props.name }, el.key)))) : (_jsx(TreeMenuMapper, { path: path, children: filterElements(tree.props.children, matchMenuElement) })) })] }));
20
32
  }
@@ -1,8 +1,11 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode, useMemo, useState } from "react";
2
2
  import { filterElements } from "../../util/element.js";
3
3
  import type { AbsolutePath } from "../../util/path.js";
4
- import type { TreeElement } from "../../util/tree.js";
4
+ import { flattenTree, searchTree, type TreeElement } from "../../util/tree.js";
5
+ import { Divider } from "../block/Divider.js";
6
+ import { TextInput } from "../form/TextInput.js";
5
7
  import { Menu, MenuItem } from "../menu/Menu.js";
8
+ import { TREE_INDEX_PATH } from "./TreeIndexPage.js";
6
9
  import { matchMenuElement, TreeMenuMapper } from "./TreeMenu.js";
7
10
 
8
11
  /**
@@ -18,23 +21,48 @@ export interface TreeSidebarProps {
18
21
  }
19
22
 
20
23
  /**
21
- * Sidebar built from a tree element.
24
+ * Sidebar built from a tree element, in three sections separated by dividers.
22
25
  *
23
- * - Renders a single "home" `<MenuItem>` for the root element itself, then the root's children as a `<TreeMenuMapper>` underneath.
24
- * - The home link uses `path` as its href (defaulting to `/`). The children's hrefs are computed by appending their `name` to the root's path.
25
- * - To customise child renderers wrap in `<TreeMenuMapping mapping={…}>` (same context as `<TreeMenu>`).
26
+ * - **Top:** a "Home" link to the root and an "All elements" link to the `<TreeIndexPage>` (`/all`).
27
+ * - **Middle:** a `<TextInput>` search-as-you-type filter.
28
+ * - **Bottom:** the root's children as a `<TreeMenuMapper>` — swapped for a flat ranked list of results (capped at 20) while the search holds a query.
29
+ *
30
+ * Child and result hrefs use each element's canonical `path` (or `joinPath(parent, name)` as a fallback). To customise child renderers wrap in `<TreeMenuMapping mapping={…}>` (same context as `<TreeMenu>`).
26
31
  *
27
32
  * @kind component
28
33
  * @param props The root `tree` element and optional root `path`.
29
- * @returns A `<Menu>` with a home link plus the root's children as navigation items.
34
+ * @returns The sectioned sidebar home/index links, a search input, and either the tree menu or search results.
30
35
  * @example <TreeSidebar tree={tree} />
31
36
  * @see https://dhoulb.github.io/shelving/ui/tree/TreeSidebar/TreeSidebar
32
37
  */
33
38
  export function TreeSidebar({ tree, path = "/" as AbsolutePath }: TreeSidebarProps): ReactNode {
39
+ const [query, setQuery] = useState("");
40
+ const trimmed = query.trim();
41
+
42
+ // Flatten once so search results carry a canonical `path` (and a unique `key`) for their links (the sidebar sits outside the router's `<TreeProvider>`).
43
+ const root = useMemo(() => flattenTree(tree).get("/") ?? tree, [tree]);
44
+ const results = trimmed ? searchTree(root, trimmed, { limit: 20 }) : null;
45
+
34
46
  return (
35
- <Menu>
36
- <MenuItem href={path}>{tree.props.title ?? tree.props.name}</MenuItem>
37
- <TreeMenuMapper path={path}>{filterElements(tree.props.children, matchMenuElement)}</TreeMenuMapper>
38
- </Menu>
47
+ <>
48
+ <Menu>
49
+ <MenuItem href={path}>Home</MenuItem>
50
+ <MenuItem href={TREE_INDEX_PATH}>All elements</MenuItem>
51
+ </Menu>
52
+ <Divider />
53
+ <TextInput name="search" title="Search" placeholder="Search…" value={query} onValue={v => setQuery(v ?? "")} />
54
+ <Divider />
55
+ <Menu>
56
+ {results ? (
57
+ results.map(el => (
58
+ <MenuItem key={el.key} href={el.props.path ?? path}>
59
+ {el.props.title ?? el.props.name}
60
+ </MenuItem>
61
+ ))
62
+ ) : (
63
+ <TreeMenuMapper path={path}>{filterElements(tree.props.children, matchMenuElement)}</TreeMenuMapper>
64
+ )}
65
+ </Menu>
66
+ </>
39
67
  );
40
68
  }
@@ -4,6 +4,7 @@ export * from "./TreeButton.js";
4
4
  export * from "./TreeCard.js";
5
5
  export * from "./TreeCards.js";
6
6
  export * from "./TreeContext.js";
7
+ export * from "./TreeIndexPage.js";
7
8
  export * from "./TreeMenu.js";
8
9
  export * from "./TreePage.js";
9
10
  export * from "./TreeRouter.js";
package/ui/tree/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./TreeButton.js";
4
4
  export * from "./TreeCard.js";
5
5
  export * from "./TreeCards.js";
6
6
  export * from "./TreeContext.js";
7
+ export * from "./TreeIndexPage.js";
7
8
  export * from "./TreeMenu.js";
8
9
  export * from "./TreePage.js";
9
10
  export * from "./TreeRouter.js";
package/ui/tree/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./TreeButton.js";
4
4
  export * from "./TreeCard.js";
5
5
  export * from "./TreeCards.js";
6
6
  export * from "./TreeContext.js";
7
+ export * from "./TreeIndexPage.js";
7
8
  export * from "./TreeMenu.js";
8
9
  export * from "./TreePage.js";
9
10
  export * from "./TreeRouter.js";
package/util/tree.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ImmutableArray } from "./array.js";
2
2
  import type { Element, ElementProps, Elements } from "./element.js";
3
3
  import { type AbsolutePath } from "./path.js";
4
+ import type { Query } from "./query.js";
4
5
  /**
5
6
  * Props for a tree element — must have a `tree-` prefixed type.
6
7
  *
@@ -153,6 +154,7 @@ declare module "react" {
153
154
  * - its **flat key** — bare `name` (e.g. `"BooleanSchema"`), or qualified `"Class.member"` for members (e.g. `"BooleanSchema.validate"`). This is what cross-refs (`extends` / `implements`) and README links resolve through.
154
155
  * - its **canonical path** (`element.props.path`, e.g. `"/schema/BooleanSchema"`) — what the router resolves a URL to.
155
156
  * - The map values keep their (stamped) children, so the map doubles as the navigable tree: `map.get("/")` is the stamped root and flattening never throws away the hierarchy. The router resolves a URL with `map.get(path)`; the static builder enumerates pages from the path-shaped keys.
157
+ * - Each stamped element's own `key` is also set to its canonical `path` — so a flat (cross-tree) listing of stamped elements has globally-unique React keys, where the bare `name` would collide (many `get` / `value` / `url`).
156
158
  * - Exported names are unique across the package (barrel re-exports enforce it at compile time), so flat-key collisions are vanishingly rare; on a collision the last writer simply wins.
157
159
  * - Missing keys (e.g. builtins like `Serializable`) resolve to `undefined` → callers fall back to plain text.
158
160
  *
@@ -163,3 +165,33 @@ declare module "react" {
163
165
  * @see https://dhoulb.github.io/shelving/util/tree/flattenTree
164
166
  */
165
167
  export declare function flattenTree(root: TreeElement, base?: ReadonlyMap<string, TreeElement>): Map<string, TreeElement>;
168
+ /**
169
+ * Options for `searchTree()`.
170
+ *
171
+ * @see https://dhoulb.github.io/shelving/util/tree/SearchTreeOptions
172
+ */
173
+ export interface SearchTreeOptions {
174
+ /** Maximum number of results to return (defaults to `20`). */
175
+ readonly limit?: number | undefined;
176
+ /**
177
+ * Optional `Query` narrowing the candidates by *any* prop before ranking — the same shape `queryItems()` takes.
178
+ * - e.g. `{ kind: "method" }` to only rank methods, or `{ source: "…" }` to constrain by source.
179
+ */
180
+ readonly filter?: Query | undefined;
181
+ }
182
+ /**
183
+ * Search the descendants of a tree element and return the best-ranked matches.
184
+ *
185
+ * - Walks every descendant of `scope` (depth-first; `scope` itself is not a candidate), optionally narrowed by `options.filter`.
186
+ * - Tokenises `query` with `getWords()` so quoted phrases match literally: `searchTree(root, '"hello world" foo')` scores the phrase `hello world` *and* the word `foo` independently, stacking their scores.
187
+ * - Ranks each candidate (case-insensitive) per token, summing: `name` exact > `name` starts-with > `name` includes > `title` includes > `description` includes > `content` includes. A `name` match always outranks a content-only match.
188
+ * - An empty `query` returns the (filtered) candidates in tree order — useful for a filter-only or "show everything" listing.
189
+ *
190
+ * @param scope The element whose descendants are searched.
191
+ * @param query The search string — bare words plus `"quoted phrases"`.
192
+ * @param options `limit` (default `20`) and an optional `filter` `Query` over each candidate's props.
193
+ * @returns The matching descendants, best first, capped at `limit`.
194
+ * @example searchTree(root, "store", { limit: 10, filter: { kind: "class" } }) // up to 10 classes ranked for "store"
195
+ * @see https://dhoulb.github.io/shelving/util/tree/searchTree
196
+ */
197
+ export declare function searchTree(scope: TreeElement, query: string, options?: SearchTreeOptions): TreeElement[];
package/util/tree.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { walkElements } from "./element.js";
2
2
  import { joinPath } from "./path.js";
3
+ import { queryItems } from "./query.js";
4
+ import { getWords } from "./string.js";
3
5
  /**
4
6
  * Flatten a tree into a `Map` for O(1) lookup, stamping a canonical `path` onto every element as it walks.
5
7
  * - Returns a *copy* of the tree, indexed. Each element is rebuilt with its canonical site-root-relative `path`: the root is `/`, each descendant is `parentPath + "/" + name` — so a module `schema` → `/schema`, its class `BooleanSchema` → `/schema/BooleanSchema`, its member `validate` → `/schema/BooleanSchema/validate`. A composite module name (e.g. `"util/string"`) becomes its own multi-segment chunk.
@@ -7,6 +9,7 @@ import { joinPath } from "./path.js";
7
9
  * - its **flat key** — bare `name` (e.g. `"BooleanSchema"`), or qualified `"Class.member"` for members (e.g. `"BooleanSchema.validate"`). This is what cross-refs (`extends` / `implements`) and README links resolve through.
8
10
  * - its **canonical path** (`element.props.path`, e.g. `"/schema/BooleanSchema"`) — what the router resolves a URL to.
9
11
  * - The map values keep their (stamped) children, so the map doubles as the navigable tree: `map.get("/")` is the stamped root and flattening never throws away the hierarchy. The router resolves a URL with `map.get(path)`; the static builder enumerates pages from the path-shaped keys.
12
+ * - Each stamped element's own `key` is also set to its canonical `path` — so a flat (cross-tree) listing of stamped elements has globally-unique React keys, where the bare `name` would collide (many `get` / `value` / `url`).
10
13
  * - Exported names are unique across the package (barrel re-exports enforce it at compile time), so flat-key collisions are vanishingly rare; on a collision the last writer simply wins.
11
14
  * - Missing keys (e.g. builtins like `Serializable`) resolve to `undefined` → callers fall back to plain text.
12
15
  *
@@ -24,7 +27,13 @@ export function flattenTree(root, base) {
24
27
  function _flattenElement(element, path, map) {
25
28
  // Rebuild children first (bottom-up) so the stamped element carries stamped children — the map doubles as the nested tree.
26
29
  const children = Array.from(walkElements(element.props.children), child => _flattenElement(child, joinPath(path, child.props.name), map));
27
- const stamped = { ...element, props: { ...element.props, path, children: children.length ? children : undefined } };
30
+ // Stamp the canonical `path` onto both `props.path` and the element's own `key` the latter gives a flat listing of tree
31
+ // elements globally-unique React keys (bare names like `get` / `value` / `url` collide across the tree).
32
+ const stamped = {
33
+ ...element,
34
+ key: path,
35
+ props: { ...element.props, path, children: children.length ? children : undefined },
36
+ };
28
37
  map.set(_flatKey(stamped), stamped);
29
38
  map.set(path, stamped);
30
39
  return stamped;
@@ -34,3 +43,74 @@ function _flatKey(element) {
34
43
  const { class: className, name } = element.props;
35
44
  return className ? `${className}.${name}` : name;
36
45
  }
46
+ /**
47
+ * Search the descendants of a tree element and return the best-ranked matches.
48
+ *
49
+ * - Walks every descendant of `scope` (depth-first; `scope` itself is not a candidate), optionally narrowed by `options.filter`.
50
+ * - Tokenises `query` with `getWords()` so quoted phrases match literally: `searchTree(root, '"hello world" foo')` scores the phrase `hello world` *and* the word `foo` independently, stacking their scores.
51
+ * - Ranks each candidate (case-insensitive) per token, summing: `name` exact > `name` starts-with > `name` includes > `title` includes > `description` includes > `content` includes. A `name` match always outranks a content-only match.
52
+ * - An empty `query` returns the (filtered) candidates in tree order — useful for a filter-only or "show everything" listing.
53
+ *
54
+ * @param scope The element whose descendants are searched.
55
+ * @param query The search string — bare words plus `"quoted phrases"`.
56
+ * @param options `limit` (default `20`) and an optional `filter` `Query` over each candidate's props.
57
+ * @returns The matching descendants, best first, capped at `limit`.
58
+ * @example searchTree(root, "store", { limit: 10, filter: { kind: "class" } }) // up to 10 classes ranked for "store"
59
+ * @see https://dhoulb.github.io/shelving/util/tree/searchTree
60
+ */
61
+ export function searchTree(scope, query, options) {
62
+ const { limit = 20, filter } = options ?? {};
63
+ // Gather every descendant of `scope`, optionally narrowed by a `filter` query over each element's props.
64
+ let candidates = Array.from(_walkTree(scope));
65
+ if (filter) {
66
+ // `queryItems()` is typed for `Data`; element props are plain objects at runtime, so cast for the filter and map back by reference.
67
+ const allowed = new Set(queryItems(candidates.map(el => el.props), filter));
68
+ candidates = candidates.filter(el => allowed.has(el.props));
69
+ }
70
+ // Tokenise the query — quoted phrases match literally, bare words match individually.
71
+ const tokens = getWords(query.toLowerCase());
72
+ // No query → return the (filtered) candidates in tree order, capped at `limit`.
73
+ if (!tokens.length)
74
+ return candidates.slice(0, limit);
75
+ // Score every candidate, drop non-matches, sort by score descending, cap at `limit`.
76
+ const scored = candidates.map(el => [el, _scoreElement(el.props, tokens)]).filter(([, score]) => score > 0);
77
+ scored.sort((a, b) => b[1] - a[1]);
78
+ return scored.slice(0, limit).map(([el]) => el);
79
+ }
80
+ /** Walk every descendant of a tree element depth-first — the element itself is not yielded. */
81
+ function* _walkTree(scope) {
82
+ for (const child of walkElements(scope.props.children)) {
83
+ yield child;
84
+ yield* _walkTree(child);
85
+ }
86
+ }
87
+ // Score tiers — separated by orders of magnitude so a single higher-tier hit outranks any realistic stack of lower-tier hits (a `name` match always beats a content-only match).
88
+ const _SCORE_NAME_EXACT = 10000;
89
+ const _SCORE_NAME_STARTS = 1000;
90
+ const _SCORE_NAME_INCLUDES = 100;
91
+ const _SCORE_TITLE = 10;
92
+ const _SCORE_DESCRIPTION = 4;
93
+ const _SCORE_CONTENT = 1;
94
+ /** Score one element's props against the (already lower-cased) query tokens — each token contributes its best-matching tier, summed. */
95
+ function _scoreElement(props, tokens) {
96
+ const name = props.name.toLowerCase();
97
+ const title = props.title?.toLowerCase() ?? "";
98
+ const description = props.description?.toLowerCase() ?? "";
99
+ const content = props.content?.toLowerCase() ?? "";
100
+ let score = 0;
101
+ for (const token of tokens) {
102
+ if (name === token)
103
+ score += _SCORE_NAME_EXACT;
104
+ else if (name.startsWith(token))
105
+ score += _SCORE_NAME_STARTS;
106
+ else if (name.includes(token))
107
+ score += _SCORE_NAME_INCLUDES;
108
+ else if (title.includes(token))
109
+ score += _SCORE_TITLE;
110
+ else if (description.includes(token))
111
+ score += _SCORE_DESCRIPTION;
112
+ else if (content.includes(token))
113
+ score += _SCORE_CONTENT;
114
+ }
115
+ return score;
116
+ }