forgebook 0.2.0 → 0.3.1

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.
Files changed (94) hide show
  1. package/README.md +113 -5
  2. package/dist/bin/forgebook/agentLoopCommands.mjs +3 -0
  3. package/dist/bin/forgebook/agentText.d.ts +2 -2
  4. package/dist/bin/forgebook/agentText.js +15 -13
  5. package/dist/bin/forgebook/agents.md +21 -6
  6. package/dist/bin/forgebook/mcpTools.js +1 -1
  7. package/dist/bin/forgebook/newStoriesFile.js +7 -2
  8. package/dist/bin/forgebook-docs/collectDocs.js +1 -1
  9. package/dist/src/argsProps.d.ts +32 -0
  10. package/dist/src/argsProps.js +69 -0
  11. package/dist/src/catalogue.d.ts +48 -5
  12. package/dist/src/catalogue.js +47 -8
  13. package/dist/src/frame/mountFrame.js +26 -7
  14. package/dist/src/frame/touchCaret.d.ts +43 -0
  15. package/dist/src/frame/touchCaret.js +195 -0
  16. package/dist/src/frame/touchEvents.d.ts +3 -2
  17. package/dist/src/frame/touchEvents.js +17 -18
  18. package/dist/src/frame/touchLayer.js +48 -7
  19. package/dist/src/propGroups.d.ts +66 -3
  20. package/dist/src/propGroups.js +67 -13
  21. package/dist/src/shell/ApiReference.d.ts +9 -4
  22. package/dist/src/shell/ApiReference.js +47 -39
  23. package/dist/src/shell/AudienceSections.js +1 -1
  24. package/dist/src/shell/ComponentPage.d.ts +5 -2
  25. package/dist/src/shell/ComponentPage.js +4 -18
  26. package/dist/src/shell/DocBlocks.js +1 -1
  27. package/dist/src/shell/PropRows.d.ts +42 -0
  28. package/dist/src/shell/PropRows.js +60 -0
  29. package/dist/src/shell/PropsTab.d.ts +14 -10
  30. package/dist/src/shell/PropsTab.js +18 -58
  31. package/dist/src/shell/RequiredFirstSwitch.d.ts +16 -0
  32. package/dist/src/shell/RequiredFirstSwitch.js +8 -0
  33. package/dist/src/shell/Stage.js +11 -23
  34. package/dist/src/shell/StoryCoverage.js +4 -1
  35. package/dist/src/shell/docPage.css +6 -0
  36. package/dist/src/shell/dockTabs.d.ts +7 -5
  37. package/dist/src/shell/dockTabs.js +4 -4
  38. package/dist/src/shell/reportedPanel.d.ts +12 -0
  39. package/dist/src/shell/reportedPanel.js +19 -0
  40. package/dist/src/shell/shell.css +56 -1
  41. package/dist/src/shell/strings.d.ts +2 -0
  42. package/dist/src/shell/strings.js +2 -0
  43. package/dist/src/shell/usePanelDocs.d.ts +11 -0
  44. package/dist/src/shell/usePanelDocs.js +29 -0
  45. package/dist/src/shell/useRequiredFirst.d.ts +27 -0
  46. package/dist/src/shell/useRequiredFirst.js +67 -0
  47. package/dist/src/storyCheck.d.ts +2 -0
  48. package/dist/src/storyCheck.js +2 -1
  49. package/dist/src/storyGlobals.js +2 -1
  50. package/dist/src/storyTree.d.ts +11 -1
  51. package/dist/src/storyTree.js +3 -1
  52. package/dist/src/viewState.d.ts +7 -0
  53. package/dist/src/viewState.js +13 -0
  54. package/dist/src/vite/agent.types.d.ts +12 -0
  55. package/dist/src/vite/agentApi.js +6 -1
  56. package/dist/src/vite/agentRoutes.js +18 -4
  57. package/dist/src/vite/builtTexts.d.ts +2 -2
  58. package/dist/src/vite/builtTexts.js +7 -6
  59. package/dist/src/vite/configFile.d.ts +3 -2
  60. package/dist/src/vite/configFile.js +63 -14
  61. package/dist/src/vite/docPagesModule.d.ts +10 -7
  62. package/dist/src/vite/docPagesModule.js +14 -11
  63. package/dist/src/vite/docsExtractor.d.ts +6 -0
  64. package/dist/src/vite/docsExtractor.js +13 -0
  65. package/dist/src/vite/fileAccess.d.ts +75 -0
  66. package/dist/src/vite/fileAccess.js +199 -0
  67. package/dist/src/vite/forgebookPlugin.d.ts +1 -1
  68. package/dist/src/vite/forgebookPlugin.js +67 -98
  69. package/dist/src/vite/framesServer.js +21 -5
  70. package/dist/src/vite/generatedModules.d.ts +23 -0
  71. package/dist/src/vite/generatedModules.js +58 -0
  72. package/dist/src/vite/hotSnippets.js +7 -3
  73. package/dist/src/vite/indexStoryFile.d.ts +2 -2
  74. package/dist/src/vite/indexStoryFile.js +2 -2
  75. package/dist/src/vite/indexedParameters.d.ts +1 -1
  76. package/dist/src/vite/indexedParameters.js +21 -1
  77. package/dist/src/vite/originGuard.d.ts +14 -4
  78. package/dist/src/vite/originGuard.js +15 -6
  79. package/dist/src/vite/pages.d.ts +3 -2
  80. package/dist/src/vite/pages.js +23 -16
  81. package/dist/src/vite/pluginIds.d.ts +24 -2
  82. package/dist/src/vite/pluginIds.js +44 -6
  83. package/dist/src/vite/pluginModules.d.ts +39 -10
  84. package/dist/src/vite/pluginModules.js +67 -22
  85. package/dist/src/vite/prebundled.js +2 -1
  86. package/dist/src/vite/searchModule.d.ts +6 -4
  87. package/dist/src/vite/searchModule.js +8 -7
  88. package/dist/src/vite/storyFilesModule.d.ts +59 -27
  89. package/dist/src/vite/storyFilesModule.js +88 -58
  90. package/dist/src/vite/storyPatterns.d.ts +39 -0
  91. package/dist/src/vite/storyPatterns.js +100 -0
  92. package/dist/src/vite/textQueries.d.ts +7 -1
  93. package/dist/src/vite/textQueries.js +7 -1
  94. package/package.json +4 -4
@@ -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: [...PANEL_TAB_IDS, ...pluginTabs].filter((tab) => tabsOff[tab] !== false),
158
- a11y: {
159
- tags: lists.a11y?.tags ?? DEFAULT_A11Y_TAGS,
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.usedIn !== false,
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
- checkStory(document, rules, axeUrl)
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
- applyView(document.documentElement, withFixed(message.view, fixed), catalogue);
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 === shownCount)
278
- reportError(error instanceof Error ? error.message : String(error));
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) => void;
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
- 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
- }));
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 simulation: the events are not trusted, so the browser's own scrolling, focus from a
15
- * touch, a virtual keyboard and real Safari stay a device's (or `forgebook dev --touch`'s).
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
- sendMouse("mousedown", first);
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.
@@ -1,16 +1,24 @@
1
1
  /**
2
- * A component's props in their groups, as its TSDoc names them (`@group Selection`): the API reference and the Props
3
- * tab list them the same way.
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. Pure.
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 {};