@jsenv/navi 0.29.151 → 0.29.153

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.
@@ -15,6 +15,45 @@ import { parseDuration, durationContainsNaN, compareTwoDurations, durationToSeco
15
15
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
16
16
  import { Suspense, createPortal, forwardRef } from "preact/compat";
17
17
 
18
+ /**
19
+ * One page, one navi.
20
+ *
21
+ * navi keeps contexts, registries and singletons at module scope, so a second
22
+ * copy of the library in the same page is a second of each, and nothing
23
+ * crosses between them. Every symptom of that lands far from the cause and
24
+ * reads as an app bug: a <Loading> mounted by one copy is invisible to the
25
+ * hook reading the other's context and reports itself missing, an action
26
+ * registered in one is unknown to the other, focus and z-index each get a
27
+ * second owner. So the copies are detected here, when the second one is
28
+ * imported, and that import fails — the first thing that goes wrong says what
29
+ * is wrong.
30
+ *
31
+ * A copy is a module evaluation, which is what the object added to the set
32
+ * stands for; the urls are only there to tell the reader which two files to
33
+ * compare. Two urls for one file on disk is the usual shape (a dev server
34
+ * stamping a package version into specifiers, a bundle embedding navi next to
35
+ * a copy resolved separately); two versions installed in node_modules is the
36
+ * other.
37
+ *
38
+ * This module must stay in the entry chunk: the whole point is that it is
39
+ * evaluated once per copy of navi, and a shared chunk would be evaluated once
40
+ * for both.
41
+ */
42
+
43
+ // Symbol.for so the set is the same one across copies — a copy cannot see the
44
+ // other's module scope, which is the very thing being detected.
45
+ const NAVI_INSTANCE_SET = Symbol.for("navi_instance_set");
46
+
47
+ const instanceSet = globalThis[NAVI_INSTANCE_SET] || new Set();
48
+ globalThis[NAVI_INSTANCE_SET] = instanceSet;
49
+ instanceSet.add({ url: import.meta.url });
50
+ if (instanceSet.size > 1) {
51
+ const urlList = Array.from(instanceSet, ({ url }) => `- ${url}`).join("\n");
52
+ throw new Error(`@jsenv/navi is loaded ${instanceSet.size} times in this page:
53
+ ${urlList}
54
+ Each one builds its own contexts, stores and registries, so what one holds is invisible to the components using another.`);
55
+ }
56
+
18
57
  installImportMetaCssBuild(import.meta);/**
19
58
  * Every z-index navi can be seen competing on, in one place, ordered.
20
59
  *
@@ -5315,6 +5354,26 @@ const extractMessageAndRemainingProps = (props) => {
5315
5354
  return [ownMessages, remaining];
5316
5355
  };
5317
5356
 
5357
+ /**
5358
+ * The one-line form of a constraint message, or `undefined` when it has none.
5359
+ *
5360
+ * A message is not always a sentence: it can be an `Error`, or a whole element
5361
+ * (an `errorMapping` returning JSX to put a link inside the callout). Those
5362
+ * still render fine in the callout, but they have no one-line form, and
5363
+ * anything that needs one — a `title` attribute, a caller drawing its own
5364
+ * summary — must be given nothing rather than `String(message)`, which writes
5365
+ * "[object Object]" on the screen.
5366
+ */
5367
+ const getMessageString = (message) => {
5368
+ if (typeof message === "string") {
5369
+ return message;
5370
+ }
5371
+ if (Error.isError(message)) {
5372
+ return message.message;
5373
+ }
5374
+ return undefined;
5375
+ };
5376
+
5318
5377
  const getConstraintMessage = (
5319
5378
  controller,
5320
5379
  constraint,
@@ -12108,7 +12167,9 @@ const createControlValidation = (
12108
12167
  typeof checkResult === "string"
12109
12168
  ? { message: checkResult }
12110
12169
  : checkResult;
12111
- constraintValidityInfo.messageString = constraintValidityInfo.message;
12170
+ constraintValidityInfo.messageString = getMessageString(
12171
+ constraintValidityInfo.message,
12172
+ );
12112
12173
  debugUIState(
12113
12174
  `${elementSig} constraint "${constraint.name}" failed -> ${constraintValidityInfo.message}`,
12114
12175
  );
@@ -12150,10 +12211,15 @@ const createControlValidation = (
12150
12211
  if (titleLess) {
12151
12212
  const element = controller.ref.current;
12152
12213
  if (element) {
12153
- element.setAttribute(
12154
- "title",
12155
- activeFailedConstraintInfo.messageString,
12156
- );
12214
+ const { messageString } = activeFailedConstraintInfo;
12215
+ if (messageString) {
12216
+ element.setAttribute("title", messageString);
12217
+ } else {
12218
+ // The message has no one-line form (an element, typically): the
12219
+ // callout shows it whole, and the tooltip stays empty rather than
12220
+ // saying "[object Object]".
12221
+ element.removeAttribute("title");
12222
+ }
12157
12223
  }
12158
12224
  }
12159
12225
  } else {
@@ -12550,6 +12616,13 @@ const useExecuteAction = (
12550
12616
  let message;
12551
12617
  if (errorMapping) {
12552
12618
  const errorMappingResult = errorMapping(error);
12619
+ // Anything the chain below does not recognize — nothing returned, above
12620
+ // all — leaves `message` undefined and the control displays nothing. That
12621
+ // is the contract, and both halves of it are useful:
12622
+ // - returning nothing says "don't show this one", per error, where
12623
+ // actionErrorEffect="none" says it once for the whole control;
12624
+ // - not handling an error means returning it untouched (`return error`),
12625
+ // and it is then shown exactly as if there were no mapping at all.
12553
12626
  if (typeof errorMappingResult === "string") {
12554
12627
  message = errorMappingResult;
12555
12628
  } else if (Error.isError(errorMappingResult)) {
@@ -30169,16 +30242,40 @@ const anyMatchingRouteSignal = (routes) => {
30169
30242
  };
30170
30243
 
30171
30244
  /**
30245
+ * Runs a navi command from JS. It is the LAST RESORT: the attributes say the
30246
+ * same thing declaratively and go through this very function, so a press that
30247
+ * opens, closes, sends, updates or travels needs none of this.
30248
+ *
30249
+ * ```html
30250
+ * <button command="--navi-open" commandfor="note-dialog" value="42">
30251
+ * ```
30252
+ *
30253
+ * `command` names it (`--navi-x:argument` when it needs an argument),
30254
+ * `commandfor` / `navi-command-target` says to whom, the source's own `value`
30255
+ * says what it is about (`command-value` when the source has a value meaning
30256
+ * something else). Reach for `triggerNaviCommand` only when what decides is not
30257
+ * a press on the element carrying those attributes: a long press, the end of a
30258
+ * drag, a double-click, a keyboard shortcut, a server answer, an observer.
30259
+ *
30172
30260
  * @param {Element} element The element asking — the command's source, and the
30173
30261
  * anchor a popup opens on unless `anchor` says otherwise.
30174
30262
  * @param {string} command
30175
30263
  * @param {Event} event What caused this. Mandatory: it is what makes a command
30176
30264
  * traceable back to its origin — the debug panel groups everything a gesture
30177
30265
  * set off under it, and the gates below read it to know whether the default
30178
- * was already prevented and which mouse button was pressed. When nothing was
30179
- * handed over — a timer firing, an action settling, a signal changing — build
30180
- * a `CustomEvent` that names what happened and chain it to whatever preceded
30181
- * it, rather than leaving the origin unsaid:
30266
+ * was already prevented and which mouse button was pressed.
30267
+ *
30268
+ * **Forward the event you were handed**, down through every function between
30269
+ * the handler and this call, so what arrives here is the real gesture. Almost
30270
+ * every command has one: a person did something to the page, or the browser
30271
+ * did. Building a `CustomEvent` at the call site instead of taking `event` as
30272
+ * a parameter looks like it satisfies the requirement, and throws away
30273
+ * exactly what makes it useful — the chain a popup reads to give the focus
30274
+ * back, the button that was pressed, a default already prevented.
30275
+ *
30276
+ * The `CustomEvent` is for the genuinely originless case — a timer firing, an
30277
+ * action settling, a signal changing — and even there it names what happened
30278
+ * and chains to whatever preceded it:
30182
30279
  * const expiredEvent = new CustomEvent("session_expired");
30183
30280
  * chainEvent(expiredEvent, causeEvent); // when something did precede it
30184
30281
  * triggerNaviCommand(dialogEl, "--navi-open", expiredEvent);
@@ -30200,7 +30297,7 @@ const triggerNaviCommand = (
30200
30297
  ) => {
30201
30298
  if (!event) {
30202
30299
  throw new Error(
30203
- `"${command}" triggered without an event: it is mandatory, a command must say what caused it. Pass the gesture, or a CustomEvent naming the cause when no gesture did — see triggerNaviCommand's jsdoc.`,
30300
+ `"${command}" triggered without an event: it is mandatory, a command must say what caused it. Forward the gesture that led here, or a CustomEvent naming the cause when no gesture did — see triggerNaviCommand's jsdoc.`,
30204
30301
  );
30205
30302
  }
30206
30303
  const naviCommand =