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 +15 -5
- package/dist/bin/forgebook/agents.md +5 -4
- package/dist/src/addressArgs.d.ts +25 -1
- package/dist/src/addressArgs.js +15 -1
- package/dist/src/shell/EmbedPage.js +33 -7
- package/dist/src/shell/PluginBlockHost.js +2 -1
- package/dist/src/shell/PluginTabHost.js +19 -2
- 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/Stage.js +7 -12
- package/dist/src/shell/Toolbar.d.ts +6 -4
- package/dist/src/shell/Toolbar.js +3 -3
- package/dist/src/shell/asideStory.d.ts +17 -4
- package/dist/src/shell/asideStory.js +14 -2
- package/dist/src/shell/shell.css +28 -1
- package/dist/src/shell/useAddressArgs.d.ts +18 -2
- package/dist/src/shell/useAddressArgs.js +19 -7
- 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
|
@@ -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
|
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
|
-
import { type
|
|
10
|
+
import { type AcceptedArgs } from "../addressArgs.ts";
|
|
11
11
|
import type { PluginStoryFrame } from "../plugin.ts";
|
|
12
12
|
import type { LiveView, StageWidth } from "../viewState.ts";
|
|
13
13
|
/** How the story is drawn again. */
|
|
@@ -18,11 +18,24 @@ export type AsideStory = {
|
|
|
18
18
|
readonly width: StageWidth;
|
|
19
19
|
/** The colour its page has in the view; none without one. */
|
|
20
20
|
readonly background: string | undefined;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The values the story accepted of the panel's, sent once it is ready; `undefined` while the check waits for the
|
|
23
|
+
* story's docs, when the frame draws the story's own and `AsideFrame.settle` gives them.
|
|
24
|
+
*/
|
|
25
|
+
readonly args: AcceptedArgs | undefined;
|
|
23
26
|
/** The view's live part, sent once it is ready; none when the address already says it all (the compare view). */
|
|
24
27
|
readonly view: LiveView | undefined;
|
|
25
28
|
};
|
|
29
|
+
/** A story's second frame, as the shell holds it. */
|
|
30
|
+
export type AsideFrame = PluginStoryFrame & {
|
|
31
|
+
/**
|
|
32
|
+
* Gives a frame drawn before the check ended the values it accepted; a frame that has them keeps them, since it shows
|
|
33
|
+
* the panel's values of the moment it was asked for.
|
|
34
|
+
*
|
|
35
|
+
* @param args - the accepted values.
|
|
36
|
+
*/
|
|
37
|
+
readonly settle: (args: AcceptedArgs) => void;
|
|
38
|
+
};
|
|
26
39
|
/**
|
|
27
40
|
* Draws a story's second frame in an element.
|
|
28
41
|
*
|
|
@@ -31,4 +44,4 @@ export type AsideStory = {
|
|
|
31
44
|
* @param aside - how the story is drawn.
|
|
32
45
|
* @returns what takes it away.
|
|
33
46
|
*/
|
|
34
|
-
export declare const showAsideStory: (target: HTMLElement, title: string, aside: AsideStory) =>
|
|
47
|
+
export declare const showAsideStory: (target: HTMLElement, title: string, aside: AsideStory) => AsideFrame;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
|
+
import { checkedArgsMessage } from "../addressArgs.js";
|
|
10
11
|
import { isFrameMessage } from "../messages.js";
|
|
11
12
|
import { framesOrigin, isFromFrames, storyFrameAttributes } from "./framesOrigin.js";
|
|
12
13
|
import { FRAME_ASIDE_ATTRIBUTE } from "./useFrameMessages.js";
|
|
@@ -32,14 +33,18 @@ export const showAsideStory = (target, title, aside) => {
|
|
|
32
33
|
frame.style.width = aside.width === "fill" ? "100%" : `${aside.width}px`;
|
|
33
34
|
if (aside.background !== undefined)
|
|
34
35
|
target.style.backgroundColor = aside.background;
|
|
36
|
+
let args = aside.args;
|
|
37
|
+
let isReady = false;
|
|
38
|
+
const send = (message) => frame.contentWindow?.postMessage(message, framesOrigin());
|
|
35
39
|
const answer = (event) => {
|
|
36
40
|
const story = frame.contentWindow;
|
|
37
41
|
if (!isFromFrames(event) || event.source !== story || story === null)
|
|
38
42
|
return;
|
|
39
43
|
if (!isFrameMessage(event.data) || event.data.type !== "forgebook:ready")
|
|
40
44
|
return;
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
isReady = true;
|
|
46
|
+
if (args !== undefined)
|
|
47
|
+
send(checkedArgsMessage(args));
|
|
43
48
|
if (aside.view !== undefined)
|
|
44
49
|
send({ type: "forgebook:view", view: aside.view });
|
|
45
50
|
};
|
|
@@ -50,6 +55,13 @@ export const showAsideStory = (target, title, aside) => {
|
|
|
50
55
|
remove: () => {
|
|
51
56
|
window.removeEventListener("message", answer);
|
|
52
57
|
frame.remove();
|
|
58
|
+
},
|
|
59
|
+
settle: (accepted) => {
|
|
60
|
+
if (args !== undefined)
|
|
61
|
+
return;
|
|
62
|
+
args = accepted;
|
|
63
|
+
if (isReady)
|
|
64
|
+
send(checkedArgsMessage(accepted));
|
|
53
65
|
}
|
|
54
66
|
};
|
|
55
67
|
};
|
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,4 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The props panel's values an address names, as a page hands them to the story's frames: only those the story declares,
|
|
3
|
+
* with a value of the declared type, once the story's frame and its component's docs say what that is.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* The address is written by whoever shares the link, so its values are checked here, in the core, before any frame
|
|
7
|
+
* receives them, whatever framework draws the story (`addressArgs.ts`). Until the story's frame is ready and its docs
|
|
8
|
+
* are read (or known to be missing), no value is sent: the frame draws the story's own args meanwhile.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { type RefObject } from "react";
|
|
13
|
+
import { type AcceptedArgs, type ArgDeclaration, type DroppedArg } from "../addressArgs.ts";
|
|
2
14
|
import type { ComponentDocs } from "../componentDocs.ts";
|
|
3
15
|
import { type PanelArgs } from "../messages.ts";
|
|
4
16
|
import type { NamedValues } from "../namedValues.ts";
|
|
@@ -29,11 +41,15 @@ export type AddressArgsInput = {
|
|
|
29
41
|
readonly controls: ReadyDeclaration["controls"];
|
|
30
42
|
/** The source's named values; none when it has none. */
|
|
31
43
|
readonly namedValues: NamedValues | undefined;
|
|
44
|
+
/** The element that holds the story's frames, which hear each change of the checked values. */
|
|
45
|
+
readonly frames: RefObject<HTMLElement | null>;
|
|
32
46
|
};
|
|
33
47
|
/** The address's values once checked, and how to check them for a frame that has just reported ready. */
|
|
34
48
|
export type AddressArgs = {
|
|
35
49
|
/** The values the story receives; none before its declaration is known. */
|
|
36
50
|
readonly accepted: PanelArgs;
|
|
51
|
+
/** The same values for a frame that waits for the check; `undefined` until the declaration is known. */
|
|
52
|
+
readonly checked: AcceptedArgs | undefined;
|
|
37
53
|
/** The values left out, with the reason. */
|
|
38
54
|
readonly dropped: readonly DroppedArg[];
|
|
39
55
|
/**
|
|
@@ -45,7 +61,7 @@ export type AddressArgs = {
|
|
|
45
61
|
* The values to send a frame that has just reported ready, from its own message, since the state that holds it
|
|
46
62
|
* updates after; none while the docs load.
|
|
47
63
|
*/
|
|
48
|
-
readonly acceptedFor: (ready: ReadyDeclaration) =>
|
|
64
|
+
readonly acceptedFor: (ready: ReadyDeclaration) => AcceptedArgs | undefined;
|
|
49
65
|
};
|
|
50
66
|
/**
|
|
51
67
|
* The address's values, checked against what the story declares.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The props panel's values an address names, as
|
|
3
|
-
*
|
|
2
|
+
* The props panel's values an address names, as a page hands them to the story's frames: only those the story declares,
|
|
3
|
+
* with a value of the declared type, once the story's frame and its component's docs say what that is.
|
|
4
4
|
*
|
|
5
5
|
* @remarks
|
|
6
6
|
* The address is written by whoever shares the link, so its values are checked here, in the core, before any frame
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module
|
|
11
11
|
*/
|
|
12
|
-
import { useMemo } from "react";
|
|
13
|
-
import { checkedAddressArgs } from "../addressArgs.js";
|
|
12
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
13
|
+
import { checkedAddressArgs, checkedArgsMessage } from "../addressArgs.js";
|
|
14
14
|
import { panelArgsParam } from "../messages.js";
|
|
15
|
+
import { tellFrames } from "./FrameView.js";
|
|
15
16
|
/**
|
|
16
17
|
* What a story declares, once both its frame and its docs answered.
|
|
17
18
|
*
|
|
@@ -43,20 +44,31 @@ const NO_NAMED_VALUES = {};
|
|
|
43
44
|
* @param input - the address's values and what the stage knows of the story.
|
|
44
45
|
*/
|
|
45
46
|
export const useAddressArgs = (input) => {
|
|
46
|
-
const { panelArgs, docs, storyArgs, argTypes, controls } = input;
|
|
47
|
+
const { panelArgs, docs, storyArgs, argTypes, controls, frames } = input;
|
|
47
48
|
const namedValues = input.namedValues ?? NO_NAMED_VALUES;
|
|
48
49
|
const checked = useMemo(() => {
|
|
49
50
|
const ready = storyArgs === undefined ? undefined : { args: storyArgs, argTypes, controls };
|
|
50
51
|
const declaration = declarationOf(docs, ready, namedValues);
|
|
51
52
|
return declaration === undefined ? undefined : checkedAddressArgs(panelArgs, declaration);
|
|
52
53
|
}, [panelArgs, docs, storyArgs, argTypes, controls, namedValues]);
|
|
54
|
+
const acceptedParam = checked === undefined ? undefined : panelArgsParam(checked.accepted);
|
|
55
|
+
// the values reach the frames from the address, so going back in the history sets them too; compared as text, so a
|
|
56
|
+
// frame is told again only when they change
|
|
57
|
+
const sealedRef = useRef(checked?.sealed);
|
|
58
|
+
sealedRef.current = checked?.sealed;
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (acceptedParam === undefined || sealedRef.current === undefined)
|
|
61
|
+
return;
|
|
62
|
+
tellFrames(frames.current, checkedArgsMessage(sealedRef.current));
|
|
63
|
+
}, [acceptedParam, frames]);
|
|
53
64
|
return {
|
|
54
65
|
accepted: checked?.accepted ?? NO_ARGS,
|
|
66
|
+
checked: checked?.sealed,
|
|
55
67
|
dropped: checked?.dropped ?? NOTHING_DROPPED,
|
|
56
|
-
acceptedParam
|
|
68
|
+
acceptedParam,
|
|
57
69
|
acceptedFor: (message) => {
|
|
58
70
|
const declaration = declarationOf(docs, message, namedValues);
|
|
59
|
-
return declaration === undefined ? undefined : checkedAddressArgs(panelArgs, declaration).
|
|
71
|
+
return declaration === undefined ? undefined : checkedAddressArgs(panelArgs, declaration).sealed;
|
|
60
72
|
}
|
|
61
73
|
};
|
|
62
74
|
};
|
|
@@ -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>);
|