forgebook 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -5
- package/dist/bin/forgebook/agentLoopCommands.mjs +3 -0
- package/dist/bin/forgebook/agentText.d.ts +2 -2
- package/dist/bin/forgebook/agentText.js +15 -13
- package/dist/bin/forgebook/agents.md +21 -6
- package/dist/bin/forgebook/mcpTools.js +1 -1
- package/dist/bin/forgebook/newStoriesFile.js +7 -2
- package/dist/bin/forgebook-docs/collectDocs.js +1 -1
- package/dist/src/argsProps.d.ts +32 -0
- package/dist/src/argsProps.js +69 -0
- package/dist/src/catalogue.d.ts +48 -5
- package/dist/src/catalogue.js +47 -8
- package/dist/src/frame/mountFrame.js +26 -7
- package/dist/src/frame/touchCaret.d.ts +43 -0
- package/dist/src/frame/touchCaret.js +195 -0
- package/dist/src/frame/touchEvents.d.ts +3 -2
- package/dist/src/frame/touchEvents.js +17 -18
- package/dist/src/frame/touchLayer.js +48 -7
- package/dist/src/propGroups.d.ts +66 -3
- package/dist/src/propGroups.js +67 -13
- package/dist/src/shell/ApiReference.d.ts +9 -4
- package/dist/src/shell/ApiReference.js +47 -39
- package/dist/src/shell/AudienceSections.js +1 -1
- package/dist/src/shell/ComponentPage.d.ts +5 -2
- package/dist/src/shell/ComponentPage.js +4 -18
- package/dist/src/shell/DocBlocks.js +1 -1
- package/dist/src/shell/PropRows.d.ts +42 -0
- package/dist/src/shell/PropRows.js +60 -0
- package/dist/src/shell/PropsTab.d.ts +14 -10
- package/dist/src/shell/PropsTab.js +18 -58
- package/dist/src/shell/RequiredFirstSwitch.d.ts +16 -0
- package/dist/src/shell/RequiredFirstSwitch.js +8 -0
- package/dist/src/shell/Stage.js +11 -23
- package/dist/src/shell/StoryCoverage.js +4 -1
- package/dist/src/shell/docPage.css +6 -0
- package/dist/src/shell/dockTabs.d.ts +7 -5
- package/dist/src/shell/dockTabs.js +4 -4
- package/dist/src/shell/reportedPanel.d.ts +12 -0
- package/dist/src/shell/reportedPanel.js +19 -0
- package/dist/src/shell/shell.css +56 -1
- package/dist/src/shell/strings.d.ts +2 -0
- package/dist/src/shell/strings.js +2 -0
- package/dist/src/shell/usePanelDocs.d.ts +11 -0
- package/dist/src/shell/usePanelDocs.js +29 -0
- package/dist/src/shell/useRequiredFirst.d.ts +27 -0
- package/dist/src/shell/useRequiredFirst.js +67 -0
- package/dist/src/storyCheck.d.ts +2 -0
- package/dist/src/storyCheck.js +2 -1
- package/dist/src/storyGlobals.js +2 -1
- package/dist/src/storyTree.d.ts +11 -1
- package/dist/src/storyTree.js +3 -1
- package/dist/src/viewState.d.ts +7 -0
- package/dist/src/viewState.js +13 -0
- package/dist/src/vite/agent.types.d.ts +12 -0
- package/dist/src/vite/agentApi.js +6 -1
- package/dist/src/vite/agentRoutes.js +18 -4
- package/dist/src/vite/builtTexts.d.ts +2 -2
- package/dist/src/vite/builtTexts.js +7 -6
- package/dist/src/vite/configFile.d.ts +3 -2
- package/dist/src/vite/configFile.js +63 -14
- package/dist/src/vite/docPagesModule.d.ts +10 -7
- package/dist/src/vite/docPagesModule.js +14 -11
- package/dist/src/vite/docsExtractor.d.ts +6 -0
- package/dist/src/vite/docsExtractor.js +13 -0
- package/dist/src/vite/fileAccess.d.ts +51 -0
- package/dist/src/vite/fileAccess.js +109 -0
- package/dist/src/vite/forgebookPlugin.d.ts +1 -1
- package/dist/src/vite/forgebookPlugin.js +61 -96
- package/dist/src/vite/framesServer.js +21 -5
- package/dist/src/vite/generatedModules.d.ts +23 -0
- package/dist/src/vite/generatedModules.js +58 -0
- package/dist/src/vite/hotSnippets.js +7 -3
- package/dist/src/vite/indexStoryFile.d.ts +2 -2
- package/dist/src/vite/indexStoryFile.js +2 -2
- package/dist/src/vite/indexedParameters.d.ts +1 -1
- package/dist/src/vite/indexedParameters.js +21 -1
- package/dist/src/vite/originGuard.d.ts +14 -4
- package/dist/src/vite/originGuard.js +15 -6
- package/dist/src/vite/pages.d.ts +3 -2
- package/dist/src/vite/pages.js +23 -16
- package/dist/src/vite/pluginIds.d.ts +9 -2
- package/dist/src/vite/pluginIds.js +21 -6
- package/dist/src/vite/pluginModules.d.ts +39 -10
- package/dist/src/vite/pluginModules.js +67 -22
- package/dist/src/vite/prebundled.js +2 -1
- package/dist/src/vite/searchModule.d.ts +6 -4
- package/dist/src/vite/searchModule.js +8 -7
- package/dist/src/vite/storyFilesModule.d.ts +59 -27
- package/dist/src/vite/storyFilesModule.js +88 -58
- package/dist/src/vite/storyPatterns.d.ts +39 -0
- package/dist/src/vite/storyPatterns.js +100 -0
- package/dist/src/vite/textQueries.d.ts +7 -1
- package/dist/src/vite/textQueries.js +7 -1
- package/package.json +2 -2
package/dist/src/catalogue.js
CHANGED
|
@@ -120,6 +120,47 @@ const namedGroups = (lists) => ({
|
|
|
120
120
|
storyGroups: lists.storyGroups ?? [],
|
|
121
121
|
propGroups: lists.propGroups ?? []
|
|
122
122
|
});
|
|
123
|
+
/**
|
|
124
|
+
* Whether a catalogue checks accessibility: unless its lists say `a11y: false`.
|
|
125
|
+
*
|
|
126
|
+
* @param lists - the lists its configuration sets.
|
|
127
|
+
*/
|
|
128
|
+
export const checksA11y = (lists) => lists.a11y !== false;
|
|
129
|
+
/**
|
|
130
|
+
* Whether a catalogue reads its components' docs from their types: unless its lists say `componentDocs: false`.
|
|
131
|
+
*
|
|
132
|
+
* @param lists - the lists its configuration sets.
|
|
133
|
+
*/
|
|
134
|
+
export const readsComponentDocs = (lists) => lists.componentDocs !== false;
|
|
135
|
+
/**
|
|
136
|
+
* Whether a catalogue offers "Used in": unless its lists turn it off, or turn off the component docs, whose
|
|
137
|
+
* TypeScript programs read the import graph it is joined from.
|
|
138
|
+
*
|
|
139
|
+
* @param lists - the lists its configuration sets.
|
|
140
|
+
*/
|
|
141
|
+
export const offersUsedIn = (lists) => lists.usedIn !== false && readsComponentDocs(lists);
|
|
142
|
+
/**
|
|
143
|
+
* The panel's tabs a catalogue shows: Forgebook's then its plugins', less those it turns off, and the Accessibility
|
|
144
|
+
* tab when it checks nothing.
|
|
145
|
+
*
|
|
146
|
+
* @param lists - the lists its configuration sets.
|
|
147
|
+
* @param pluginTabs - the ids of the tabs its plugins add.
|
|
148
|
+
*/
|
|
149
|
+
const shownTabs = (lists, pluginTabs) => {
|
|
150
|
+
const tabsOff = lists.tabs ?? {};
|
|
151
|
+
const hasA11y = checksA11y(lists);
|
|
152
|
+
return [...PANEL_TAB_IDS, ...pluginTabs].filter((tab) => tabsOff[tab] !== false && (hasA11y || tab !== "a11y"));
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* A catalogue's accessibility rules, the defaults filled in; none when it checks nothing.
|
|
156
|
+
*
|
|
157
|
+
* @param lists - the lists its configuration sets.
|
|
158
|
+
*/
|
|
159
|
+
const a11ySettings = (lists) => {
|
|
160
|
+
if (lists.a11y === false)
|
|
161
|
+
return undefined;
|
|
162
|
+
return { tags: lists.a11y?.tags ?? DEFAULT_A11Y_TAGS, disabledRules: lists.a11y?.disabledRules ?? [] };
|
|
163
|
+
};
|
|
123
164
|
/**
|
|
124
165
|
* A catalogue's settings, its lists' defaults filled in.
|
|
125
166
|
*
|
|
@@ -136,7 +177,6 @@ export const catalogueSettings = (title, switches, lists = {}, pluginTabs = [])
|
|
|
136
177
|
const offered = builtIn === false
|
|
137
178
|
? []
|
|
138
179
|
: BUILT_IN_BACKDROPS.filter((backdrop) => builtIn === undefined || builtIn.includes(backdrop.id));
|
|
139
|
-
const tabsOff = lists.tabs ?? {};
|
|
140
180
|
const controlsOff = lists.controls ?? {};
|
|
141
181
|
const ownControls = OWN_CONTROL_IDS.filter((control) => controlsOff[control] !== false);
|
|
142
182
|
const named = lists.toolbar ?? defaultToolbar(switches, ownControls);
|
|
@@ -154,23 +194,22 @@ export const catalogueSettings = (title, switches, lists = {}, pluginTabs = [])
|
|
|
154
194
|
textSizes: withHundred(lists.textSizes, [100, 125, 150]),
|
|
155
195
|
backdrops: [...offered, ...(lists.backgrounds?.custom ?? []).map(customBackdrop)],
|
|
156
196
|
...namedGroups(lists),
|
|
157
|
-
tabs:
|
|
158
|
-
a11y:
|
|
159
|
-
|
|
160
|
-
disabledRules: lists.a11y?.disabledRules ?? []
|
|
161
|
-
},
|
|
197
|
+
tabs: shownTabs(lists, pluginTabs),
|
|
198
|
+
a11y: a11ySettings(lists),
|
|
199
|
+
hasComponentDocs: readsComponentDocs(lists),
|
|
162
200
|
gridStep: lists.gridStep ?? 8,
|
|
163
201
|
strings: lists.strings ?? {},
|
|
164
202
|
ownControls,
|
|
165
203
|
toolbar,
|
|
166
|
-
hasUsedIn: lists
|
|
204
|
+
hasUsedIn: offersUsedIn(lists),
|
|
167
205
|
audience,
|
|
168
206
|
hasEmbed: controlsOff.embed !== false,
|
|
169
207
|
appearance: lists.appearance ?? "system",
|
|
170
208
|
keys: usableKeys([...switches.map((each) => each.id), ...ownControls], lists.keys),
|
|
171
209
|
views: catalogueViews(switches, lists.views),
|
|
172
210
|
// checked when the configuration loads (`configFile.ts`): a model that is none is left out here
|
|
173
|
-
device: deviceDefaultOf(NO_DEVICE, lists.device)
|
|
211
|
+
device: deviceDefaultOf(NO_DEVICE, lists.device),
|
|
212
|
+
requiredFirst: lists.requiredFirst ?? "in-groups"
|
|
174
213
|
};
|
|
175
214
|
};
|
|
176
215
|
/**
|
|
@@ -28,8 +28,7 @@ import { fixedViewOf, NOT_FIXED, withFixed } from "../storyGlobals.js";
|
|
|
28
28
|
import { shownTexts, storyTextKeysOf, withStoryTexts } from "../storyTexts.js";
|
|
29
29
|
import { findStory, storyTree } from "../storyTree.js";
|
|
30
30
|
import { catalogueSwitches } from "../switches.js";
|
|
31
|
-
import { codeGlobalsOf, frameAddress, languageOf, parseView } from "../viewState.js";
|
|
32
|
-
import { checkStory } from "./checkStory.js";
|
|
31
|
+
import { codeGlobalsOf, frameAddress, isSameLiveView, languageOf, liveView, parseView } from "../viewState.js";
|
|
33
32
|
import { followTouch } from "./followTouch.js";
|
|
34
33
|
import { storyFrameClock } from "./frameClock.js";
|
|
35
34
|
import { FRAME_STARTED_ATTRIBUTE, STATUS_ATTRIBUTE } from "./frameStatus.js";
|
|
@@ -61,7 +60,7 @@ import.meta.hot?.on("vite:afterUpdate", () => {
|
|
|
61
60
|
* @param message - the message.
|
|
62
61
|
* @param controls - the rendered story's controls.
|
|
63
62
|
* @param storyId - the story's id, for the answers.
|
|
64
|
-
* @param rules - the catalogue's accessibility rules, for a check.
|
|
63
|
+
* @param rules - the catalogue's accessibility rules, for a check; none when it checks no accessibility.
|
|
65
64
|
* @param axeUrl - the address of axe's script, for a check.
|
|
66
65
|
* @param tellShell - tells the shell the check's result.
|
|
67
66
|
*/
|
|
@@ -81,7 +80,12 @@ const answerShell = (message, controls, storyId, rules, axeUrl, tellShell) => {
|
|
|
81
80
|
else if (message.type === "forgebook:clock-restart")
|
|
82
81
|
controls.restartClock();
|
|
83
82
|
else if (message.type === "forgebook:check") {
|
|
84
|
-
|
|
83
|
+
// loaded on the first check: a frame that is never checked, or one of a catalogue that checks nothing, never
|
|
84
|
+
// fetches the code that loads axe
|
|
85
|
+
const checked = rules === undefined
|
|
86
|
+
? Promise.reject(new Error("The catalogue checks no accessibility (a11y: false)"))
|
|
87
|
+
: import("./checkStory.js").then((module) => module.checkStory(document, rules, axeUrl));
|
|
88
|
+
checked
|
|
85
89
|
.then((violations) => tellShell({ type: "forgebook:checked", storyId, violations }))
|
|
86
90
|
.catch((error) => tellShell({
|
|
87
91
|
type: "forgebook:check-error",
|
|
@@ -119,6 +123,9 @@ export const mountFrame = async (container, config) => {
|
|
|
119
123
|
let fixed = NOT_FIXED;
|
|
120
124
|
// set once the story runs: a view the shell sends then changes its language and its globals without a new mount
|
|
121
125
|
let changeShown;
|
|
126
|
+
// the view both the root and the story's drawing show; none when they may differ, so the next view is applied whatever
|
|
127
|
+
// it says: a view that comes before the story runs reaches only the root, and an edit draws the story with new globals
|
|
128
|
+
let shownView = liveView(view);
|
|
122
129
|
// the shell sends the live part of a changed view: applied on the root, and kept in the address so a reload (an
|
|
123
130
|
// edit, a stale module) shows the same view
|
|
124
131
|
const showSpills = spillOutline(document, ".forgebook-frame", (count) => tellShell({ type: "forgebook:spills", storyId, count }));
|
|
@@ -165,8 +172,15 @@ export const mountFrame = async (container, config) => {
|
|
|
165
172
|
showSpills(message.isOn);
|
|
166
173
|
if (message.type !== "forgebook:view")
|
|
167
174
|
return;
|
|
168
|
-
|
|
175
|
+
const shown = withFixed(message.view, fixed);
|
|
176
|
+
// the same view again draws nothing: a story's state, and the time its draw takes, are not the shell's to spend
|
|
177
|
+
if (changeShown !== undefined &&
|
|
178
|
+
shownView !== undefined &&
|
|
179
|
+
isSameLiveView(withFixed(shownView, fixed), shown))
|
|
180
|
+
return;
|
|
181
|
+
applyView(document.documentElement, shown, catalogue);
|
|
169
182
|
changeShown?.(message.view);
|
|
183
|
+
shownView = changeShown === undefined ? undefined : message.view;
|
|
170
184
|
const address = frameAddress(window.location.pathname, storyId, { ...view, ...message.view }, switches);
|
|
171
185
|
window.history.replaceState(null, "", address);
|
|
172
186
|
});
|
|
@@ -274,8 +288,11 @@ export const mountFrame = async (container, config) => {
|
|
|
274
288
|
})
|
|
275
289
|
.catch((error) => {
|
|
276
290
|
// a language another choice replaced meanwhile is no longer the frame's concern
|
|
277
|
-
if (count
|
|
278
|
-
|
|
291
|
+
if (count !== shownCount)
|
|
292
|
+
return;
|
|
293
|
+
// the story still shows the view before this one: choosing this one again must try again
|
|
294
|
+
shownView = undefined;
|
|
295
|
+
reportError(error instanceof Error ? error.message : String(error));
|
|
279
296
|
});
|
|
280
297
|
};
|
|
281
298
|
},
|
|
@@ -314,6 +331,7 @@ export const mountFrame = async (container, config) => {
|
|
|
314
331
|
return;
|
|
315
332
|
}
|
|
316
333
|
story = next;
|
|
334
|
+
shownView = undefined;
|
|
317
335
|
running.setStory(next);
|
|
318
336
|
if (controls !== undefined)
|
|
319
337
|
tellReady();
|
|
@@ -333,6 +351,7 @@ export const mountFrame = async (container, config) => {
|
|
|
333
351
|
return;
|
|
334
352
|
}
|
|
335
353
|
story = next;
|
|
354
|
+
shownView = undefined;
|
|
336
355
|
running.setStory(next, [...preview.decorators, ...plugins.decorators]);
|
|
337
356
|
if (controls !== undefined)
|
|
338
357
|
tellReady();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a simulated finger's tap does to an editable field: it focuses the field and puts the caret under the finger, the
|
|
3
|
+
* reactions a dispatched event never gets from the browser. Used by the touch layer (`touchLayer.ts`), which loads
|
|
4
|
+
* only when the Touch switch is on.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* `caretPositionFromPoint` answers the character's place for an input or a textarea (the field itself as its node, in
|
|
8
|
+
* Chromium, Firefox and WebKit) and for a contenteditable (a text node). `caretRangeFromPoint`, for a browser without it,
|
|
9
|
+
* answers only for a contenteditable: for the other two it names an unrelated place, so they go to their end there.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import type { Point } from "./touchGesture.ts";
|
|
14
|
+
/**
|
|
15
|
+
* The editable field a node is in, the one a finger on it would focus: an input with a caret or a textarea, not
|
|
16
|
+
* disabled (a read-only one still takes the caret, as under a real finger), or a contenteditable's editing host.
|
|
17
|
+
*
|
|
18
|
+
* @param node - the node (a contenteditable's text, for one).
|
|
19
|
+
* @returns the field; none when the node is in no such field.
|
|
20
|
+
*/
|
|
21
|
+
export declare const editableFieldOf: (node: Node) => HTMLElement | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The element under a point, inside the open shadow roots on the way (`elementFromPoint` stops at their hosts).
|
|
24
|
+
*
|
|
25
|
+
* @param document - the frame's document.
|
|
26
|
+
* @param point - the point.
|
|
27
|
+
*/
|
|
28
|
+
export declare const deepElementAt: (document: Document, point: Point) => Element | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* A tap lands at a point: the editable field under it, if any, takes the focus and the caret.
|
|
31
|
+
*
|
|
32
|
+
* @param document - the frame's document.
|
|
33
|
+
* @param point - where the finger tapped.
|
|
34
|
+
*/
|
|
35
|
+
export declare const tapField: (document: Document, point: Point) => void;
|
|
36
|
+
/**
|
|
37
|
+
* A tap's click reached a label: the editable field it names takes the focus, which a dispatched click does not give it.
|
|
38
|
+
* The caret stays where the field had it, as under a real finger. A tap on what is interactive inside the label (the
|
|
39
|
+
* field itself, a button) does not activate the label.
|
|
40
|
+
*
|
|
41
|
+
* @param origin - where the click began, inside any shadow root.
|
|
42
|
+
*/
|
|
43
|
+
export declare const tapLabel: (origin: EventTarget | undefined) => void;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** The input types with a caret: the others (a checkbox, a range, a date…) keep the browser's own reaction. */
|
|
2
|
+
const CARET_INPUT_TYPES = new Set([
|
|
3
|
+
"text",
|
|
4
|
+
"search",
|
|
5
|
+
"url",
|
|
6
|
+
"tel",
|
|
7
|
+
"email",
|
|
8
|
+
"password",
|
|
9
|
+
"number"
|
|
10
|
+
]);
|
|
11
|
+
/** What is interactive by itself inside a label: a tap on it does not activate the label. */
|
|
12
|
+
const LABEL_INTERACTIVE = "a[href], button, input, select, textarea, summary, [tabindex]";
|
|
13
|
+
/**
|
|
14
|
+
* Whether a field is an input or a textarea.
|
|
15
|
+
*
|
|
16
|
+
* @param field - the field.
|
|
17
|
+
*/
|
|
18
|
+
const isTextControl = (field) => field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement;
|
|
19
|
+
/**
|
|
20
|
+
* The editable field a node is in, the one a finger on it would focus: an input with a caret or a textarea, not
|
|
21
|
+
* disabled (a read-only one still takes the caret, as under a real finger), or a contenteditable's editing host.
|
|
22
|
+
*
|
|
23
|
+
* @param node - the node (a contenteditable's text, for one).
|
|
24
|
+
* @returns the field; none when the node is in no such field.
|
|
25
|
+
*/
|
|
26
|
+
export const editableFieldOf = (node) => {
|
|
27
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
28
|
+
if (!(element instanceof HTMLElement))
|
|
29
|
+
return undefined;
|
|
30
|
+
if (element instanceof HTMLTextAreaElement)
|
|
31
|
+
return element.matches(":disabled") ? undefined : element;
|
|
32
|
+
if (element instanceof HTMLInputElement)
|
|
33
|
+
return CARET_INPUT_TYPES.has(element.type) && !element.matches(":disabled") ? element : undefined;
|
|
34
|
+
if (!element.isContentEditable)
|
|
35
|
+
return undefined;
|
|
36
|
+
// only the editing host takes the focus: what is inside it is edited through it
|
|
37
|
+
let host = element;
|
|
38
|
+
while (host.parentElement?.isContentEditable === true)
|
|
39
|
+
host = host.parentElement;
|
|
40
|
+
return host;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The element under a point, inside the open shadow roots on the way (`elementFromPoint` stops at their hosts).
|
|
44
|
+
*
|
|
45
|
+
* @param document - the frame's document.
|
|
46
|
+
* @param point - the point.
|
|
47
|
+
*/
|
|
48
|
+
export const deepElementAt = (document, point) => {
|
|
49
|
+
let element = document.elementFromPoint(point.x, point.y);
|
|
50
|
+
while (element?.shadowRoot) {
|
|
51
|
+
const inner = element.shadowRoot.elementFromPoint(point.x, point.y);
|
|
52
|
+
if (inner === null || inner === element)
|
|
53
|
+
break;
|
|
54
|
+
element = inner;
|
|
55
|
+
}
|
|
56
|
+
return element ?? undefined;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The shadow roots a field is inside: the caret's lookup does not enter one it is not given.
|
|
60
|
+
*
|
|
61
|
+
* @param field - the field.
|
|
62
|
+
*/
|
|
63
|
+
const shadowRootsAround = (field) => {
|
|
64
|
+
const roots = [];
|
|
65
|
+
let root = field.getRootNode();
|
|
66
|
+
while (root instanceof ShadowRoot) {
|
|
67
|
+
roots.push(root);
|
|
68
|
+
root = root.host.getRootNode();
|
|
69
|
+
}
|
|
70
|
+
return roots;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Where the browser would put the caret at a point, when that is in the field.
|
|
74
|
+
*
|
|
75
|
+
* @param document - the frame's document.
|
|
76
|
+
* @param point - the point.
|
|
77
|
+
* @param field - the field.
|
|
78
|
+
* @returns the place; none when the browser cannot tell.
|
|
79
|
+
*/
|
|
80
|
+
const caretSpotAt = (document, point, field) => {
|
|
81
|
+
let spot;
|
|
82
|
+
if (typeof document.caretPositionFromPoint === "function") {
|
|
83
|
+
const position = document.caretPositionFromPoint(point.x, point.y, {
|
|
84
|
+
shadowRoots: shadowRootsAround(field)
|
|
85
|
+
});
|
|
86
|
+
if (position !== null)
|
|
87
|
+
spot = { node: position.offsetNode, offset: position.offset };
|
|
88
|
+
}
|
|
89
|
+
else if (!isTextControl(field)) {
|
|
90
|
+
const range = document.caretRangeFromPoint(point.x, point.y);
|
|
91
|
+
if (range !== null)
|
|
92
|
+
spot = { node: range.startContainer, offset: range.startOffset };
|
|
93
|
+
}
|
|
94
|
+
return spot !== undefined && field.contains(spot.node) ? spot : undefined;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Puts the caret in an input or a textarea, at the end without an offset.
|
|
98
|
+
*
|
|
99
|
+
* @param document - the frame's document.
|
|
100
|
+
* @param control - the field, focused.
|
|
101
|
+
* @param offset - how far into its value.
|
|
102
|
+
*/
|
|
103
|
+
const placeInControl = (document, control, offset) => {
|
|
104
|
+
if (control.selectionStart !== null) {
|
|
105
|
+
const place = offset ?? control.value.length;
|
|
106
|
+
control.setSelectionRange(place, place);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// an email or a number field has no selection range to set, but the document's selection walks through it, focused:
|
|
110
|
+
// it lands on the place in Chromium and WebKit, not in Firefox
|
|
111
|
+
const selection = document.getSelection();
|
|
112
|
+
if (selection === null)
|
|
113
|
+
return;
|
|
114
|
+
if (offset === undefined) {
|
|
115
|
+
selection.modify("move", "forward", "lineboundary");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
selection.modify("move", "backward", "lineboundary");
|
|
119
|
+
// the offset counts UTF-16 code units, the steps whole characters
|
|
120
|
+
const before = control.value.slice(0, offset);
|
|
121
|
+
const steps = Array.from(new Intl.Segmenter().segment(before)).length;
|
|
122
|
+
for (let step = 0; step < steps; step += 1)
|
|
123
|
+
selection.modify("move", "forward", "character");
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Puts the caret in a contenteditable, at its end without a place.
|
|
127
|
+
*
|
|
128
|
+
* @param document - the frame's document.
|
|
129
|
+
* @param host - the editing host, focused.
|
|
130
|
+
* @param spot - the place.
|
|
131
|
+
*/
|
|
132
|
+
const placeInEditable = (document, host, spot) => {
|
|
133
|
+
const selection = document.getSelection();
|
|
134
|
+
if (selection === null)
|
|
135
|
+
return;
|
|
136
|
+
// the spot was read before the focus: a focus handler may have changed the text since
|
|
137
|
+
if (spot !== undefined && host.contains(spot.node)) {
|
|
138
|
+
const { node } = spot;
|
|
139
|
+
const length = node instanceof CharacterData ? node.length : node.childNodes.length;
|
|
140
|
+
selection.collapse(node, Math.min(spot.offset, length));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
selection.selectAllChildren(host);
|
|
144
|
+
selection.collapseToEnd();
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Whether a field holds the focus: a story's focus handler may have moved it on.
|
|
148
|
+
*
|
|
149
|
+
* @param field - the field.
|
|
150
|
+
*/
|
|
151
|
+
const holdsFocus = (field) => {
|
|
152
|
+
const root = field.getRootNode();
|
|
153
|
+
return (root instanceof Document || root instanceof ShadowRoot) && root.activeElement === field;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* A tap lands at a point: the editable field under it, if any, takes the focus and the caret.
|
|
157
|
+
*
|
|
158
|
+
* @param document - the frame's document.
|
|
159
|
+
* @param point - where the finger tapped.
|
|
160
|
+
*/
|
|
161
|
+
export const tapField = (document, point) => {
|
|
162
|
+
const element = deepElementAt(document, point);
|
|
163
|
+
const field = element === undefined ? undefined : editableFieldOf(element);
|
|
164
|
+
if (field === undefined)
|
|
165
|
+
return;
|
|
166
|
+
// read before the focus, which may scroll a long field's text from under the finger
|
|
167
|
+
const spot = caretSpotAt(document, point, field);
|
|
168
|
+
field.focus();
|
|
169
|
+
if (!holdsFocus(field))
|
|
170
|
+
return;
|
|
171
|
+
if (isTextControl(field))
|
|
172
|
+
placeInControl(document, field, spot?.offset);
|
|
173
|
+
else
|
|
174
|
+
placeInEditable(document, field, spot);
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* A tap's click reached a label: the editable field it names takes the focus, which a dispatched click does not give it.
|
|
178
|
+
* The caret stays where the field had it, as under a real finger. A tap on what is interactive inside the label (the
|
|
179
|
+
* field itself, a button) does not activate the label.
|
|
180
|
+
*
|
|
181
|
+
* @param origin - where the click began, inside any shadow root.
|
|
182
|
+
*/
|
|
183
|
+
export const tapLabel = (origin) => {
|
|
184
|
+
if (!(origin instanceof Element))
|
|
185
|
+
return;
|
|
186
|
+
const label = origin.closest("label");
|
|
187
|
+
if (label === null)
|
|
188
|
+
return;
|
|
189
|
+
const interactive = origin.closest(LABEL_INTERACTIVE);
|
|
190
|
+
if (interactive !== null && label.contains(interactive))
|
|
191
|
+
return;
|
|
192
|
+
const { control } = label;
|
|
193
|
+
if (control instanceof HTMLElement && editableFieldOf(control) !== undefined)
|
|
194
|
+
control.focus();
|
|
195
|
+
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @remarks
|
|
6
6
|
* A page cannot make trusted events, so these are dispatched ones: the story's handlers see a finger, the browser's
|
|
7
|
-
* own reactions (scrolling, focus) do not follow them. A touch pointer is captured by the element it went down on,
|
|
7
|
+
* own reactions (scrolling, focus) do not follow them: the layer makes up for a tap's focus (`touchCaret.ts`). A touch pointer is captured by the element it went down on,
|
|
8
8
|
* as the browser does; a story that captures it elsewhere moves it there (`touchLayer.ts` patches the capture calls
|
|
9
9
|
* for the simulated pointers' ids).
|
|
10
10
|
*
|
|
@@ -61,5 +61,6 @@ export declare const sendMouse: (type: string, finger: Finger) => boolean;
|
|
|
61
61
|
*
|
|
62
62
|
* @param target - the real click's target.
|
|
63
63
|
* @param finger - the finger that tapped.
|
|
64
|
+
* @returns whether no handler cancelled it.
|
|
64
65
|
*/
|
|
65
|
-
export declare const sendClick: (target: EventTarget, finger: Finger) =>
|
|
66
|
+
export declare const sendClick: (target: EventTarget, finger: Finger) => boolean;
|
|
@@ -130,22 +130,21 @@ export const sendMouse = (type, finger) => {
|
|
|
130
130
|
*
|
|
131
131
|
* @param target - the real click's target.
|
|
132
132
|
* @param finger - the finger that tapped.
|
|
133
|
+
* @returns whether no handler cancelled it.
|
|
133
134
|
*/
|
|
134
|
-
export const sendClick = (target, finger) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}));
|
|
151
|
-
};
|
|
135
|
+
export const sendClick = (target, finger) => target.dispatchEvent(new PointerEvent("click", {
|
|
136
|
+
bubbles: true,
|
|
137
|
+
cancelable: true,
|
|
138
|
+
composed: true,
|
|
139
|
+
view: finger.startTarget.ownerDocument.defaultView,
|
|
140
|
+
detail: 1,
|
|
141
|
+
pointerId: finger.id,
|
|
142
|
+
pointerType: "touch",
|
|
143
|
+
isPrimary: true,
|
|
144
|
+
clientX: finger.point.x,
|
|
145
|
+
clientY: finger.point.y,
|
|
146
|
+
screenX: finger.point.x,
|
|
147
|
+
screenY: finger.point.y,
|
|
148
|
+
width: CONTACT_PX,
|
|
149
|
+
height: CONTACT_PX
|
|
150
|
+
}));
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
* no button down sends nothing: a finger does not hover. Shift fixes a centre beside the mouse (the second finger shows 80 px to its left); a press then puts a
|
|
12
12
|
* second finger mirrored around it, so dragging pinches and rotates. The real cursor is hidden and the fingers are
|
|
13
13
|
* dots (`touchDots.ts`). While it is on, `navigator.maxTouchPoints` and `ontouchstart` say touch, and capturing a
|
|
14
|
-
* simulated pointer works. A
|
|
15
|
-
*
|
|
14
|
+
* simulated pointer works. A tap on an editable field focuses it and puts the caret there (`touchCaret.ts`), the real
|
|
15
|
+
* press on one doing nothing: a finger that drags or holds there must not focus it. A simulation: the events are not
|
|
16
|
+
* trusted, so the browser's own scrolling, a virtual keyboard and real Safari stay a device's (or
|
|
17
|
+
* `forgebook dev --touch`'s).
|
|
16
18
|
*
|
|
17
19
|
* @module
|
|
18
20
|
*/
|
|
21
|
+
import { deepElementAt, editableFieldOf, tapField, tapLabel } from "./touchCaret.js";
|
|
19
22
|
import { touchDots } from "./touchDots.js";
|
|
20
23
|
import { sendClick, sendMouse, sendPointer, sendTouch } from "./touchEvents.js";
|
|
21
24
|
import { dragAxis, isDragging, LONG_PRESS_MS, mirroredFinger } from "./touchGesture.js";
|
|
@@ -166,6 +169,18 @@ const pressMove = (state, press, event) => {
|
|
|
166
169
|
sendPointer("pointermove", finger, true);
|
|
167
170
|
drawDots(state);
|
|
168
171
|
};
|
|
172
|
+
/**
|
|
173
|
+
* A tap's compatibility mouse events, and the focus on an editable field a real finger's comes with: a handler that
|
|
174
|
+
* cancels the `mousedown` leaves the field without it.
|
|
175
|
+
*
|
|
176
|
+
* @param document - the frame's document.
|
|
177
|
+
* @param finger - the finger that tapped.
|
|
178
|
+
*/
|
|
179
|
+
const sendTapMouse = (document, finger) => {
|
|
180
|
+
if (sendMouse("mousedown", finger))
|
|
181
|
+
tapField(document, finger.point);
|
|
182
|
+
sendMouse("mouseup", finger);
|
|
183
|
+
};
|
|
169
184
|
/**
|
|
170
185
|
* A press ends: the fingers lift, a scroll flings on, a tap sends the mouse's compatibility events and readies its
|
|
171
186
|
* click.
|
|
@@ -198,12 +213,19 @@ const pressUp = (state, press, isCancel, time) => {
|
|
|
198
213
|
const isTap = !isCancel && !press.isDrag && !press.isLongPress && press.fingers.length === 1;
|
|
199
214
|
const isClicking = isTap && !press.isTouchCancelled && !isEndCancelled;
|
|
200
215
|
state.tapper = isClicking ? first : undefined;
|
|
201
|
-
if (isClicking && !press.isDownCancelled && first !== undefined)
|
|
202
|
-
|
|
203
|
-
sendMouse("mouseup", first);
|
|
204
|
-
}
|
|
216
|
+
if (isClicking && !press.isDownCancelled && first !== undefined)
|
|
217
|
+
sendTapMouse(state.document, first);
|
|
205
218
|
drawDots(state);
|
|
206
219
|
};
|
|
220
|
+
/**
|
|
221
|
+
* Whether a real event began in an editable field.
|
|
222
|
+
*
|
|
223
|
+
* @param event - the event.
|
|
224
|
+
*/
|
|
225
|
+
const isInField = (event) => {
|
|
226
|
+
const [origin] = event.composedPath();
|
|
227
|
+
return origin instanceof Node && editableFieldOf(origin) !== undefined;
|
|
228
|
+
};
|
|
207
229
|
/**
|
|
208
230
|
* A real event the layer handles apart from the pointer's, before the story: the mouse's click (a tap's is sent again as
|
|
209
231
|
* a finger's, a drag's or a long press's is none), a text selection or an image dragged by the finger, the right
|
|
@@ -226,10 +248,12 @@ const handledApart = (state, event) => {
|
|
|
226
248
|
state.tapper = undefined;
|
|
227
249
|
// after the real click's dispatch: cancelling it undoes a checkbox's toggle at its end, a click sent inside it too
|
|
228
250
|
const { target } = event;
|
|
251
|
+
const [origin] = event.composedPath();
|
|
229
252
|
if (finger !== undefined && target !== null)
|
|
230
253
|
state.clickTimer = window.setTimeout(() => {
|
|
231
254
|
state.clickTimer = undefined;
|
|
232
|
-
sendClick(target, finger)
|
|
255
|
+
if (sendClick(target, finger))
|
|
256
|
+
tapLabel(origin);
|
|
233
257
|
}, 0);
|
|
234
258
|
return true;
|
|
235
259
|
}
|
|
@@ -237,6 +261,9 @@ const handledApart = (state, event) => {
|
|
|
237
261
|
return true;
|
|
238
262
|
if (event.type !== "selectstart" && event.type !== "dragstart" && event.type !== "contextmenu")
|
|
239
263
|
return false;
|
|
264
|
+
// inside a field the keyboard selects (Shift and an arrow): only the finger's selection of the page stays out
|
|
265
|
+
if (event.type === "selectstart" && isInField(event))
|
|
266
|
+
return true;
|
|
240
267
|
event.preventDefault();
|
|
241
268
|
event.stopImmediatePropagation();
|
|
242
269
|
return true;
|
|
@@ -290,6 +317,18 @@ const pointerInput = (state, event) => {
|
|
|
290
317
|
pressUp(state, press, event.type === "pointercancel", event.timeStamp);
|
|
291
318
|
}
|
|
292
319
|
};
|
|
320
|
+
/**
|
|
321
|
+
* A real press on an editable field does nothing there: the browser would focus it and start a selection at once, when
|
|
322
|
+
* a finger's reaction waits for its lift (`touchCaret.ts`) and never comes from a drag or a long press.
|
|
323
|
+
*
|
|
324
|
+
* @param state - the simulation's state.
|
|
325
|
+
* @param event - the real press.
|
|
326
|
+
*/
|
|
327
|
+
const keepFieldForTap = (state, event) => {
|
|
328
|
+
const element = deepElementAt(state.document, { x: event.clientX, y: event.clientY });
|
|
329
|
+
if (element !== undefined && editableFieldOf(element) !== undefined)
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
};
|
|
293
332
|
/**
|
|
294
333
|
* What the simulation does with one real event, before the story sees it: the story never sees a real one.
|
|
295
334
|
*
|
|
@@ -302,6 +341,8 @@ const intercept = (state, event) => {
|
|
|
302
341
|
event.stopImmediatePropagation();
|
|
303
342
|
if (event instanceof PointerEvent)
|
|
304
343
|
pointerInput(state, event);
|
|
344
|
+
else if (event.type === "mousedown" && event instanceof MouseEvent)
|
|
345
|
+
keepFieldForTap(state, event);
|
|
305
346
|
};
|
|
306
347
|
/**
|
|
307
348
|
* Makes the frame say it is a touch device, and lets the story capture a simulated pointer.
|
package/dist/src/propGroups.d.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A component's props in their groups, as its TSDoc names them (`@group Selection`)
|
|
3
|
-
* tab list them the same
|
|
2
|
+
* A component's props in their groups, as its TSDoc names them (`@group Selection`), with its required props where the
|
|
3
|
+
* catalogue, the component or the story asks (`requiredFirst`): the API reference and the Props tab list them the same
|
|
4
|
+
* way.
|
|
4
5
|
*
|
|
5
6
|
* @remarks
|
|
6
7
|
* The props that name no group come first: they are the ones an author left out of every group, most often the few
|
|
7
8
|
* every use of the component sets (`value`, `onChange`). Then each group, in the order a configuration names them
|
|
8
9
|
* (the component's meta, then the catalogue), the others in the order they first appear among the props. A component
|
|
9
10
|
* whose props name no group is one list, as it always was. Every group starts open: a reader folds one away, never the
|
|
10
|
-
* catalogue.
|
|
11
|
+
* catalogue.
|
|
12
|
+
*
|
|
13
|
+
* The required props come first inside each group by default, the others keeping the order of the source, so a reader
|
|
14
|
+
* sees at once what every use must set. A `section` layout gathers them in one section before the groups instead,
|
|
15
|
+
* which only means something when there are groups: without, it is the same single list. The narrowest setting wins
|
|
16
|
+
* (the story's, the component's, the catalogue's), and a reader's switch turns the ordering on or off over them,
|
|
17
|
+
* never choosing the layout, which stays the developer's. Pure.
|
|
11
18
|
*
|
|
12
19
|
* @module
|
|
13
20
|
*/
|
|
21
|
+
import type { RequiredFirst } from "@forgebook/story-format/csf";
|
|
14
22
|
/** A group of props: its name, none for the props that name no group, and its props in their order. */
|
|
15
23
|
export type PropGroup<TProp> = {
|
|
16
24
|
/** The group's name, as `@group` writes it; none for the props that name no group. */
|
|
@@ -44,3 +52,58 @@ export declare const groupedProps: <TProp extends {
|
|
|
44
52
|
export declare const hasPropGroups: (props: readonly {
|
|
45
53
|
readonly group?: string | undefined;
|
|
46
54
|
}[]) => boolean;
|
|
55
|
+
/** The layouts the required props can take, as a configuration and `parameters.requiredFirst` write them. */
|
|
56
|
+
export declare const REQUIRED_FIRST_LAYOUTS: readonly RequiredFirst[];
|
|
57
|
+
/**
|
|
58
|
+
* The layout a reader sees: the narrowest the developer set (the story's or the component's, else the catalogue's),
|
|
59
|
+
* with the reader's switch over it. Turned off, the source's order; turned on where the developer chose the source's
|
|
60
|
+
* order, the catalogue's layout, else `in-groups`.
|
|
61
|
+
*
|
|
62
|
+
* @param own - the story's or the component's setting; none leaves it to the catalogue.
|
|
63
|
+
* @param catalogue - the catalogue's setting.
|
|
64
|
+
* @param isReaderOn - the reader's switch; none when the reader never turned it.
|
|
65
|
+
*/
|
|
66
|
+
export declare const requiredFirstLayout: (own: RequiredFirst | undefined, catalogue: RequiredFirst, isReaderOn: boolean | undefined) => RequiredFirst;
|
|
67
|
+
/** A prop as the layout reads it: its group, whether it is required, and whether it is an event. */
|
|
68
|
+
type LaidOutProp = {
|
|
69
|
+
readonly group?: string | undefined;
|
|
70
|
+
readonly isRequired: boolean;
|
|
71
|
+
readonly kind?: string;
|
|
72
|
+
};
|
|
73
|
+
/** Props laid out for reading: the "Required" section, the groups, the events. */
|
|
74
|
+
export type PropLayout<TProp> = {
|
|
75
|
+
/**
|
|
76
|
+
* The "Required" section's props, those naming no group first, then each group's in the groups' order, then the
|
|
77
|
+
* events; empty unless the layout is `section` and the props name a group.
|
|
78
|
+
*/
|
|
79
|
+
readonly required: readonly TProp[];
|
|
80
|
+
/** The props but the events in their groups (`groupedProps`), without those in `required`; no group left empty. */
|
|
81
|
+
readonly groups: readonly PropGroup<TProp>[];
|
|
82
|
+
/** The events, without those in `required`. */
|
|
83
|
+
readonly events: readonly TProp[];
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Whether putting the required props first changes anything: a required prop and one that is not. A table without
|
|
87
|
+
* shows no "Required first" switch.
|
|
88
|
+
*
|
|
89
|
+
* @param props - the props shown.
|
|
90
|
+
*/
|
|
91
|
+
export declare const canOrderRequired: (props: readonly {
|
|
92
|
+
readonly isRequired: boolean;
|
|
93
|
+
}[]) => boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Props with the required ones first, the rest in their order.
|
|
96
|
+
*
|
|
97
|
+
* @param props - the props, in their order.
|
|
98
|
+
*/
|
|
99
|
+
export declare const requiredPropsFirst: <TProp extends LaidOutProp>(props: readonly TProp[]) => TProp[];
|
|
100
|
+
/**
|
|
101
|
+
* Props laid out for reading: in their groups (`groupedProps`), the events apart, the required ones where the layout
|
|
102
|
+
* puts them.
|
|
103
|
+
*
|
|
104
|
+
* @param props - the props, events among them (`kind: "event"`), in the order of the source.
|
|
105
|
+
* @param order - the groups to show first, in this order (`propGroupOrder`).
|
|
106
|
+
* @param layout - where the required props go (`requiredFirstLayout`).
|
|
107
|
+
*/
|
|
108
|
+
export declare const laidOutProps: <TProp extends LaidOutProp>(props: readonly TProp[], order: readonly string[], layout: RequiredFirst) => PropLayout<TProp>;
|
|
109
|
+
export {};
|