forgebook 0.3.1 → 0.3.2
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/README.md +12 -3
- package/dist/bin/forgebook/agents.md +2 -1
- package/dist/src/shell/SearchPanel.js +2 -3
- package/dist/src/shell/Shell.js +14 -5
- package/dist/src/shell/Sidebar.d.ts +8 -2
- package/dist/src/shell/Sidebar.js +3 -3
- package/dist/src/shell/SidebarHandle.d.ts +14 -1
- package/dist/src/shell/SidebarHandle.js +10 -0
- package/dist/src/shell/SidebarSearch.js +7 -4
- package/dist/src/shell/Toolbar.d.ts +6 -4
- package/dist/src/shell/Toolbar.js +3 -3
- package/dist/src/shell/shell.css +28 -1
- package/dist/src/shell/useSidebarLayout.d.ts +13 -3
- package/dist/src/shell/useSidebarLayout.js +43 -5
- package/dist/src/shell/useSidebarOverlay.d.ts +34 -0
- package/dist/src/shell/useSidebarOverlay.js +51 -0
- package/dist/src/storyMocks.d.ts +53 -0
- package/dist/src/storyMocks.js +208 -0
- package/dist/src/vite/docsExtractor.d.ts +8 -1
- package/dist/src/vite/docsExtractor.js +52 -10
- package/dist/src/vite/docsWorker.js +6 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ per story, switches for theme, contrast, motion and language, and a props table
|
|
|
9
9
|
> and every change is in the
|
|
10
10
|
> [changelog](https://github.com/ensiria/forgebook/blob/develop/packages/forgebook/CHANGELOG.md).
|
|
11
11
|
|
|
12
|
-
**Requirements:** Node 22.18 or later · Vite 8 (the catalogue is built on it; other bundlers are not supported) · React 19 through
|
|
12
|
+
**Requirements:** Node 22.18 or later · Vite 8.3.4+ (the catalogue is built on it; other bundlers are not supported) · React 19 through
|
|
13
13
|
[`@forgebook/react`](https://www.npmjs.com/package/@forgebook/react), Vue 3.5 through
|
|
14
14
|
[`@forgebook/vue`](https://www.npmjs.com/package/@forgebook/vue) (a Nuxt project's through
|
|
15
15
|
[`@forgebook/nuxt`](https://www.npmjs.com/package/@forgebook/nuxt)), Svelte 5 through
|
|
@@ -194,6 +194,15 @@ too, with a warning.
|
|
|
194
194
|
|
|
195
195
|
It adds about a dozen packages to a React and Vite project.
|
|
196
196
|
|
|
197
|
+
## On a narrow window
|
|
198
|
+
|
|
199
|
+
Below 720 px the sidebar stops taking room from the page. It starts closed, opens from the toolbar's sidebar button and
|
|
200
|
+
slides over the page, which keeps the whole width and cannot be reached while the sidebar is open. A tap outside it,
|
|
201
|
+
Escape (once the search is empty and no menu is open), its own button or opening any page closes it, and the focus goes
|
|
202
|
+
back to the button, unless a search's result has taken it to what it found. Opening it there changes
|
|
203
|
+
nothing a wider window remembers: the sidebar a person showed or hid on a wide window is as they left it, width
|
|
204
|
+
included. A reduced-motion preference shows and hides it at once.
|
|
205
|
+
|
|
197
206
|
## The toolbar
|
|
198
207
|
|
|
199
208
|
The View menu holds every control: the catalogue's switches, and Forgebook's own (the background, the style sheets, the
|
|
@@ -498,8 +507,8 @@ The buttons read their colours from the page's theme.
|
|
|
498
507
|
|
|
499
508
|
The field at the top of the sidebar searches the whole catalogue. The tree filters by name as you type, for browsing;
|
|
500
509
|
beside it a list ranks everything the words find, each component or page with what matched inside it and why (a prop,
|
|
501
|
-
an event, a heading, a sentence, a story), the words marked. `/` or ⌘K (Ctrl+K) comes to the field from anywhere,
|
|
502
|
-
move, Enter opens at the exact heading or prop, ⌘Enter or a modified press opens a new tab, Escape empties the field.
|
|
510
|
+
an event, a heading, a sentence, a story), the words marked. `/` or ⌘K (Ctrl+K) comes to the field from anywhere (with the sidebar hidden, ⌘K alone,
|
|
511
|
+
which shows it), ↑ and ↓ move, Enter opens at the exact heading or prop, ⌘Enter or a modified press opens a new tab, Escape empties the field.
|
|
503
512
|
|
|
504
513
|
A prop has an address of its own, so a link to one can be pasted anywhere: `?component=acme:inputs-select#prop.value`,
|
|
505
514
|
and `#prop.Group.label` for a prop of a part named in `subcomponents`. A heading of a component's documentation file
|
|
@@ -115,7 +115,8 @@ in JSON with `--json`, and every answer about a story carries the links that ope
|
|
|
115
115
|
and what its server code reads with `nextjs.cookies`, `headers` and `draftMode`. A link or a router call moves the
|
|
116
116
|
story's route and is listed among its events; a `play` reads where it went with `storyRoute()`
|
|
117
117
|
(`@forgebook/nextjs/route`). Async server components are drawn; what they reach on a server (a database, the file
|
|
118
|
-
system) is not there: fetch it and mock it with `parameters.msw`.
|
|
118
|
+
system) is not there: fetch it and mock it with `parameters.msw`. In the server tests a server component's `fetch` never
|
|
119
|
+
reaches the network, whatever its address: a fetch no handler answers fails the story, naming the address.
|
|
119
120
|
|
|
120
121
|
- Say when a component is for and when it is not, in its TSDoc: `@useWhen`, `@avoidWhen`, `@alternatives`, `@deprecated`,
|
|
121
122
|
`@replacedBy`. `forgebook story` returns them as `docs.guidance`: read them before using a component, and never use a
|
|
@@ -18,10 +18,9 @@ import { searchables, searchCatalogue } from "../search.js";
|
|
|
18
18
|
import { SEARCH_STRINGS } from "./searchStrings.js";
|
|
19
19
|
import { shownTree } from "./treeSearch.js";
|
|
20
20
|
import { useSearchIndex } from "./useSearchIndex.js";
|
|
21
|
+
import { SIDE_BY_SIDE_WIDTH } from "./useSidebarLayout.js";
|
|
21
22
|
/** How much a page shows: what a person reads before refining the words. */
|
|
22
23
|
const LIMITS = { results: 24, hits: 3 };
|
|
23
|
-
/** The narrowest window where the list opens beside the sidebar: a smaller one has it over the sidebar, under the field. */
|
|
24
|
-
const BESIDE_WIDTH = 720;
|
|
25
24
|
/** The room between the field and the list when the list opens under it, in CSS px. */
|
|
26
25
|
const LIST_GAP = 6;
|
|
27
26
|
/** The room the list leaves around it, in CSS px. */
|
|
@@ -35,7 +34,7 @@ const LIST_WIDTH = "38rem";
|
|
|
35
34
|
* @param sidebarWidth - the sidebar's width, in CSS px.
|
|
36
35
|
*/
|
|
37
36
|
const listPlace = (field, sidebarWidth) => {
|
|
38
|
-
if (window.innerWidth <
|
|
37
|
+
if (window.innerWidth < SIDE_BY_SIDE_WIDTH)
|
|
39
38
|
return { top: field.bottom + LIST_GAP, insetInline: LIST_MARGIN };
|
|
40
39
|
const start = sidebarWidth + LIST_MARGIN;
|
|
41
40
|
return {
|
package/dist/src/shell/Shell.js
CHANGED
|
@@ -19,7 +19,7 @@ import { LogicMark } from "./LogicMark.js";
|
|
|
19
19
|
import { lazyModule } from "./lazyModule.js";
|
|
20
20
|
import { PluginHostsContext } from "./pluginHostsContext.js";
|
|
21
21
|
import { Sidebar } from "./Sidebar.js";
|
|
22
|
-
import {
|
|
22
|
+
import { SidebarEdge } from "./SidebarHandle.js";
|
|
23
23
|
import { Stage } from "./Stage.js";
|
|
24
24
|
import { StoryPicker } from "./StoryPicker.js";
|
|
25
25
|
import { STRINGS } from "./strings.js";
|
|
@@ -28,6 +28,7 @@ import { useAppearance } from "./useAppearance.js";
|
|
|
28
28
|
import { useRecentPages } from "./useRecentPages.js";
|
|
29
29
|
import { useShownView } from "./useShownView.js";
|
|
30
30
|
import { useSidebarLayout } from "./useSidebarLayout.js";
|
|
31
|
+
import { useSidebarOverlay } from "./useSidebarOverlay.js";
|
|
31
32
|
import { useStoryHeights } from "./useStoryHeights.js";
|
|
32
33
|
import { useStoryIdsElement } from "./useStoryIdsElement.js";
|
|
33
34
|
import { useStoryKeys } from "./useStoryKeys.js";
|
|
@@ -222,7 +223,7 @@ const fixedOfShown = (fixedViews, shown) => shown.kind === "story" ? (fixedViews
|
|
|
222
223
|
*
|
|
223
224
|
* @param props - whether the home is shown, and the toolbar's props.
|
|
224
225
|
*/
|
|
225
|
-
const ShellBar = ({ isHome, ...toolbar }) => isHome ? (_jsx("div", { className: "forgebook-toolbar", children: toolbar.onShowSidebar === undefined ? null : (_jsx(ShowSidebarButton, { onShowSidebar: toolbar.onShowSidebar })) })) : (_jsx(Toolbar, { ...toolbar }));
|
|
226
|
+
const ShellBar = ({ isHome, ...toolbar }) => isHome ? (_jsx("div", { className: "forgebook-toolbar", children: toolbar.onShowSidebar === undefined ? null : (_jsx(ShowSidebarButton, { onShowSidebar: toolbar.onShowSidebar, showSidebarRef: toolbar.showSidebarRef })) })) : (_jsx(Toolbar, { ...toolbar }));
|
|
226
227
|
/**
|
|
227
228
|
* What a component's page loads: its first file's docs and page, as Storybook's docs come from the first file of a
|
|
228
229
|
* title, and the code of every file, for each example's Code card.
|
|
@@ -415,10 +416,16 @@ const ShellPage = ({ title, logo, hasBranding, heights, sources, frameUrl, addre
|
|
|
415
416
|
// the written home's module, loaded when the home is first opened
|
|
416
417
|
const writtenHome = useMemo(() => (home === undefined ? undefined : lazyModule(home)), [home]);
|
|
417
418
|
const idsByTitle = useMemo(() => componentIdsByTitle(tree), [tree]);
|
|
418
|
-
const { width: sidebarWidth, setWidth: setSidebarWidth, isOpen: isSidebarOpen, setIsOpen: setIsSidebarOpen } = useSidebarLayout();
|
|
419
|
-
const showSidebar = useCallback(() => setIsSidebarOpen(true), [setIsSidebarOpen]);
|
|
420
419
|
const catalogue = useCatalogue();
|
|
421
420
|
const { address, visit, pageAddress, openPage, openFound, openAnchor, replaceAddress } = useAddress(catalogue, addressTexts);
|
|
421
|
+
const { width: sidebarWidth, setWidth: setSidebarWidth, isOpen: isSidebarOpen, setIsOpen: setIsSidebarOpen, isOverlay: isSidebarOverlay } = useSidebarLayout(visit);
|
|
422
|
+
const showSidebar = useCallback(() => setIsSidebarOpen(true), [setIsSidebarOpen]);
|
|
423
|
+
const hideSidebar = useCallback(() => setIsSidebarOpen(false), [setIsSidebarOpen]);
|
|
424
|
+
const { isOver: isSidebarOver, sidebarRef, hideButtonRef, showButtonRef } = useSidebarOverlay({
|
|
425
|
+
isOverlay: isSidebarOverlay,
|
|
426
|
+
isOpen: isSidebarOpen,
|
|
427
|
+
onClose: hideSidebar
|
|
428
|
+
});
|
|
422
429
|
// each page's last scroll position, kept across the component pages and the stage
|
|
423
430
|
const scrollPositions = useRef(new Map());
|
|
424
431
|
const scroll = { visit, positions: scrollPositions.current };
|
|
@@ -489,7 +496,9 @@ const ShellPage = ({ title, logo, hasBranding, heights, sources, frameUrl, addre
|
|
|
489
496
|
header,
|
|
490
497
|
scroll
|
|
491
498
|
};
|
|
492
|
-
return (_jsxs("div", { className: "catalogue", children: [_jsx(Sidebar, { width: sidebarWidth, isOpen: isSidebarOpen,
|
|
499
|
+
return (_jsxs("div", { className: "catalogue", children: [_jsx(Sidebar, { width: sidebarWidth, isOpen: isSidebarOpen, isOverlay: isSidebarOverlay, sidebarRef: sidebarRef, hideButtonRef: hideButtonRef, onHide: hideSidebar, onShow: showSidebar, appearance: appearance, title: title, logo: logo, hasBranding: hasBranding !== false, tree: tree, sources: sources, searchHref: (target) => addressQuery(pageAddress(target), catalogue), onOpenResult: openFound, isHome: shown.kind === "home", homeHref: addressQuery(pageAddress(HOME), catalogue), onOpenHome: () => openPage(HOME), current: sidebarCurrent(location, shownStoryId), currentPageId: shownPageLocation?.page.id, pageHref: (id) => addressQuery(pageAddress({ kind: "page", id }), catalogue), onOpenPage: (id) => openPage({ kind: "page", id }), componentHref: (sourceId, componentTitle) => addressQuery(pageAddress(titledPage(idsByTitle, sourceId, componentTitle)), catalogue), onOpenComponent: (sourceId, componentTitle) => openPage(titledPage(idsByTitle, sourceId, componentTitle)) }), _jsx(SidebarEdge, { isOverlay: isSidebarOverlay, isOpen: isSidebarOpen, width: sidebarWidth, onWidthChange: setSidebarWidth, onHide: hideSidebar }), _jsxs("main", { className: "forgebook-main", inert: isSidebarOver, children: [_jsx(ShellBar, { isHome: hasNoStories(shown), view: shownView, onViewChange: changeShownView, isStoryPage: shown.kind === "story", isComponentPage: shown.kind === "component", isLogic: isLogicPage(shown),
|
|
500
|
+
// under a sidebar lying over the page the button stays, so the bar's controls never move behind it
|
|
501
|
+
onShowSidebar: isSidebarOpen && !isSidebarOver ? undefined : showSidebar, showSidebarRef: showButtonRef, shownWidth: shown.kind === "story" ? shownWidth : undefined, fixed: fixedOfShown(fixedViews, shown), deviceDefault: deviceOfShown(catalogue.device, shown) }), shown.kind === "component" ? (_jsx(LazyPage, { module: COMPONENT_PAGE, header: header, pageProps: {
|
|
493
502
|
component: shown.location.component,
|
|
494
503
|
anchor: shown.anchor,
|
|
495
504
|
view: address.view,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module
|
|
12
12
|
*/
|
|
13
13
|
import type { StorySource } from "@forgebook/story-format/source";
|
|
14
|
-
import { type ReactNode } from "react";
|
|
14
|
+
import { type ReactNode, type Ref } from "react";
|
|
15
15
|
import type { SearchTarget } from "../search.ts";
|
|
16
16
|
import type { StoryTree } from "../storyTree.ts";
|
|
17
17
|
import { type SidebarPageLinks } from "./SidebarDocs.tsx";
|
|
@@ -54,6 +54,12 @@ export type SidebarProps = SidebarPageLinks & {
|
|
|
54
54
|
readonly width: number;
|
|
55
55
|
/** Whether it is shown: hidden, it slides out and stays mounted, so its search and open families come back with it. */
|
|
56
56
|
readonly isOpen: boolean;
|
|
57
|
+
/** Whether it lies over the page and not beside it, on a narrow window. */
|
|
58
|
+
readonly isOverlay: boolean;
|
|
59
|
+
/** The sidebar's element. */
|
|
60
|
+
readonly sidebarRef: Ref<HTMLElement>;
|
|
61
|
+
/** Its button that hides it. */
|
|
62
|
+
readonly hideButtonRef: Ref<HTMLButtonElement>;
|
|
57
63
|
/** Hides the sidebar. */
|
|
58
64
|
readonly onHide: () => void;
|
|
59
65
|
/** Shows it again. */
|
|
@@ -66,4 +72,4 @@ export type SidebarProps = SidebarPageLinks & {
|
|
|
66
72
|
*
|
|
67
73
|
* @param props - the tree, the shown component and how to open one.
|
|
68
74
|
*/
|
|
69
|
-
export declare const Sidebar: ({ title, logo, hasBranding, tree, sources: storySources, searchHref, onOpenResult, isHome, homeHref, onOpenHome, current, currentPageId, pageHref, onOpenPage, width, isOpen, onHide, onShow, appearance, ...links }: SidebarProps) => ReactNode;
|
|
75
|
+
export declare const Sidebar: ({ title, logo, hasBranding, tree, sources: storySources, searchHref, onOpenResult, isHome, homeHref, onOpenHome, current, currentPageId, pageHref, onOpenPage, width, isOpen, isOverlay, sidebarRef, hideButtonRef, onHide, onShow, appearance, ...links }: SidebarProps) => ReactNode;
|
|
@@ -50,7 +50,7 @@ const componentCount = (family) => family.groups.reduce((count, group) => count
|
|
|
50
50
|
*
|
|
51
51
|
* @param props - the tree, the shown component and how to open one.
|
|
52
52
|
*/
|
|
53
|
-
export const Sidebar = ({ title, logo, hasBranding, tree, sources: storySources, searchHref, onOpenResult, isHome, homeHref, onOpenHome, current, currentPageId, pageHref, onOpenPage, width, isOpen, onHide, onShow, appearance, ...links }) => {
|
|
53
|
+
export const Sidebar = ({ title, logo, hasBranding, tree, sources: storySources, searchHref, onOpenResult, isHome, homeHref, onOpenHome, current, currentPageId, pageHref, onOpenPage, width, isOpen, isOverlay, sidebarRef, hideButtonRef, onHide, onShow, appearance, ...links }) => {
|
|
54
54
|
const [query, setQuery] = useState("");
|
|
55
55
|
const [filterTree, setFilterTree] = useState();
|
|
56
56
|
const showFiltered = useCallback((filter) => setFilterTree(() => filter), []);
|
|
@@ -86,12 +86,12 @@ export const Sidebar = ({ title, logo, hasBranding, tree, sources: storySources,
|
|
|
86
86
|
// the filter comes with the search's list; a moment before it, or with no words, the tree shows whole
|
|
87
87
|
const { sources, pages } = isSearching && filterTree !== undefined ? filterTree(tree, query) : wholeTree(tree);
|
|
88
88
|
const hasMatches = pages.length > 0 || sources.some(({ families }) => families.length > 0);
|
|
89
|
-
return (_jsxs("aside", { className: "forgebook-sidebar", "data-open": isOpen ? "" : undefined, inert: !isOpen, style: { width, marginInlineStart: isOpen ? 0 : -width }, children: [_jsxs("div", { className: "forgebook-brand", children: [_jsxs("a", { className: "forgebook-brand__home", href: homeHref, "aria-current": isHome ? "page" : undefined, title: STRINGS.home, onClick: (event) => {
|
|
89
|
+
return (_jsxs("aside", { ref: sidebarRef, className: "forgebook-sidebar", "data-open": isOpen ? "" : undefined, "data-overlay": isOverlay ? "" : undefined, inert: !isOpen, style: { width, marginInlineStart: isOpen ? 0 : -width }, children: [_jsxs("div", { className: "forgebook-brand", children: [_jsxs("a", { className: "forgebook-brand__home", href: homeHref, "aria-current": isHome ? "page" : undefined, title: STRINGS.home, onClick: (event) => {
|
|
90
90
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0)
|
|
91
91
|
return;
|
|
92
92
|
event.preventDefault();
|
|
93
93
|
onOpenHome();
|
|
94
|
-
}, children: [mark === undefined ? null : (_jsx("img", { className: "forgebook-brand__logo", src: mark, alt: "", width: 28, height: 28 })), _jsx("span", { className: "forgebook-brand__name", children: title })] }), _jsx("button", { type: "button", className: "forgebook-icon-button forgebook-brand__hide", title: STRINGS.hideSidebar, "aria-label": STRINGS.hideSidebar, onClick: onHide, children: _jsx(Icon, { name: "sidebar", size: 14 }) })] }), _jsx(SidebarSearch, { query: query, onQueryChange: setQuery, onFilterReady: showFiltered, tree: tree, sources: storySources, sidebarWidth: width, isSidebarOpen: isOpen, onShowSidebar: onShow, searchHref: searchHref, onOpenResult: onOpenResult }), _jsxs("nav", { ref: navRef, className: "forgebook-tree", "aria-label": STRINGS.componentsNav, children: [hasMatches ? null : _jsx("p", { className: "forgebook-tree__empty", children: STRINGS.noMatch }), _jsx(SidebarDocs, { sources: pages, currentPageId: currentPageId, isSearching: isSearching, foldChoices: foldChoices, onFoldChange: setFoldOpen, pageHref: pageHref, onOpenPage: onOpenPage }), sources.map(({ source, families }) => families.length === 0 ? null : (_jsxs("section", { className: "forgebook-tree__source", children: [_jsxs("h2", { className: "forgebook-tree__source-name", children: [source.logo === undefined ? null : (_jsx("img", { className: "forgebook-source-logo", src: source.logo, alt: "", width: 14, height: 14 })), source.name] }), families.map((family) => {
|
|
94
|
+
}, children: [mark === undefined ? null : (_jsx("img", { className: "forgebook-brand__logo", src: mark, alt: "", width: 28, height: 28 })), _jsx("span", { className: "forgebook-brand__name", children: title })] }), _jsx("button", { ref: hideButtonRef, type: "button", className: "forgebook-icon-button forgebook-brand__hide", title: STRINGS.hideSidebar, "aria-label": STRINGS.hideSidebar, onClick: onHide, children: _jsx(Icon, { name: "sidebar", size: 14 }) })] }), _jsx(SidebarSearch, { query: query, onQueryChange: setQuery, onFilterReady: showFiltered, tree: tree, sources: storySources, sidebarWidth: width, isSidebarOpen: isOpen, onShowSidebar: onShow, searchHref: searchHref, onOpenResult: onOpenResult }), _jsxs("nav", { ref: navRef, className: "forgebook-tree", "aria-label": STRINGS.componentsNav, children: [hasMatches ? null : _jsx("p", { className: "forgebook-tree__empty", children: STRINGS.noMatch }), _jsx(SidebarDocs, { sources: pages, currentPageId: currentPageId, isSearching: isSearching, foldChoices: foldChoices, onFoldChange: setFoldOpen, pageHref: pageHref, onOpenPage: onOpenPage }), sources.map(({ source, families }) => families.length === 0 ? null : (_jsxs("section", { className: "forgebook-tree__source", children: [_jsxs("h2", { className: "forgebook-tree__source-name", children: [source.logo === undefined ? null : (_jsx("img", { className: "forgebook-source-logo", src: source.logo, alt: "", width: 14, height: 14 })), source.name] }), families.map((family) => {
|
|
95
95
|
const key = familyKey(source, family);
|
|
96
96
|
// a search opens every family it finds something in
|
|
97
97
|
const isOpen = isSearching || (foldChoices.get(key) ?? isCurrentFamily(source, family));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The handle on the sidebar's right edge: dragged, or moved with the arrow keys, it sets the sidebar's width
|
|
2
|
+
* The handle on the sidebar's right edge: dragged, or moved with the arrow keys, it sets the sidebar's width; on a narrow
|
|
3
|
+
* window, where the sidebar lies over the page at no chosen width, the shade on the page takes its place.
|
|
3
4
|
*
|
|
4
5
|
* @module
|
|
5
6
|
*/
|
|
@@ -26,3 +27,15 @@ export declare const SidebarHandle: ({ width, onWidthChange }: {
|
|
|
26
27
|
readonly width: number;
|
|
27
28
|
readonly onWidthChange: (width: number) => void;
|
|
28
29
|
}) => ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* What borders the sidebar: beside the page its handle, while shown; over it the shade on the page.
|
|
32
|
+
*
|
|
33
|
+
* @param props - where the sidebar lies, whether it is shown, its width and how to hide it.
|
|
34
|
+
*/
|
|
35
|
+
export declare const SidebarEdge: ({ isOverlay, isOpen, width, onWidthChange, onHide }: {
|
|
36
|
+
readonly isOverlay: boolean;
|
|
37
|
+
readonly isOpen: boolean;
|
|
38
|
+
readonly width: number;
|
|
39
|
+
readonly onWidthChange: (width: number) => void;
|
|
40
|
+
readonly onHide: () => void;
|
|
41
|
+
}) => ReactNode;
|
|
@@ -43,3 +43,13 @@ export const SidebarHandle = ({ width, onWidthChange }) => {
|
|
|
43
43
|
};
|
|
44
44
|
return (_jsx("div", { className: "forgebook-sidebar-handle", role: "separator", "aria-orientation": "vertical", "aria-label": STRINGS.sidebarWidth, "aria-valuenow": width, "aria-valuemin": SIDEBAR_WIDTH.min, "aria-valuemax": SIDEBAR_WIDTH.max, tabIndex: 0, onPointerDown: startResize, onKeyDown: resizeWithKeys }));
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* What borders the sidebar: beside the page its handle, while shown; over it the shade on the page.
|
|
48
|
+
*
|
|
49
|
+
* @param props - where the sidebar lies, whether it is shown, its width and how to hide it.
|
|
50
|
+
*/
|
|
51
|
+
export const SidebarEdge = ({ isOverlay, isOpen, width, onWidthChange, onHide }) => {
|
|
52
|
+
if (!isOverlay)
|
|
53
|
+
return isOpen ? _jsx(SidebarHandle, { width: width, onWidthChange: onWidthChange }) : null;
|
|
54
|
+
return (_jsx("div", { className: "forgebook-sidebar-shade", "data-open": isOpen ? "" : undefined, "aria-hidden": "true", onClick: onHide }));
|
|
55
|
+
};
|
|
@@ -68,13 +68,16 @@ export const SidebarSearch = ({ query, onQueryChange, onFilterReady, tree, sourc
|
|
|
68
68
|
event.preventDefault();
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
|
-
// Escape empties the search first, then leaves the field
|
|
71
|
+
// Escape empties the search first, then leaves the field; emptying uses the key up, so a sidebar lying over
|
|
72
|
+
// the page stays open for it (`useSidebarOverlay.ts`)
|
|
72
73
|
if (event.key !== "Escape")
|
|
73
74
|
return;
|
|
74
|
-
if (query === "")
|
|
75
|
+
if (query === "") {
|
|
75
76
|
event.currentTarget.blur();
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
onQueryChange("");
|
|
78
81
|
}, placeholder: STRINGS.searchPlaceholder, "aria-label": STRINGS.searchLabel, onChange: (event) => {
|
|
79
82
|
onQueryChange(event.target.value);
|
|
80
83
|
setIsListOpen(true);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module
|
|
11
11
|
*/
|
|
12
|
-
import { type ReactNode } from "react";
|
|
12
|
+
import { type ReactNode, type Ref } from "react";
|
|
13
13
|
import { type DeviceDefault } from "../deviceView.ts";
|
|
14
14
|
import { type FixedView } from "../storyGlobals.ts";
|
|
15
15
|
import { type ViewState } from "../viewState.ts";
|
|
@@ -23,8 +23,10 @@ export type ToolbarProps = {
|
|
|
23
23
|
readonly isStoryPage?: boolean;
|
|
24
24
|
/** Whether a component's page is shown: the audience changes nothing elsewhere. */
|
|
25
25
|
readonly isComponentPage?: boolean;
|
|
26
|
-
/** Shows the hidden sidebar; none while it is shown. */
|
|
26
|
+
/** Shows the hidden sidebar; none while it is shown beside the page. */
|
|
27
27
|
readonly onShowSidebar?: (() => void) | undefined;
|
|
28
|
+
/** The button that shows the sidebar, where the focus goes back when a sidebar over the page closes. */
|
|
29
|
+
readonly showSidebarRef?: Ref<HTMLButtonElement> | undefined;
|
|
28
30
|
/** The width the shown story really has, in CSS px, for the width field; none on a component's page. */
|
|
29
31
|
readonly shownWidth?: number | undefined;
|
|
30
32
|
/** What the shown story fixes of the view: shown, and not changeable here; nothing by default. */
|
|
@@ -39,7 +41,7 @@ export type ToolbarProps = {
|
|
|
39
41
|
*
|
|
40
42
|
* @param props - what showing the sidebar does.
|
|
41
43
|
*/
|
|
42
|
-
export declare const ShowSidebarButton: ({ onShowSidebar }: {
|
|
44
|
+
export declare const ShowSidebarButton: ({ onShowSidebar, showSidebarRef }: Pick<ToolbarProps, "showSidebarRef"> & {
|
|
43
45
|
readonly onShowSidebar: () => void;
|
|
44
46
|
}) => ReactNode;
|
|
45
47
|
/**
|
|
@@ -47,4 +49,4 @@ export declare const ShowSidebarButton: ({ onShowSidebar }: {
|
|
|
47
49
|
*
|
|
48
50
|
* @param props - the view, its setter, the page shown and the story's width.
|
|
49
51
|
*/
|
|
50
|
-
export declare const Toolbar: ({ view: ownView, onViewChange: onOwnViewChange, isStoryPage, isComponentPage, onShowSidebar, shownWidth, fixed, isLogic, deviceDefault }: ToolbarProps) => ReactNode;
|
|
52
|
+
export declare const Toolbar: ({ view: ownView, onViewChange: onOwnViewChange, isStoryPage, isComponentPage, onShowSidebar, showSidebarRef, shownWidth, fixed, isLogic, deviceDefault }: ToolbarProps) => ReactNode;
|
|
@@ -73,13 +73,13 @@ const CompareMenu = ({ view, onViewChange, isDisabled, fixed = NOT_FIXED }) => {
|
|
|
73
73
|
*
|
|
74
74
|
* @param props - what showing the sidebar does.
|
|
75
75
|
*/
|
|
76
|
-
export const ShowSidebarButton = ({ onShowSidebar }) => (_jsx("button", { type: "button", className: "forgebook-icon-button", title: STRINGS.showSidebar, "aria-label": STRINGS.showSidebar, onClick: onShowSidebar, children: _jsx(Icon, { name: "sidebar", size: 14 }) }));
|
|
76
|
+
export const ShowSidebarButton = ({ onShowSidebar, showSidebarRef }) => (_jsx("button", { ref: showSidebarRef, type: "button", className: "forgebook-icon-button", title: STRINGS.showSidebar, "aria-label": STRINGS.showSidebar, onClick: onShowSidebar, children: _jsx(Icon, { name: "sidebar", size: 14 }) }));
|
|
77
77
|
/**
|
|
78
78
|
* The toolbar.
|
|
79
79
|
*
|
|
80
80
|
* @param props - the view, its setter, the page shown and the story's width.
|
|
81
81
|
*/
|
|
82
|
-
export const Toolbar = ({ view: ownView, onViewChange: onOwnViewChange, isStoryPage = true, isComponentPage = false, onShowSidebar, shownWidth, fixed = NOT_FIXED, isLogic = false, deviceDefault = NO_DEVICE }) => {
|
|
82
|
+
export const Toolbar = ({ view: ownView, onViewChange: onOwnViewChange, isStoryPage = true, isComponentPage = false, onShowSidebar, showSidebarRef, shownWidth, fixed = NOT_FIXED, isLogic = false, deviceDefault = NO_DEVICE }) => {
|
|
83
83
|
const layout = useToolbarLayout();
|
|
84
84
|
const catalogue = useCatalogue();
|
|
85
85
|
// the story's fixed values are shown, and a change of anything else keeps the user's own in the address
|
|
@@ -96,5 +96,5 @@ export const Toolbar = ({ view: ownView, onViewChange: onOwnViewChange, isStoryP
|
|
|
96
96
|
const moved = useToolbarOverflow(pinnedRef, pinned.map((each) => each.control.id));
|
|
97
97
|
// a bar that wraps has room for every pinned control
|
|
98
98
|
const onBar = layout.isWrapped ? pinned : pinned.filter((each) => !moved.includes(each.control.id));
|
|
99
|
-
return (_jsxs("div", { className: layout.isWrapped ? "forgebook-toolbar forgebook-toolbar--wrap" : "forgebook-toolbar", children: [onShowSidebar === undefined ? null : _jsx(ShowSidebarButton, { onShowSidebar: onShowSidebar }), _jsx(ViewMenu, { layout: layout, ownView: ownView, movedIds: [...(layout.isWrapped ? [] : moved), ...putAway], ...context }), onBar.length === 0 ? null : _jsx("span", { className: "forgebook-toolbar__divider" }), _jsx("div", { ref: pinnedRef, className: "forgebook-toolbar__pinned-list", children: onBar.map((each) => (_jsx(PinnedControl, { control: each.control, isHidden: each.isHidden, isChanged: hasViewChanges(ownView, catalogue.switches, [each.control.id], catalogue.audience), layout: layout, ...context }, each.control.id))) }), _jsxs("button", { type: "button", className: "forgebook-button", title: STRINGS.panel, "aria-pressed": view.isPanelOpen, disabled: !isStoryPage, onClick: () => onViewChange({ ...view, isPanelOpen: !view.isPanelOpen }), children: [_jsx(Icon, { name: "panel", size: 14 }), _jsx("span", { className: "forgebook-button__label", children: STRINGS.panel })] }), isLogic ? null : (_jsx(CompareMenu, { view: view, onViewChange: onViewChange, isDisabled: !isStoryPage, fixed: fixed }))] }));
|
|
99
|
+
return (_jsxs("div", { className: layout.isWrapped ? "forgebook-toolbar forgebook-toolbar--wrap" : "forgebook-toolbar", children: [onShowSidebar === undefined ? null : (_jsx(ShowSidebarButton, { onShowSidebar: onShowSidebar, showSidebarRef: showSidebarRef })), _jsx(ViewMenu, { layout: layout, ownView: ownView, movedIds: [...(layout.isWrapped ? [] : moved), ...putAway], ...context }), onBar.length === 0 ? null : _jsx("span", { className: "forgebook-toolbar__divider" }), _jsx("div", { ref: pinnedRef, className: "forgebook-toolbar__pinned-list", children: onBar.map((each) => (_jsx(PinnedControl, { control: each.control, isHidden: each.isHidden, isChanged: hasViewChanges(ownView, catalogue.switches, [each.control.id], catalogue.audience), layout: layout, ...context }, each.control.id))) }), _jsxs("button", { type: "button", className: "forgebook-button", title: STRINGS.panel, "aria-pressed": view.isPanelOpen, disabled: !isStoryPage, onClick: () => onViewChange({ ...view, isPanelOpen: !view.isPanelOpen }), children: [_jsx(Icon, { name: "panel", size: 14 }), _jsx("span", { className: "forgebook-button__label", children: STRINGS.panel })] }), isLogic ? null : (_jsx(CompareMenu, { view: view, onViewChange: onViewChange, isDisabled: !isStoryPage, fixed: fixed }))] }));
|
|
100
100
|
};
|
package/dist/src/shell/shell.css
CHANGED
|
@@ -136,6 +136,31 @@
|
|
|
136
136
|
border-inline-end: 1px solid var(--forgebook-separator);
|
|
137
137
|
transition: margin-inline-start 320ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
138
138
|
}
|
|
139
|
+
.forgebook-sidebar[data-overlay] {
|
|
140
|
+
position: fixed;
|
|
141
|
+
inset-block: 0;
|
|
142
|
+
inset-inline-start: 0;
|
|
143
|
+
z-index: 22;
|
|
144
|
+
height: auto;
|
|
145
|
+
max-width: calc(100vw - 2rem);
|
|
146
|
+
transition: margin-inline-start 320ms cubic-bezier(0.32, 0.72, 0, 1), box-shadow 320ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
147
|
+
}
|
|
148
|
+
.forgebook-sidebar[data-overlay][data-open] {
|
|
149
|
+
box-shadow: 0 0 0 1px var(--forgebook-edge), 0 18px 48px var(--forgebook-shadow-deep), 0 4px 12px var(--forgebook-shadow-lift);
|
|
150
|
+
}
|
|
151
|
+
.forgebook-sidebar-shade {
|
|
152
|
+
position: fixed;
|
|
153
|
+
inset: 0;
|
|
154
|
+
z-index: 21;
|
|
155
|
+
background: var(--forgebook-shade);
|
|
156
|
+
opacity: 0;
|
|
157
|
+
visibility: hidden;
|
|
158
|
+
transition: opacity 320ms cubic-bezier(0.32, 0.72, 0, 1), visibility 320ms;
|
|
159
|
+
}
|
|
160
|
+
.forgebook-sidebar-shade[data-open] {
|
|
161
|
+
opacity: 1;
|
|
162
|
+
visibility: visible;
|
|
163
|
+
}
|
|
139
164
|
.forgebook-appearance {
|
|
140
165
|
position: relative;
|
|
141
166
|
flex: none;
|
|
@@ -2476,7 +2501,9 @@
|
|
|
2476
2501
|
.forgebook-stage-tools {
|
|
2477
2502
|
animation-name: forgebook-appear;
|
|
2478
2503
|
}
|
|
2479
|
-
.forgebook-sidebar
|
|
2504
|
+
.forgebook-sidebar,
|
|
2505
|
+
.forgebook-sidebar[data-overlay],
|
|
2506
|
+
.forgebook-sidebar-shade {
|
|
2480
2507
|
transition: none;
|
|
2481
2508
|
}
|
|
2482
2509
|
.forgebook-example__toggle svg,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** The narrowest window with room for the sidebar beside the page and for the search's list beside it, in CSS px. */
|
|
2
|
+
export declare const SIDE_BY_SIDE_WIDTH = 720;
|
|
1
3
|
/** The sidebar's layout and its setters. */
|
|
2
4
|
export type SidebarLayout = {
|
|
3
5
|
/** Its width, in CSS px. */
|
|
@@ -6,8 +8,16 @@ export type SidebarLayout = {
|
|
|
6
8
|
readonly setWidth: (width: number) => void;
|
|
7
9
|
/** Whether it is shown. */
|
|
8
10
|
readonly isOpen: boolean;
|
|
9
|
-
/** Shows or hides it. */
|
|
11
|
+
/** Shows or hides it: kept for the next visit on a wide window, only for this visit of the page on a narrow one. */
|
|
10
12
|
readonly setIsOpen: (isOpen: boolean) => void;
|
|
13
|
+
/** Whether the window is narrow, so that the sidebar, when shown, lies over the page and not beside it. */
|
|
14
|
+
readonly isOverlay: boolean;
|
|
11
15
|
};
|
|
12
|
-
/**
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The sidebar's layout.
|
|
18
|
+
*
|
|
19
|
+
* @param visit - the page's visit, new whenever a page is opened or the history moves, the shown page opened again
|
|
20
|
+
* included: whatever changes the page under a sidebar lying over it closes the sidebar, in the same render, so that
|
|
21
|
+
* the new page is never drawn out of reach.
|
|
22
|
+
*/
|
|
23
|
+
export declare const useSidebarLayout: (visit: object) => SidebarLayout;
|
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The sidebar's width and whether it is shown: this browser's convenience, kept for the next visit, not
|
|
2
|
+
* The sidebar's width and whether it is shown: on a wide window, this browser's convenience, kept for the next visit, not
|
|
3
|
+
* the address's; on a narrow one, a moment's look over the page, which leaves what was kept alone.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Beside the page, a sidebar on a phone's window leaves the page about 110 px. There it slides over the page instead
|
|
7
|
+
* (`useSidebarOverlay.ts`), starts closed, and belongs to the visit it was opened on. A choice kept from a wide window
|
|
8
|
+
* would otherwise cover the page at every load, and opening it on a phone would change what the wide window shows next.
|
|
3
9
|
*
|
|
4
10
|
* @module
|
|
5
11
|
*/
|
|
12
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
6
13
|
import { boundedSidebarWidth, SIDEBAR_WIDTH } from "./SidebarHandle.js";
|
|
7
14
|
import { useStored } from "./useStored.js";
|
|
15
|
+
/** The narrowest window with room for the sidebar beside the page and for the search's list beside it, in CSS px. */
|
|
16
|
+
export const SIDE_BY_SIDE_WIDTH = 720;
|
|
17
|
+
/** The media query of a window too narrow for the sidebar beside the page. */
|
|
18
|
+
const NARROW_WINDOW = `(max-width: ${SIDE_BY_SIDE_WIDTH - 1}px)`;
|
|
8
19
|
/** The width a stored text holds, or the default. */
|
|
9
20
|
const storedWidth = (stored) => {
|
|
10
21
|
const width = Number(stored);
|
|
11
22
|
return stored === null || !Number.isFinite(width) ? SIDEBAR_WIDTH.initial : boundedSidebarWidth(width);
|
|
12
23
|
};
|
|
13
|
-
/**
|
|
14
|
-
|
|
24
|
+
/** Listens to the window crossing the narrow width. */
|
|
25
|
+
const subscribeToNarrow = (onChange) => {
|
|
26
|
+
const query = window.matchMedia(NARROW_WINDOW);
|
|
27
|
+
query.addEventListener("change", onChange);
|
|
28
|
+
return () => query.removeEventListener("change", onChange);
|
|
29
|
+
};
|
|
30
|
+
/** Whether the window is narrow now. */
|
|
31
|
+
const isNarrowNow = () => window.matchMedia(NARROW_WINDOW).matches;
|
|
32
|
+
/**
|
|
33
|
+
* The sidebar's layout.
|
|
34
|
+
*
|
|
35
|
+
* @param visit - the page's visit, new whenever a page is opened or the history moves, the shown page opened again
|
|
36
|
+
* included: whatever changes the page under a sidebar lying over it closes the sidebar, in the same render, so that
|
|
37
|
+
* the new page is never drawn out of reach.
|
|
38
|
+
*/
|
|
39
|
+
export const useSidebarLayout = (visit) => {
|
|
15
40
|
const [width, setWidth] = useStored("sidebar-width", storedWidth, String);
|
|
16
|
-
const [
|
|
17
|
-
|
|
41
|
+
const [isKeptOpen, setIsKeptOpen] = useStored("sidebar", (stored) => stored !== "hidden", (shown) => (shown ? "shown" : "hidden"));
|
|
42
|
+
const isOverlay = useSyncExternalStore(subscribeToNarrow, isNarrowNow, () => false);
|
|
43
|
+
const [overlayVisit, setOverlayVisit] = useState(undefined);
|
|
44
|
+
// it does not come back open when the window turns narrow again
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isOverlay)
|
|
47
|
+
setOverlayVisit(undefined);
|
|
48
|
+
}, [isOverlay]);
|
|
49
|
+
const setIsOpen = useCallback((isOpen) => {
|
|
50
|
+
if (isOverlay)
|
|
51
|
+
setOverlayVisit(isOpen ? visit : undefined);
|
|
52
|
+
else
|
|
53
|
+
setIsKeptOpen(isOpen);
|
|
54
|
+
}, [isOverlay, visit, setIsKeptOpen]);
|
|
55
|
+
return { width, setWidth, isOpen: isOverlay ? overlayVisit === visit : isKeptOpen, setIsOpen, isOverlay };
|
|
18
56
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a sidebar lying over the page does beyond its look: Escape closes it, the focus moves into it as it opens and back
|
|
3
|
+
* to the button that shows it as it closes. The page behind is made inert by the shell, and a tap on the shade closes it.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { type RefObject } from "react";
|
|
8
|
+
/** What the shell reads, and wires to the sidebar and its buttons. */
|
|
9
|
+
export type SidebarOverlay = {
|
|
10
|
+
/** Whether the sidebar lies over the page now: shown, on a narrow window. */
|
|
11
|
+
readonly isOver: boolean;
|
|
12
|
+
/** The sidebar. */
|
|
13
|
+
readonly sidebarRef: RefObject<HTMLElement | null>;
|
|
14
|
+
/** The sidebar's button that hides it: where the focus goes as it opens. */
|
|
15
|
+
readonly hideButtonRef: RefObject<HTMLButtonElement | null>;
|
|
16
|
+
/** The toolbar's button that shows it: where the focus goes back as it closes. */
|
|
17
|
+
readonly showButtonRef: RefObject<HTMLButtonElement | null>;
|
|
18
|
+
};
|
|
19
|
+
/** What the wiring needs to know of the sidebar. */
|
|
20
|
+
export type SidebarOverlayState = {
|
|
21
|
+
/** Whether the window is narrow, so that a shown sidebar lies over the page. */
|
|
22
|
+
readonly isOverlay: boolean;
|
|
23
|
+
/** Whether it is shown. */
|
|
24
|
+
readonly isOpen: boolean;
|
|
25
|
+
/** Closes it. */
|
|
26
|
+
readonly onClose: () => void;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Wires a sidebar lying over the page; nothing on a wide window, where the sidebar sits beside it.
|
|
30
|
+
*
|
|
31
|
+
* @param state - the sidebar's state.
|
|
32
|
+
* @returns whether it lies over the page, and the references to give the sidebar and its buttons.
|
|
33
|
+
*/
|
|
34
|
+
export declare const useSidebarOverlay: ({ isOverlay, isOpen, onClose }: SidebarOverlayState) => SidebarOverlay;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a sidebar lying over the page does beyond its look: Escape closes it, the focus moves into it as it opens and back
|
|
3
|
+
* to the button that shows it as it closes. The page behind is made inert by the shell, and a tap on the shade closes it.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef } from "react";
|
|
8
|
+
/**
|
|
9
|
+
* Wires a sidebar lying over the page; nothing on a wide window, where the sidebar sits beside it.
|
|
10
|
+
*
|
|
11
|
+
* @param state - the sidebar's state.
|
|
12
|
+
* @returns whether it lies over the page, and the references to give the sidebar and its buttons.
|
|
13
|
+
*/
|
|
14
|
+
export const useSidebarOverlay = ({ isOverlay, isOpen, onClose }) => {
|
|
15
|
+
const sidebarRef = useRef(null);
|
|
16
|
+
const hideButtonRef = useRef(null);
|
|
17
|
+
const showButtonRef = useRef(null);
|
|
18
|
+
const isOver = isOverlay && isOpen;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isOver)
|
|
21
|
+
return;
|
|
22
|
+
const closeOnEscape = (event) => {
|
|
23
|
+
// a key an input method is composing with, or one the search used to empty itself, is not for the sidebar; neither
|
|
24
|
+
// is one that closes a menu open in the sidebar, which handles it itself
|
|
25
|
+
if (event.key !== "Escape" || event.isComposing || event.defaultPrevented)
|
|
26
|
+
return;
|
|
27
|
+
const openMenu = sidebarRef.current?.querySelector(".forgebook-popover");
|
|
28
|
+
if (openMenu === null || openMenu === undefined)
|
|
29
|
+
onClose();
|
|
30
|
+
};
|
|
31
|
+
document.addEventListener("keydown", closeOnEscape);
|
|
32
|
+
return () => document.removeEventListener("keydown", closeOnEscape);
|
|
33
|
+
}, [isOver, onClose]);
|
|
34
|
+
const wasOver = useRef(false);
|
|
35
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: also looked at when the window crosses the width, and never when a wide window's sidebar is hidden, which leaves the focus alone
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const focused = document.activeElement;
|
|
38
|
+
const isFocusInSidebar = sidebarRef.current?.contains(focused) === true;
|
|
39
|
+
// the platform's search key has already put the focus on the search field
|
|
40
|
+
if (isOver && !isFocusInSidebar)
|
|
41
|
+
hideButtonRef.current?.focus({ preventScroll: true });
|
|
42
|
+
// the sidebar went away with the focus in it, closed or by the window crossing the width; one still shown keeps the
|
|
43
|
+
// focus, and a search's result that has already taken it to what it found keeps it too
|
|
44
|
+
const isFocusLost = isFocusInSidebar || (wasOver.current && (focused === document.body || focused === null));
|
|
45
|
+
if (!isOver && sidebarRef.current?.inert === true && isFocusLost) {
|
|
46
|
+
showButtonRef.current?.focus({ preventScroll: true });
|
|
47
|
+
}
|
|
48
|
+
wasOver.current = isOver;
|
|
49
|
+
}, [isOver, isOverlay]);
|
|
50
|
+
return { isOver, sidebarRef, hideButtonRef, showButtonRef };
|
|
51
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A story's mock handlers (`parameters.msw.handlers`) read from its parameters, and run on a server in place of the
|
|
3
|
+
* network. Exported as `forgebook/story-mocks`, for `@forgebook/plugin-msw` and the adapters that render on a server.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* A server render (the generated server tests) has no service worker, and whatever answers on the test machine must
|
|
7
|
+
* not decide what a page draws. So the handlers the frame's worker serves are run here through MSW's `getResponse`, a
|
|
8
|
+
* plain call with no interception, so concurrent renders never see each other's handlers. A request no handler answers,
|
|
9
|
+
* one a handler answers with `passthrough()`, or one made with `bypass()`, fails with the error the adapter writes, which
|
|
10
|
+
* knows what its stories can give instead. A handler's `HttpResponse.error()` rejects the fetch and its redirect is
|
|
11
|
+
* followed, as in the frame.
|
|
12
|
+
*
|
|
13
|
+
* Imports nothing, MSW included: `msw` is an optional peer of each adapter that uses this, so it is resolved from that
|
|
14
|
+
* package and passed in. A strict package manager would not find it from here.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
/** What a story's mocks need to answer requests on a server. */
|
|
19
|
+
export type ServerMocks = {
|
|
20
|
+
/** The story's handlers (`mockHandlersOf`). */
|
|
21
|
+
readonly handlers: readonly unknown[];
|
|
22
|
+
/** The origin of the story's page, which a handler's path (`/api/users`) is read from, as the frame reads it. */
|
|
23
|
+
readonly origin: string;
|
|
24
|
+
/** Imports `msw`, from the adapter that declares it as a peer; called only when there are handlers to run. */
|
|
25
|
+
readonly loadMsw: () => Promise<unknown>;
|
|
26
|
+
/** The error a request fails with when no handler answers it; the adapter also keeps it, to fail the render. */
|
|
27
|
+
readonly onUnanswered: (request: Request) => Error;
|
|
28
|
+
/** Whether the mocks answer an address, or the network does; every address by default. */
|
|
29
|
+
readonly isMocked?: (address: URL) => boolean;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The handlers a story's parameters declare, flat: a list, or a record of lists as MSW's Storybook addon writes them.
|
|
33
|
+
*
|
|
34
|
+
* @param parameters - the story's parameters.
|
|
35
|
+
* @returns the handlers; none when the story has no mocks.
|
|
36
|
+
*/
|
|
37
|
+
export declare const mockHandlersOf: (parameters: Readonly<Record<string, unknown>>) => readonly unknown[];
|
|
38
|
+
/**
|
|
39
|
+
* Whether an address reaches the same server as a page: its origin, or another name of this machine on the same scheme
|
|
40
|
+
* and port (`127.0.0.1` or `[::1]` beside a `localhost` page), which a real machine answers the same.
|
|
41
|
+
*
|
|
42
|
+
* @param address - the address.
|
|
43
|
+
* @param page - the page's address.
|
|
44
|
+
*/
|
|
45
|
+
export declare const isOnPageOrigin: (address: URL, page: URL) => boolean;
|
|
46
|
+
/**
|
|
47
|
+
* A `fetch` of one render, its requests answered by the story's mocks. Make one per render: its `{ once: true }` handlers
|
|
48
|
+
* answer again from here.
|
|
49
|
+
*
|
|
50
|
+
* @param mocks - the story's mocks.
|
|
51
|
+
* @returns what sends a request; an address the mocks do not take goes to the global `fetch`.
|
|
52
|
+
*/
|
|
53
|
+
export declare const mockedFetch: (mocks: ServerMocks) => ((request: Request) => Promise<Response>);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A story's mock handlers (`parameters.msw.handlers`) read from its parameters, and run on a server in place of the
|
|
3
|
+
* network. Exported as `forgebook/story-mocks`, for `@forgebook/plugin-msw` and the adapters that render on a server.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* A server render (the generated server tests) has no service worker, and whatever answers on the test machine must
|
|
7
|
+
* not decide what a page draws. So the handlers the frame's worker serves are run here through MSW's `getResponse`, a
|
|
8
|
+
* plain call with no interception, so concurrent renders never see each other's handlers. A request no handler answers,
|
|
9
|
+
* one a handler answers with `passthrough()`, or one made with `bypass()`, fails with the error the adapter writes, which
|
|
10
|
+
* knows what its stories can give instead. A handler's `HttpResponse.error()` rejects the fetch and its redirect is
|
|
11
|
+
* followed, as in the frame.
|
|
12
|
+
*
|
|
13
|
+
* Imports nothing, MSW included: `msw` is an optional peer of each adapter that uses this, so it is resolved from that
|
|
14
|
+
* package and passed in. A strict package manager would not find it from here.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
/** How many redirects a fetch follows, as browsers and Node's `fetch` do. */
|
|
19
|
+
const MOST_REDIRECTS = 20;
|
|
20
|
+
/**
|
|
21
|
+
* How many requests of a render wait at once. A handler that fetches the address it answers comes back to itself for
|
|
22
|
+
* ever, in promises alone, so no timer of the render's ever fires: only this stops it.
|
|
23
|
+
*/
|
|
24
|
+
const MOST_WAITING = 1000;
|
|
25
|
+
/** The headers a redirect to another origin does not carry. */
|
|
26
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
27
|
+
/** Whether a value is an object with named fields. */
|
|
28
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
/**
|
|
30
|
+
* The handlers a story's parameters declare, flat: a list, or a record of lists as MSW's Storybook addon writes them.
|
|
31
|
+
*
|
|
32
|
+
* @param parameters - the story's parameters.
|
|
33
|
+
* @returns the handlers; none when the story has no mocks.
|
|
34
|
+
*/
|
|
35
|
+
export const mockHandlersOf = (parameters) => {
|
|
36
|
+
const msw = parameters["msw"];
|
|
37
|
+
const handlers = isRecord(msw) ? msw["handlers"] : undefined;
|
|
38
|
+
if (Array.isArray(handlers))
|
|
39
|
+
return handlers;
|
|
40
|
+
return isRecord(handlers) ? Object.values(handlers).flat() : [];
|
|
41
|
+
};
|
|
42
|
+
/** The addresses of this machine that are not a range: every interface's, which connecting to reaches this machine. */
|
|
43
|
+
const OWN_ADDRESSES = new Set(["[::1]", "0.0.0.0", "[::]"]);
|
|
44
|
+
/**
|
|
45
|
+
* Whether a host name is this machine's own, as `URL` writes it: `localhost` and its subdomains, with or without the
|
|
46
|
+
* final dot, `127.0.0.0/8`, the same range mapped into IPv6, and the addresses that reach every interface.
|
|
47
|
+
*
|
|
48
|
+
* @param hostname - the host name, from a `URL`.
|
|
49
|
+
*/
|
|
50
|
+
const isLoopback = (hostname) => {
|
|
51
|
+
const name = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;
|
|
52
|
+
return (name === "localhost" ||
|
|
53
|
+
name.endsWith(".localhost") ||
|
|
54
|
+
OWN_ADDRESSES.has(name) ||
|
|
55
|
+
/^127\.\d+\.\d+\.\d+$/.test(name) ||
|
|
56
|
+
/^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(name));
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Whether an address reaches the same server as a page: its origin, or another name of this machine on the same scheme
|
|
60
|
+
* and port (`127.0.0.1` or `[::1]` beside a `localhost` page), which a real machine answers the same.
|
|
61
|
+
*
|
|
62
|
+
* @param address - the address.
|
|
63
|
+
* @param page - the page's address.
|
|
64
|
+
*/
|
|
65
|
+
export const isOnPageOrigin = (address, page) => {
|
|
66
|
+
if (address.origin === page.origin)
|
|
67
|
+
return true;
|
|
68
|
+
return (address.protocol === page.protocol &&
|
|
69
|
+
address.port === page.port &&
|
|
70
|
+
isLoopback(address.hostname) &&
|
|
71
|
+
isLoopback(page.hostname));
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Makes a story's `{ once: true }` handlers answer again, as MSW's `restoreHandlers` does: each render on a server is a
|
|
75
|
+
* page of its own, as each frame is, while the handler objects outlive it in the test process.
|
|
76
|
+
*
|
|
77
|
+
* @param handlers - the story's handlers.
|
|
78
|
+
*/
|
|
79
|
+
const restoreHandlers = (handlers) => {
|
|
80
|
+
for (const handler of handlers)
|
|
81
|
+
if (isRecord(handler) && typeof handler["isUsed"] === "boolean")
|
|
82
|
+
Reflect.set(handler, "isUsed", false);
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Whether a module is MSW's, as much as a server render uses.
|
|
86
|
+
*
|
|
87
|
+
* @param module - the module, as `import()` gave it.
|
|
88
|
+
*/
|
|
89
|
+
const isMswCore = (module) => typeof module === "object" &&
|
|
90
|
+
module !== null &&
|
|
91
|
+
"getResponse" in module &&
|
|
92
|
+
typeof module.getResponse === "function";
|
|
93
|
+
/**
|
|
94
|
+
* Loads MSW, which only a story with handlers needs on a server.
|
|
95
|
+
*
|
|
96
|
+
* @param loadMsw - imports it.
|
|
97
|
+
* @throws when MSW is not installed, or is older than its `getResponse`.
|
|
98
|
+
*/
|
|
99
|
+
const mswCore = async (loadMsw) => {
|
|
100
|
+
let module;
|
|
101
|
+
try {
|
|
102
|
+
module = await loadMsw();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new Error("Answering a fetch with the story's mocks on a server needs MSW: npm install --save-dev msw", { cause: error });
|
|
106
|
+
}
|
|
107
|
+
if (!isMswCore(module))
|
|
108
|
+
throw new Error("The installed MSW has no getResponse: MSW 2.11 or later is needed");
|
|
109
|
+
return module;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Whether a request asks to skip the mocks and reach the network: MSW's `bypass()` adds this to its `accept` header. A
|
|
113
|
+
* handler's `fetch(bypass(request))` would otherwise come back to the same handler for ever.
|
|
114
|
+
*
|
|
115
|
+
* @param request - the request.
|
|
116
|
+
*/
|
|
117
|
+
const isBypass = (request) => request.headers.get("accept")?.includes("msw/passthrough") === true;
|
|
118
|
+
/**
|
|
119
|
+
* Whether an answer is MSW's `passthrough()`: MSW marks it with this status and header, which its own
|
|
120
|
+
* `isPassthroughResponse` reads (exported from MSW 3 only).
|
|
121
|
+
*
|
|
122
|
+
* @param answer - a handler's answer.
|
|
123
|
+
*/
|
|
124
|
+
const isPassthrough = (answer) => answer.status === 302 && answer.headers.get("x-msw-intention") === "passthrough";
|
|
125
|
+
/**
|
|
126
|
+
* What the story's handlers answer: none when they do not, or when the one that does sends the request on to the
|
|
127
|
+
* network, which a server render never reaches.
|
|
128
|
+
*
|
|
129
|
+
* @param mocks - the story's mocks.
|
|
130
|
+
* @param request - the request.
|
|
131
|
+
*/
|
|
132
|
+
const mockAnswer = async (mocks, request) => {
|
|
133
|
+
if (mocks.handlers.length === 0 || isBypass(request))
|
|
134
|
+
return undefined;
|
|
135
|
+
const { getResponse } = await mswCore(mocks.loadMsw);
|
|
136
|
+
const address = new URL(request.url);
|
|
137
|
+
// an address on this machine, whichever name or port, is the app's own: `/api/users` answers it as in the app
|
|
138
|
+
const baseUrl = isLoopback(address.hostname) ? address.origin : mocks.origin;
|
|
139
|
+
const answer = await getResponse([...mocks.handlers], request, { baseUrl });
|
|
140
|
+
return answer === undefined || isPassthrough(answer) ? undefined : answer;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* The request a mocked redirect leads to, as `fetch` follows it: the same method and body for a 307 or a 308, a GET
|
|
144
|
+
* for a 303 and for a POST's 301 or 302; none when the answer is no redirect or the request does not follow one.
|
|
145
|
+
*
|
|
146
|
+
* @param answer - a handler's answer.
|
|
147
|
+
* @param request - the request it answered, its body unread.
|
|
148
|
+
* @throws a `TypeError` for a redirect a request with `redirect: "error"` gets, as `fetch` does.
|
|
149
|
+
*/
|
|
150
|
+
const redirectOf = (answer, request) => {
|
|
151
|
+
const location = answer.headers.get("location");
|
|
152
|
+
if (![301, 302, 303, 307, 308].includes(answer.status) || location === null)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (request.redirect === "manual")
|
|
155
|
+
return undefined;
|
|
156
|
+
if (request.redirect === "error")
|
|
157
|
+
throw new TypeError(`Redirected to ${location}, which the request refuses`);
|
|
158
|
+
const target = new URL(location, request.url);
|
|
159
|
+
const isGet = answer.status === 303 || (request.method === "POST" && answer.status !== 307 && answer.status !== 308);
|
|
160
|
+
const next = isGet
|
|
161
|
+
? new Request(target, { method: request.method === "HEAD" ? "HEAD" : "GET", headers: request.headers })
|
|
162
|
+
: new Request(target, request);
|
|
163
|
+
// an adapter may send this one to the network: another origin never gets the first one's credentials, as with `fetch`
|
|
164
|
+
if (target.origin !== new URL(request.url).origin)
|
|
165
|
+
for (const name of CREDENTIAL_HEADERS)
|
|
166
|
+
next.headers.delete(name);
|
|
167
|
+
return next;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* A `fetch` of one render, its requests answered by the story's mocks. Make one per render: its `{ once: true }` handlers
|
|
171
|
+
* answer again from here.
|
|
172
|
+
*
|
|
173
|
+
* @param mocks - the story's mocks.
|
|
174
|
+
* @returns what sends a request; an address the mocks do not take goes to the global `fetch`.
|
|
175
|
+
*/
|
|
176
|
+
export const mockedFetch = (mocks) => {
|
|
177
|
+
restoreHandlers(mocks.handlers);
|
|
178
|
+
const send = async (request, redirects) => {
|
|
179
|
+
if (mocks.isMocked?.(new URL(request.url)) === false)
|
|
180
|
+
return globalThis.fetch(request);
|
|
181
|
+
// kept for a redirect that sends the request again, body included, after a handler read it
|
|
182
|
+
const kept = request.clone();
|
|
183
|
+
const answer = await mockAnswer(mocks, request);
|
|
184
|
+
if (answer === undefined)
|
|
185
|
+
throw mocks.onUnanswered(request);
|
|
186
|
+
// MSW's mocked network error: `fetch` rejects, as it does in the frame
|
|
187
|
+
if (answer.type === "error")
|
|
188
|
+
throw new TypeError("Failed to fetch", { cause: answer });
|
|
189
|
+
const next = redirectOf(answer, kept);
|
|
190
|
+
if (next === undefined)
|
|
191
|
+
return answer;
|
|
192
|
+
if (redirects >= MOST_REDIRECTS)
|
|
193
|
+
throw new TypeError(`Too many redirects, the last to ${next.url}`);
|
|
194
|
+
return send(next, redirects + 1);
|
|
195
|
+
};
|
|
196
|
+
let waiting = 0;
|
|
197
|
+
return async (request) => {
|
|
198
|
+
if (waiting >= MOST_WAITING)
|
|
199
|
+
throw new TypeError(`${request.method} ${request.url}: a handler keeps fetching what it answers`);
|
|
200
|
+
waiting += 1;
|
|
201
|
+
try {
|
|
202
|
+
return await send(request, 0);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
waiting -= 1;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
};
|
|
@@ -83,6 +83,11 @@ export type DocsWorkerData = {
|
|
|
83
83
|
/** Whether its answers are kept on disk, so it stamps what each was read from; a build's are not. */
|
|
84
84
|
readonly keepsAnswers: boolean;
|
|
85
85
|
};
|
|
86
|
+
/** What the worker says of its own, unasked: that its modules, the framework's docs module's included, are loaded. */
|
|
87
|
+
export type DocsWorkerLoaded = {
|
|
88
|
+
/** The message's kind. */
|
|
89
|
+
readonly kind: "loaded";
|
|
90
|
+
};
|
|
86
91
|
/** How an extractor reads. */
|
|
87
92
|
export type DocsExtractorOptions = {
|
|
88
93
|
/** Whether every story's docs will be read, as in a build; a dev server's reads by default. */
|
|
@@ -94,6 +99,8 @@ export type DocsExtractorOptions = {
|
|
|
94
99
|
* never ends before the build does.
|
|
95
100
|
*/
|
|
96
101
|
readonly idleMs?: number;
|
|
102
|
+
/** How long ending the worker waits for its modules to load, in milliseconds; `LOAD_WAIT_MS` by default. */
|
|
103
|
+
readonly loadWaitMs?: number;
|
|
97
104
|
};
|
|
98
105
|
/**
|
|
99
106
|
* How long a dev server's worker stays with nothing to do, in milliseconds, before it ends. Its programs are the
|
|
@@ -114,4 +121,4 @@ export declare const NO_DOCS_EXTRACTOR: DocsExtractor;
|
|
|
114
121
|
* @param options - how it reads, and where it keeps what it read.
|
|
115
122
|
* @returns the extractor.
|
|
116
123
|
*/
|
|
117
|
-
export declare const createDocsExtractor: (docsModule: string, { readsEverything, cacheFolder, idleMs }?: DocsExtractorOptions) => DocsExtractor;
|
|
124
|
+
export declare const createDocsExtractor: (docsModule: string, { readsEverything, cacheFolder, idleMs, loadWaitMs }?: DocsExtractorOptions) => DocsExtractor;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the disk keeps no docs at all (a first start, a build), one warm-up at a time and only while nothing waits. For the
|
|
11
11
|
* same reason a dev server's worker left with nothing to do ends (`IDLE_MS`), so the memory a first start took does
|
|
12
12
|
* not stay with the server until it stops. Requests are answered in order. The worker never keeps the process alive
|
|
13
|
-
* (`unref`), and `close` ends it when the build or the server ends.
|
|
13
|
+
* (`unref`), and `close` ends it when the build or the server ends, once its modules are loaded (`endOnceLoaded`).
|
|
14
14
|
*
|
|
15
15
|
* @module
|
|
16
16
|
*/
|
|
@@ -40,6 +40,11 @@ const YOUNG_GENERATION_MB = 8;
|
|
|
40
40
|
* looks keeps them, renewed after each edit rather than built again (seconds, in a large monorepo).
|
|
41
41
|
*/
|
|
42
42
|
export const IDLE_MS = 5 * 60_000;
|
|
43
|
+
/**
|
|
44
|
+
* How long ending a worker waits for its modules to load, in milliseconds: well past a slow machine's load of the
|
|
45
|
+
* compiler, and a limit so that a docs module whose loading never ends cannot keep a server from closing.
|
|
46
|
+
*/
|
|
47
|
+
const LOAD_WAIT_MS = 30_000;
|
|
43
48
|
/**
|
|
44
49
|
* The worker's young generation in a build, in MB: a build reads every story's docs in one go, and with the dev
|
|
45
50
|
* server's small one it would spend much of that time collecting garbage.
|
|
@@ -75,6 +80,28 @@ export const NO_DOCS_EXTRACTOR = {
|
|
|
75
80
|
listed: () => undefined,
|
|
76
81
|
close: () => Promise.resolve()
|
|
77
82
|
};
|
|
83
|
+
/**
|
|
84
|
+
* Ends a worker once its modules are loaded.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Node 24 aborts the whole process when a worker is terminated while it reads the exports of a CommonJS module it
|
|
88
|
+
* imports, and the compiler is one: a command answered before the warm-up's worker had loaded would die with nothing
|
|
89
|
+
* printed. Past the loading, terminating is safe and stops a program being built at once, which a request to the worker
|
|
90
|
+
* to end by itself would have to wait for.
|
|
91
|
+
*
|
|
92
|
+
* @param worker - the worker.
|
|
93
|
+
* @param loaded - settled once its modules are loaded, or it has ended.
|
|
94
|
+
* @param waitMs - how long to wait for that, in milliseconds.
|
|
95
|
+
*/
|
|
96
|
+
const endOnceLoaded = async (worker, loaded, waitMs) => {
|
|
97
|
+
let limit;
|
|
98
|
+
const overLimit = new Promise((resolve) => {
|
|
99
|
+
limit = setTimeout(resolve, waitMs);
|
|
100
|
+
});
|
|
101
|
+
await Promise.race([loaded, overLimit]);
|
|
102
|
+
clearTimeout(limit);
|
|
103
|
+
await worker.terminate();
|
|
104
|
+
};
|
|
78
105
|
/**
|
|
79
106
|
* A docs extractor with its worker, started on its first request.
|
|
80
107
|
*
|
|
@@ -82,7 +109,7 @@ export const NO_DOCS_EXTRACTOR = {
|
|
|
82
109
|
* @param options - how it reads, and where it keeps what it read.
|
|
83
110
|
* @returns the extractor.
|
|
84
111
|
*/
|
|
85
|
-
export const createDocsExtractor = (docsModule, { readsEverything = false, cacheFolder, idleMs = IDLE_MS } = {}) => {
|
|
112
|
+
export const createDocsExtractor = (docsModule, { readsEverything = false, cacheFolder, idleMs = IDLE_MS, loadWaitMs = LOAD_WAIT_MS } = {}) => {
|
|
86
113
|
// the code that writes the answers: this package's and the framework's docs, whose edit or new version is a miss
|
|
87
114
|
const code = cacheFolder === undefined ? undefined : codeStampOf(docsModule);
|
|
88
115
|
const cacheOf = (kind, isValue) => cacheFolder === undefined || code === undefined
|
|
@@ -91,6 +118,8 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
91
118
|
const docsCache = cacheOf("docs", isKeptDocs);
|
|
92
119
|
const usageCache = cacheOf("usage", isKeptUsage);
|
|
93
120
|
let worker;
|
|
121
|
+
// settled once the current worker's modules are loaded, or it has ended
|
|
122
|
+
let workerLoaded = Promise.resolve();
|
|
94
123
|
let nextId = 0;
|
|
95
124
|
// the changes the watcher reported: an answer read across one may have read a file before it changed, so it is not kept
|
|
96
125
|
let changes = 0;
|
|
@@ -103,17 +132,23 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
103
132
|
// the warm-ups not sent yet: one goes out only when nothing is pending, so a read never waits behind them all
|
|
104
133
|
const warmUps = [];
|
|
105
134
|
let idle;
|
|
135
|
+
// every ending under way: a second close must wait for the first's too, as a process may exit once it resolves
|
|
136
|
+
let ending = Promise.resolve();
|
|
137
|
+
// lets the current worker go, so the next request starts another, and ends it
|
|
138
|
+
const endWorker = async () => {
|
|
139
|
+
const running = worker;
|
|
140
|
+
worker = undefined;
|
|
141
|
+
if (running !== undefined)
|
|
142
|
+
ending = Promise.all([ending, endOnceLoaded(running, workerLoaded, loadWaitMs)]);
|
|
143
|
+
await ending;
|
|
144
|
+
};
|
|
106
145
|
// ends the worker once nothing is asked of it for a while; a request in the meantime keeps it
|
|
107
146
|
const stopWhenIdle = () => {
|
|
108
147
|
clearTimeout(idle);
|
|
109
148
|
// a build reads every story, and a quiet stretch in it would build every program again
|
|
110
149
|
if (readsEverything || pending.size > 0 || warmUps.length > 0)
|
|
111
150
|
return;
|
|
112
|
-
idle = setTimeout(() =>
|
|
113
|
-
const running = worker;
|
|
114
|
-
worker = undefined;
|
|
115
|
-
void running?.terminate();
|
|
116
|
-
}, idleMs);
|
|
151
|
+
idle = setTimeout(() => void endWorker(), idleMs);
|
|
117
152
|
idle.unref();
|
|
118
153
|
};
|
|
119
154
|
const sendWarmUp = () => {
|
|
@@ -135,7 +170,16 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
135
170
|
}
|
|
136
171
|
});
|
|
137
172
|
created.unref();
|
|
173
|
+
workerLoaded = new Promise((resolve) => {
|
|
174
|
+
created.on("message", (message) => {
|
|
175
|
+
if (message.kind === "loaded")
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
created.once("exit", () => resolve());
|
|
179
|
+
});
|
|
138
180
|
created.on("message", (response) => {
|
|
181
|
+
if (response.kind === "loaded")
|
|
182
|
+
return;
|
|
139
183
|
pending.get(response.id)?.(response);
|
|
140
184
|
pending.delete(response.id);
|
|
141
185
|
sendWarmUp();
|
|
@@ -229,10 +273,8 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
229
273
|
},
|
|
230
274
|
close: async () => {
|
|
231
275
|
clearTimeout(idle);
|
|
232
|
-
const running = worker;
|
|
233
|
-
worker = undefined;
|
|
234
276
|
warmUps.length = 0;
|
|
235
|
-
await
|
|
277
|
+
await endWorker();
|
|
236
278
|
}
|
|
237
279
|
};
|
|
238
280
|
};
|
|
@@ -17,7 +17,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
17
17
|
* read builds one, and a changed source file (`{ kind: "changed" }`) renews them on their next read. A usage request
|
|
18
18
|
* (`{ kind: "usage", id, files }`) is answered from the same programs (`usageGraph.ts`). A request is `{ id, storyFile }`, the answer `{ id, result }`, `extractStoryComponent`'s result as
|
|
19
19
|
* it is (plain data), with the files it can change with (`docsDependencies.ts`), which the dev server keeps it on disk
|
|
20
|
-
* with.
|
|
20
|
+
* with. Unasked, it says `{ kind: "loaded" }` once its modules are loaded.
|
|
21
21
|
*
|
|
22
22
|
* @module
|
|
23
23
|
*/
|
|
@@ -55,6 +55,11 @@ if (isWorkerData(workerData) && !workerData.readsEverything)
|
|
|
55
55
|
readAskedFilesOnly();
|
|
56
56
|
/** The framework's docs module, loaded once, before the first answer. */
|
|
57
57
|
const docsModule = loadDocsModule();
|
|
58
|
+
// the extractor waits for this before it ends the thread: `endOnceLoaded` says why
|
|
59
|
+
void docsModule.then(() => {
|
|
60
|
+
const loaded = { kind: "loaded" };
|
|
61
|
+
parentPort?.postMessage(loaded);
|
|
62
|
+
});
|
|
58
63
|
/** The extractor's result, or why it threw: a throw would end the worker and every request waiting on it. */
|
|
59
64
|
const extracted = async (storyFile) => {
|
|
60
65
|
const module = await docsModule;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgebook",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "See every component in every theme, contrast, language and width: CSF stories, one frame per story, and a props table from the components' types.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -232,6 +232,10 @@
|
|
|
232
232
|
"./code-literal": {
|
|
233
233
|
"types": "./dist/src/codeLiteral.d.ts",
|
|
234
234
|
"default": "./dist/src/codeLiteral.js"
|
|
235
|
+
},
|
|
236
|
+
"./story-mocks": {
|
|
237
|
+
"types": "./dist/src/storyMocks.d.ts",
|
|
238
|
+
"default": "./dist/src/storyMocks.js"
|
|
235
239
|
}
|
|
236
240
|
},
|
|
237
241
|
"dependencies": {
|
|
@@ -240,7 +244,7 @@
|
|
|
240
244
|
"lightningcss": "1.33.0",
|
|
241
245
|
"marked": "18.1.0",
|
|
242
246
|
"typescript-6": "npm:typescript@6.0.3",
|
|
243
|
-
"@forgebook/story-format": "0.2.
|
|
247
|
+
"@forgebook/story-format": "0.2.3"
|
|
244
248
|
},
|
|
245
249
|
"peerDependencies": {
|
|
246
250
|
"@testing-library/dom": "^10.4.0",
|
|
@@ -248,7 +252,7 @@
|
|
|
248
252
|
"react": "^19.0.0",
|
|
249
253
|
"react-dom": "^19.0.0",
|
|
250
254
|
"sass-embedded": "^1.80.0",
|
|
251
|
-
"vite": "^8.
|
|
255
|
+
"vite": "^8.3.4"
|
|
252
256
|
},
|
|
253
257
|
"devDependencies": {
|
|
254
258
|
"@testing-library/dom": "10.4.2",
|