forgebook 0.3.1 → 0.3.3

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 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, ↑ and ↓
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
@@ -1086,8 +1095,9 @@ it, and the Embed button's **Follow the story's height** adds the few lines that
1086
1095
  iframe, since its modules are loaded as those of its own origin.
1087
1096
 
1088
1097
  An embed sends nothing but the height and listens to nothing from the page around it, and it keeps working where a browser
1089
- denies a third-party frame its storage. What it does take from outside is its address: like the catalogue's own `args`, the
1090
- address reaches the story's props, so a story that renders a prop as raw HTML should not be published for anyone to frame.
1098
+ denies a third-party frame its storage. What it does take from outside is its address, checked as the catalogue's own
1099
+ `args` are: a story receives only the props its panel can set, with a value of their type. A prop the story does declare
1100
+ still reaches it, so a story that renders a prop as raw HTML should not be published for anyone to frame.
1091
1101
  And a height says something: a page that can reach your catalogue can tell whether a story exists.
1092
1102
 
1093
1103
  Forgebook sets no framing headers, on the dev server or in a build, so what you host keeps the rules it has. To choose who may
@@ -84,9 +84,9 @@ in JSON with `--json`, and every answer about a story carries the links that ope
84
84
  the window (`style={{ flex: 1 }}`) needs `parameters.layout: "fullscreen"`; the app's root providers
85
85
  (`SafeAreaProvider`, a theme) go in the preview's decorators. A component that calls a native module or a gesture
86
86
  of native code does not draw in a browser: give it a `.web.tsx` file, or mock what it calls in the preview.
87
- - A link may carry the Props tab's values (`args=` in the address, JSON). The catalogue applies only the props the
88
- story's panel can set, with a value of their type; to show a story with other values, write a story with those
89
- args rather than a link.
87
+ - A link or an embed may carry the Props tab's values (`args=` in the address, JSON). The catalogue applies only the
88
+ props the story's panel can set, with a value of their type; to show a story with other values, write a story with
89
+ those args rather than a link.
90
90
  - `parameters.height` gives the frame the room an overlay the story opens needs (a menu, a popover), in CSS px.
91
91
  - A screen meant for a phone or a tablet can be shown in a drawn device by default:
92
92
  `parameters: { device: { frame: true, model: "iphone-16" } }` (on the meta for every story; `orientation:
@@ -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
@@ -12,12 +12,16 @@
12
12
  * left out and reported. A value that is code never comes from an address: a named value is looked up among the
13
13
  * source's own entries only, never through an object's prototype (`constructor`). Pure.
14
14
  *
15
+ * A frame takes values from one kind of message only, whichever frame it is (the stage's, an embed's, a plugin's aside),
16
+ * so `checkedArgsMessage` builds it, from nothing but what this check sealed (`AcceptedArgs`): a page that skipped the
17
+ * check has no value of that type to give it. A test holds every other module to not writing the message itself.
18
+ *
15
19
  * @module
16
20
  */
17
21
  import type { StoryControlsSetting } from "@forgebook/story-format/source";
18
22
  import { type PanelArgTypes, type PanelProp } from "./argTypes.ts";
19
23
  import type { PropDocs } from "./componentDocs.ts";
20
- import { type ArgValue, type PanelArgs } from "./messages.ts";
24
+ import { type ArgValue, type PanelArgs, type ShellMessage } from "./messages.ts";
21
25
  import { type NamedValues } from "./namedValues.ts";
22
26
  /** What a story declares about the args the panel sets, as the shell knows it once the story's frame is ready. */
23
27
  export type ArgDeclaration = {
@@ -47,7 +51,20 @@ export type CheckedArgs = {
47
51
  readonly accepted: PanelArgs;
48
52
  /** The values left out, in the address's order. */
49
53
  readonly dropped: readonly DroppedArg[];
54
+ /** The values the story receives, as a frame can be told them. */
55
+ readonly sealed: AcceptedArgs;
56
+ };
57
+ /** What only this module can write on values: the type system's proof that they went through the check. */
58
+ declare const ACCEPTED: unique symbol;
59
+ /** Values the check accepted, the only ones a frame can be told. */
60
+ export type AcceptedArgs = {
61
+ /** The values. */
62
+ readonly values: PanelArgs;
63
+ /** Marks them as the check's. */
64
+ readonly [ACCEPTED]: true;
50
65
  };
66
+ /** No values: what a frame the address has no say in receives (a story on a docs page). */
67
+ export declare const NO_ACCEPTED_ARGS: AcceptedArgs;
51
68
  /**
52
69
  * The props the panel sets for a story, whatever the address says: the component's props with the `argTypes` over
53
70
  * them and the args only the `argTypes` name, then the story's own args the two leave out, each kept only when the
@@ -72,3 +89,10 @@ export declare const isValueOfProp: (prop: PanelProp, value: ArgValue, namedValu
72
89
  * @param declaration - what the story declares.
73
90
  */
74
91
  export declare const checkedAddressArgs: (args: PanelArgs, declaration: ArgDeclaration) => CheckedArgs;
92
+ /**
93
+ * The message that gives a frame its values.
94
+ *
95
+ * @param accepted - the values `checkedAddressArgs` accepted.
96
+ */
97
+ export declare const checkedArgsMessage: (accepted: AcceptedArgs) => ShellMessage;
98
+ export {};
@@ -1,6 +1,10 @@
1
1
  import { panelProps, settableProps } from "./argTypes.js";
2
2
  import { isChoiceList, isNamedArg } from "./messages.js";
3
3
  import { namedValueOf } from "./namedValues.js";
4
+ /** What only this module can write on values: the type system's proof that they went through the check. */
5
+ const ACCEPTED = Symbol("accepted");
6
+ /** No values: what a frame the address has no say in receives (a story on a docs page). */
7
+ export const NO_ACCEPTED_ARGS = { values: {}, [ACCEPTED]: true };
4
8
  /** A prop made from one of the story's own args, for an arg neither the types nor the `argTypes` name. */
5
9
  const propOfStoryArg = (name, value) => {
6
10
  const prop = { name, type: "", description: "", isRequired: false };
@@ -105,5 +109,15 @@ export const checkedAddressArgs = (args, declaration) => {
105
109
  else
106
110
  accepted.push([name, value]);
107
111
  }
108
- return { accepted: Object.fromEntries(accepted), dropped };
112
+ const values = Object.fromEntries(accepted);
113
+ return { accepted: values, dropped, sealed: { values, [ACCEPTED]: true } };
109
114
  };
115
+ /**
116
+ * The message that gives a frame its values.
117
+ *
118
+ * @param accepted - the values `checkedAddressArgs` accepted.
119
+ */
120
+ export const checkedArgsMessage = (accepted) => ({
121
+ type: "forgebook:args",
122
+ args: accepted.values
123
+ });
@@ -15,9 +15,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  */
16
16
  import "./embed.css";
17
17
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
18
+ import { checkedArgsMessage } from "../addressArgs.js";
18
19
  import { addressArgsFor } from "../addressTexts.js";
19
20
  import { EMBED_SIZE_TYPE } from "../embed.js";
20
21
  import { parsePanelArgsParam } from "../messages.js";
22
+ import { sourceFiles } from "../sourceFiles.js";
21
23
  import { storyBackground } from "../storyBackground.js";
22
24
  import { NOT_FIXED, withFixed } from "../storyGlobals.js";
23
25
  import { findStory, storyTree } from "../storyTree.js";
@@ -28,11 +30,17 @@ import { framesOrigin } from "./framesOrigin.js";
28
30
  import { LATER_STRINGS } from "./laterStrings.js";
29
31
  import { shellIsDark } from "./shellScheme.js";
30
32
  import { STRINGS } from "./strings.js";
33
+ import { useAddressArgs } from "./useAddressArgs.js";
31
34
  import { APPEARANCE_ATTRIBUTE } from "./useAppearance.js";
32
35
  import { useFrameMessages } from "./useFrameMessages.js";
36
+ import { usePanelDocs } from "./usePanelDocs.js";
33
37
  import { useSystemDark } from "./useSystemDark.js";
34
38
  /** The height of the frame before its story reports its own, in CSS px, in a page with no height of its own yet. */
35
39
  const WAITING_HEIGHT = 240;
40
+ /** No actions, before the frame reported its own. */
41
+ const NO_ACTIONS = [];
42
+ /** No `argTypes`, before the frame reported its own. */
43
+ const NO_ARG_TYPES = {};
36
44
  /**
37
45
  * Tells the page around the embed how tall the story is.
38
46
  *
@@ -98,17 +106,34 @@ const EmbedNotice = ({ message, detail, isFailure }) => {
98
106
  *
99
107
  * @param props - the story, the view, its args and the frame page.
100
108
  */
101
- const EmbedStory = ({ location, view, panelArgs, frameUrl }) => {
109
+ const EmbedStory = ({ location, view, panelArgs, loadDocs, namedValues, frameUrl }) => {
102
110
  const catalogue = useCatalogue();
103
111
  const { story, component, source } = location;
104
112
  const [fixed, setFixed] = useState(NOT_FIXED);
105
113
  const [failure, setFailure] = useState(undefined);
114
+ const [ready, setReady] = useState(undefined);
115
+ const storyRef = useRef(null);
116
+ // the docs only serve the check: an embed whose address names no value does not load them
117
+ const hasValues = Object.keys(panelArgs).length > 0;
118
+ const docs = usePanelDocs(hasValues ? loadDocs : undefined, component.name, ready?.args, ready?.actions ?? NO_ACTIONS);
119
+ // whoever embeds the story writes its address, as whoever shares a link does: the same check applies
120
+ const addressArgs = useAddressArgs({
121
+ panelArgs,
122
+ docs,
123
+ storyArgs: ready?.args,
124
+ argTypes: ready?.argTypes ?? NO_ARG_TYPES,
125
+ controls: ready?.controls ?? "all",
126
+ namedValues,
127
+ frames: storyRef
128
+ });
106
129
  const { heights, settled } = useFrameMessages({
107
130
  onReady: (frame, message) => {
108
131
  setFixed(message.fixed);
109
132
  setFailure(undefined);
110
- const args = { type: "forgebook:args", args: panelArgs };
111
- frame.postMessage(args, framesOrigin());
133
+ setReady(message);
134
+ const accepted = addressArgs.acceptedFor(message);
135
+ if (accepted !== undefined)
136
+ frame.postMessage(checkedArgsMessage(accepted), framesOrigin());
112
137
  },
113
138
  onCall: () => undefined,
114
139
  onFailed: (_storyId, message) => setFailure(message)
@@ -135,7 +160,7 @@ const EmbedStory = ({ location, view, panelArgs, frameUrl }) => {
135
160
  useHostHeight(failure === undefined ? ownHeight : undefined);
136
161
  return (_jsxs("main", { className: "forgebook-embed", style: {
137
162
  backgroundColor: storyBackground(source.background, shownView, catalogue, isSystemDark)
138
- }, children: [failure === undefined ? null : (_jsx(EmbedNotice, { message: STRINGS.embedFailed, detail: failure, isFailure: true })), _jsx("div", { className: "forgebook-embed__story", hidden: failure !== undefined, style: { width: shownView.width === "fill" ? undefined : shownView.width }, children: _jsx(FrameView, { title: STRINGS.frameTitle(component.name, story.name), address: address, heights: heights, settled: settled, fallbackHeight: WAITING_HEIGHT, sceneHeight: Math.max(story.height ?? 0, windowHeight), fixedHeight: fixedHeight }) })] }));
163
+ }, children: [failure === undefined ? null : (_jsx(EmbedNotice, { message: STRINGS.embedFailed, detail: failure, isFailure: true })), _jsx("div", { ref: storyRef, className: "forgebook-embed__story", hidden: failure !== undefined, style: { width: shownView.width === "fill" ? undefined : shownView.width }, children: _jsx(FrameView, { title: STRINGS.frameTitle(component.name, story.name), address: address, heights: heights, settled: settled, fallbackHeight: WAITING_HEIGHT, sceneHeight: Math.max(story.height ?? 0, windowHeight), fixedHeight: fixedHeight }) })] }));
139
164
  };
140
165
  /**
141
166
  * The page: the story the address names, or why there is none.
@@ -150,15 +175,16 @@ const EmbedContent = ({ title, sources, frameUrl, addressTexts }) => {
150
175
  const storyId = params.get("story");
151
176
  const location = storyId === null ? undefined : findStory(tree, storyId);
152
177
  const name = location?.component.name;
178
+ // one object for as long as the address is read: the check is not run again for each render
179
+ const panelArgs = useMemo(() => addressArgsFor(parsePanelArgsParam(params.get("args")), addressTexts), [params, addressTexts]);
153
180
  useEffect(() => {
154
181
  document.title = name === undefined ? title : `${name} · ${title}`;
155
182
  }, [name, title]);
156
183
  if (location === undefined) {
157
184
  return (_jsx("main", { className: "forgebook-embed", children: _jsx(EmbedNotice, { message: storyId === null ? LATER_STRINGS.embedNoStory : LATER_STRINGS.embedUnknownStory(storyId) }) }));
158
185
  }
159
- return (_jsx(EmbedStory, { location: location, view: parseView(params.toString(), catalogue),
160
- // whoever embeds the story writes its address, as whoever shares a link does: the same rule applies
161
- panelArgs: addressArgsFor(parsePanelArgsParam(params.get("args")), addressTexts), frameUrl: frameUrl }));
186
+ const source = sources.find((each) => each.id === location.source.id);
187
+ return (_jsx(EmbedStory, { location: location, view: parseView(params.toString(), catalogue), panelArgs: panelArgs, loadDocs: source === undefined ? undefined : sourceFiles(source).docs[location.story.file], namedValues: source?.namedValues, frameUrl: frameUrl }));
162
188
  };
163
189
  /**
164
190
  * One story alone, inside the catalogue's settings.
@@ -5,6 +5,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
5
5
  * @module
6
6
  */
7
7
  import { useCallback, useMemo, useRef } from "react";
8
+ import { NO_ACCEPTED_ARGS } from "../addressArgs.js";
8
9
  import { findStory } from "../storyTree.js";
9
10
  import { frameAddress, isComparing, liveView } from "../viewState.js";
10
11
  import { showAsideStory } from "./asideStory.js";
@@ -29,7 +30,7 @@ export const PluginBlockHost = ({ load, pluginId, argument, body, page }) => {
29
30
  address: frameAddress(frameUrl, options.storyId, view, switches),
30
31
  width: "fill",
31
32
  background: backgroundOf(location.source),
32
- args: {},
33
+ args: NO_ACCEPTED_ARGS,
33
34
  view: isComparing(view) ? undefined : liveView(view)
34
35
  });
35
36
  }, []);
@@ -5,7 +5,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
5
5
  *
6
6
  * @module
7
7
  */
8
- import { useCallback, useMemo, useRef } from "react";
8
+ import { useCallback, useEffect, useMemo, useRef } from "react";
9
9
  import { showAsideStory } from "./asideStory.js";
10
10
  import { PluginViewHost, pluginText, useShellAppearance } from "./PluginViewHost.js";
11
11
  /**
@@ -18,7 +18,24 @@ export const PluginTabHost = ({ load, pluginId, strings, story, report, aside })
18
18
  // read when the view asks: the stage of that moment, without a new context for every change of the stage
19
19
  const asideRef = useRef(aside);
20
20
  asideRef.current = aside;
21
- const showStory = useCallback((target, { title }) => showAsideStory(target, title, asideRef.current), []);
21
+ const asideFrames = useRef(new Set());
22
+ const showStory = useCallback((target, { title }) => {
23
+ const frame = showAsideStory(target, title, asideRef.current);
24
+ asideFrames.current.add(frame);
25
+ return {
26
+ remove: () => {
27
+ asideFrames.current.delete(frame);
28
+ frame.remove();
29
+ }
30
+ };
31
+ }, []);
32
+ const { args } = aside;
33
+ useEffect(() => {
34
+ if (args === undefined)
35
+ return;
36
+ for (const frame of asideFrames.current)
37
+ frame.settle(args);
38
+ }, [args]);
22
39
  const text = useMemo(() => pluginText(strings, pluginId), [strings, pluginId]);
23
40
  const context = useMemo(() => ({ appearance, text, story, report, showStory }), [appearance, text, story, report, showStory]);
24
41
  return _jsx(PluginViewHost, { load: load, context: context });
@@ -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 < BESIDE_WIDTH)
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 {
@@ -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 { SidebarHandle } from "./SidebarHandle.js";
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, onHide: () => setIsSidebarOpen(false), 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)) }), isSidebarOpen ? _jsx(SidebarHandle, { width: sidebarWidth, onWidthChange: setSidebarWidth }) : null, _jsxs("main", { className: "forgebook-main", children: [_jsx(ShellBar, { isHome: hasNoStories(shown), view: shownView, onViewChange: changeShownView, isStoryPage: shown.kind === "story", isComponentPage: shown.kind === "component", isLogic: isLogicPage(shown), onShowSidebar: isSidebarOpen ? undefined : () => setIsSidebarOpen(true), 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: {
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
- else
77
- onQueryChange("");
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);
@@ -12,9 +12,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * @module
13
13
  */
14
14
  import { useEffect, useMemo, useRef, useState } from "react";
15
+ import { checkedArgsMessage } from "../addressArgs.js";
15
16
  import { panelProps, settableProps } from "../argTypes.js";
16
17
  import { matrixOf, matrixProps } from "../matrix.js";
17
- import { panelArgsParam, parsePanelArgsParam } from "../messages.js";
18
+ import { panelArgsParam } from "../messages.js";
18
19
  import { withFixed, withOwnFixed } from "../storyGlobals.js";
19
20
  import { frameAddress, isComparing, liveView, schemeOf, viewParams } from "../viewState.js";
20
21
  import { useStoryClockControls } from "./ClockControls.js";
@@ -127,7 +128,8 @@ export const Stage = ({ component, story, view: ownView, onViewChange: onOwnView
127
128
  storyArgs: storyArgs.get(story.id),
128
129
  argTypes: panel.argTypes,
129
130
  controls: panel.controls,
130
- namedValues
131
+ namedValues,
132
+ frames: storyRef
131
133
  });
132
134
  const acceptedArgs = addressArgs.accepted;
133
135
  const values = { ...storyArgs.get(story.id), ...acceptedArgs };
@@ -160,7 +162,7 @@ export const Stage = ({ component, story, view: ownView, onViewChange: onOwnView
160
162
  // checked against the shown story's declaration only: a late message of the story shown before sends nothing
161
163
  const accepted = message.storyId === story.id ? addressArgs.acceptedFor(message) : undefined;
162
164
  if (accepted !== undefined)
163
- send({ type: "forgebook:args", args: accepted });
165
+ send(checkedArgsMessage(accepted));
164
166
  send({ type: "forgebook:matrix", matrix });
165
167
  // the view may have changed while the frame loaded
166
168
  if (!isComparing(view))
@@ -186,14 +188,6 @@ export const Stage = ({ component, story, view: ownView, onViewChange: onOwnView
186
188
  // the docs may arrive after the frames: they learn the events then
187
189
  useEffect(() => tellFrames(storyRef.current, { type: "forgebook:events", names: eventNames }), [eventNames]);
188
190
  useEffect(() => tellFrames(storyRef.current, { type: "forgebook:matrix", matrix }), [matrix]);
189
- // the values reach the frames from the address, so going back in the history sets them too; compared as text, so
190
- // a frame is told again only when they change
191
- const { acceptedParam } = addressArgs;
192
- useEffect(() => {
193
- if (acceptedParam === undefined)
194
- return;
195
- tellFrames(storyRef.current, { type: "forgebook:args", args: parsePanelArgsParam(acceptedParam) });
196
- }, [acceptedParam]);
197
191
  // a change starts from the values the story takes: one the address named and the story does not is let go
198
192
  const changeValue = (name, value) => {
199
193
  const { [name]: _removed, ...rest } = acceptedArgs;
@@ -235,7 +229,8 @@ export const Stage = ({ component, story, view: ownView, onViewChange: onOwnView
235
229
  address: deviceAddress(frameAddress(frameUrl, story.id, view, switches), framed),
236
230
  width: framed?.area.width ?? view.width,
237
231
  background: backgroundOf(view),
238
- args: panelArgs,
232
+ // as the stage's frames receive them: an aside asked for while the check waits for the docs is told when it ends
233
+ args: addressArgs.checked,
239
234
  view: liveView(view)
240
235
  }
241
236
  });
@@ -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;