@brandup/ui-app 1.0.43 → 2.0.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.
package/README.md CHANGED
@@ -31,13 +31,14 @@ interface ExampleApplicationModel extends ApplicationModel {
31
31
  export class ExampleApplication extends Application<ExampleApplicationModel> {
32
32
  }
33
33
 
34
- const builder = new ApplicationBuilder<ExampleApplicationModel>();
34
+ const appModel: ExampleApplicationModel = {};
35
+
36
+ const builder = new ApplicationBuilder<ExampleApplicationModel>(appModel);
35
37
  builder
36
38
  .useApp(ExampleApplication)
37
39
  .useMiddleware(pages);
38
40
 
39
- const appModel: ExampleApplicationModel = {};
40
- const app = builder.build<ExampleApplicationModel>({ basePath: "/" }, appModel);
41
+ const app = builder.build({ basePath: "/" });
41
42
 
42
43
  app.run({ /*optional context params*/ })
43
44
  .then(context: StartContext => { })
@@ -136,7 +137,7 @@ export interface PagesMiddleware {
136
137
  export default () => new PagesMiddlewareImpl();
137
138
  ```
138
139
 
139
- Example SPA navigation middleware: [example/src/frontend/middlewares/pages.ts](/example/src/frontend/middlewares/pages.ts)
140
+ Example SPA navigation middleware: [npm/brandup-ui-example/src/frontend/middlewares/pages.ts](/npm/brandup-ui-example/src/frontend/middlewares/pages.ts)
140
141
 
141
142
  ### Access to middleware
142
143
 
package/dist/cjs/index.js CHANGED
@@ -3,23 +3,44 @@
3
3
  var ui = require('@brandup/ui');
4
4
  var uiHelpers = require('@brandup/ui-helpers');
5
5
 
6
+ /** Runs a chain of middlewares, invoking the named method on each in registration order. */
6
7
  class MiddlewareInvoker {
8
+ /** Middleware handled by this invoker node. */
7
9
  middleware;
8
10
  __next;
11
+ __tail = this;
12
+ /**
13
+ * @param middleware Middleware handled by this invoker node.
14
+ */
9
15
  constructor(middleware) {
10
16
  this.middleware = middleware;
11
17
  }
18
+ /**
19
+ * Append a middleware to the end of the chain.
20
+ * @param middleware Middleware to add.
21
+ */
12
22
  next(middleware) {
13
- if (this.__next)
14
- this.__next.next(middleware);
15
- else
16
- this.__next = new MiddlewareInvoker(middleware);
23
+ const invoker = new MiddlewareInvoker(middleware);
24
+ this.__tail.__next = invoker;
25
+ this.__tail = invoker;
17
26
  }
27
+ /**
28
+ * Invoke the given method across the whole middleware chain.
29
+ * @param method Name of the middleware method to call (e.g. "start", "navigate").
30
+ * @param context Invocation context.
31
+ * @returns Promise resolved when the chain has completed.
32
+ */
18
33
  invoke(method, context) {
19
34
  return this.__exec(method, context);
20
35
  }
21
36
  async __exec(method, context) {
22
- const nextFunc = () => this.__next ? this.__next.__exec(method, context) : Promise.resolve();
37
+ let nextCalled = false;
38
+ const nextFunc = () => {
39
+ if (nextCalled)
40
+ throw new Error(`Middleware "${this.middleware.name}" called next() more than once for method "${method}".`);
41
+ nextCalled = true;
42
+ return this.__next ? this.__next.__exec(method, context) : Promise.resolve();
43
+ };
23
44
  context.abort.throwIfAborted();
24
45
  const methodFunc = this.middleware[method];
25
46
  if (typeof methodFunc === "function") {
@@ -53,14 +74,18 @@ var constants = /*#__PURE__*/Object.freeze({
53
74
  default: result
54
75
  });
55
76
 
77
+ /** Unique name of the built-in state middleware. */
56
78
  const STATE_MIDDLEWARE_NAME = "app-state";
79
+ /**
80
+ * Create the built-in state middleware that toggles loading/loaded/ready CSS state
81
+ * classes on the application element during start, load, navigation and submit.
82
+ * @returns The state middleware instance.
83
+ */
57
84
  const StateMiddlewareFactory = () => {
58
85
  let counter = 0;
59
- //let beginTime: number;
60
86
  const begin = (context) => {
61
87
  const prev = counter++;
62
88
  if (prev === 0) {
63
- //beginTime = Date.now();
64
89
  context.app.element?.classList.remove(result.STATE_CLASS.LOADED);
65
90
  context.app.element?.classList.add(result.STATE_CLASS.LOADING);
66
91
  }
@@ -79,6 +104,9 @@ const StateMiddlewareFactory = () => {
79
104
  begin(context);
80
105
  try {
81
106
  await next();
107
+ // on success the begin() is left open on purpose: the loading state
108
+ // must persist through "loaded" and the first navigation, which closes
109
+ // it (see the "first" branch in navigate). On error we close it here.
82
110
  }
83
111
  catch (reason) {
84
112
  end(context);
@@ -101,12 +129,15 @@ const StateMiddlewareFactory = () => {
101
129
  await next();
102
130
  }
103
131
  finally {
132
+ end(context); // close the begin() of this navigation
133
+ // the first navigation also closes the loading opened by start(),
134
+ // which intentionally leaves its begin() open until the page is rendered
104
135
  if (context.source == "first")
105
136
  end(context);
106
- end(context);
107
137
  }
108
138
  },
109
139
  submit: async (context, next) => {
140
+ begin(context);
110
141
  try {
111
142
  await next();
112
143
  }
@@ -120,10 +151,15 @@ const StateMiddlewareFactory = () => {
120
151
  };
121
152
  };
122
153
 
154
+ /** Unique name of the built-in hyperlink middleware. */
123
155
  const HYPERLINK_MIDDLEWARE_NAME = "app-hyperlink";
156
+ /**
157
+ * Create the built-in hyperlink middleware that intercepts clicks on application links
158
+ * (anchors with the `applink` class or elements with `data-nav-url`) and routes them through
159
+ * application navigation. Honors meta/ctrl-click and `target="_blank"` to open in a new tab.
160
+ * @returns The hyperlink middleware instance.
161
+ */
124
162
  const HyperLinkMiddlewareFactory = () => {
125
- let __ctrlPressed = false;
126
- const onKeyDownUp = (e) => __ctrlPressed = e.ctrlKey;
127
163
  let onClick;
128
164
  return {
129
165
  name: HYPERLINK_MIDDLEWARE_NAME,
@@ -141,11 +177,9 @@ const HyperLinkMiddlewareFactory = () => {
141
177
  if (elem.classList.contains(result.NavUrlClassName) || elem.hasAttribute(result.NavUrlAttributeName))
142
178
  break;
143
179
  }
144
- if (elem === e.currentTarget)
145
- return;
146
180
  elem = elem.parentElement;
147
181
  }
148
- if (!elem || __ctrlPressed || elem.getAttribute("target") === "_blank")
182
+ if (!elem || e.ctrlKey || e.metaKey || elem.getAttribute("target") === "_blank")
149
183
  return;
150
184
  e.preventDefault();
151
185
  e.stopPropagation();
@@ -156,8 +190,12 @@ const HyperLinkMiddlewareFactory = () => {
156
190
  url = elem.getAttribute("href");
157
191
  else if (elem.hasAttribute(result.NavUrlAttributeName))
158
192
  url = elem.getAttribute(result.NavUrlAttributeName);
159
- else
160
- throw "Not found url for navigation.";
193
+ else {
194
+ // matched an applink element with no resolvable url (malformed markup);
195
+ // swallow the click rather than throwing inside a global event listener
196
+ console.warn("Application hyperlink: clicked element has no navigation url.");
197
+ return;
198
+ }
161
199
  if (elem.classList.contains(result.LoadingElementClass))
162
200
  return;
163
201
  elem.classList.add(result.LoadingElementClass);
@@ -171,18 +209,20 @@ const HyperLinkMiddlewareFactory = () => {
171
209
  .catch(() => { })
172
210
  .finally(() => elem.classList.remove(result.LoadingElementClass));
173
211
  }, false);
174
- window.addEventListener("keydown", onKeyDownUp, false);
175
- window.addEventListener("keyup", onKeyDownUp, false);
176
212
  },
177
213
  stop: (_context, next) => {
178
214
  window.removeEventListener("click", onClick, false);
179
- window.removeEventListener("keydown", onKeyDownUp, false);
180
- window.removeEventListener("keyup", onKeyDownUp, false);
181
215
  return next();
182
216
  }
183
217
  };
184
218
  };
185
219
 
220
+ /**
221
+ * Parse a url into its components relative to the application base path.
222
+ * @param basePath Application base path.
223
+ * @param url Url to parse. If null, the current `window.location` is used.
224
+ * @returns Parsed url parts.
225
+ */
186
226
  const parseUrl = (basePath, url) => {
187
227
  const loc = window.location;
188
228
  let origin = loc.origin;
@@ -214,7 +254,7 @@ const parseUrl = (basePath, url) => {
214
254
  path = loc.pathname;
215
255
  query = new URLSearchParams(url);
216
256
  }
217
- else if (url.startsWith("http")) {
257
+ else if (/^https?:\/\//i.test(url)) {
218
258
  const u = new URL(url);
219
259
  if (u.origin != origin) {
220
260
  origin = u.origin;
@@ -250,7 +290,7 @@ const parseUrl = (basePath, url) => {
250
290
  path = path.substring(0, path.length - 1);
251
291
  path = path.toLowerCase();
252
292
  if (basePath) {
253
- if (path.toLowerCase().startsWith(basePath.toLowerCase())) {
293
+ if (path.startsWith(basePath.toLowerCase())) {
254
294
  path = path.substring(basePath.length);
255
295
  if (!path)
256
296
  path = '/';
@@ -293,8 +333,10 @@ const extendQuery = (url, query) => {
293
333
  query.forEach((v, k) => url.query.append(k, v.toString()));
294
334
  }
295
335
  else {
296
- for (let key in query) {
336
+ for (const key in query) {
297
337
  const value = query[key];
338
+ if (value === null || typeof value === "undefined")
339
+ continue;
298
340
  if (!Array.isArray(value)) {
299
341
  url.query.set(key, value);
300
342
  }
@@ -316,6 +358,14 @@ const rebuildUrl = (parsedUrl) => {
316
358
  parsedUrl.relative = relativeUrl;
317
359
  parsedUrl.full = parsedUrl.hash ? `${parsedUrl.url}#${parsedUrl.hash}` : parsedUrl.url;
318
360
  };
361
+ /**
362
+ * Build a relative url from a base path with optional path, query and hash.
363
+ * @param basePath Application base path.
364
+ * @param path Optional path appended to the base path.
365
+ * @param query Optional query parameters.
366
+ * @param hash Optional hash.
367
+ * @returns Relative url with base path.
368
+ */
319
369
  const buildUrl = (basePath, path, query, hash) => {
320
370
  let url = basePath;
321
371
  if (url == '/')
@@ -360,6 +410,7 @@ const buildUrl = (basePath, path, query, hash) => {
360
410
  }
361
411
  return url;
362
412
  };
413
+ /** Url helper functions. */
363
414
  var urlHelper = {
364
415
  parseUrl,
365
416
  extendQuery,
@@ -371,7 +422,9 @@ var url = /*#__PURE__*/Object.freeze({
371
422
  default: urlHelper
372
423
  });
373
424
 
425
+ /** Type name of the base {@link Application} class. */
374
426
  const APP_TYPENAME = "brandup-ui-app";
427
+ /** Reason value thrown when a navigation is overridden (e.g. by a redirect). */
375
428
  const NAV_OVERIDE_ERROR = "NavigationOveride";
376
429
  /**
377
430
  * Base application class.
@@ -388,8 +441,13 @@ class Application extends ui.UIElement {
388
441
  __isRuned;
389
442
  __middlewares = {};
390
443
  __globalSubmit;
444
+ __onPopStateHandler;
391
445
  __execNav; // current navigation invoking
392
446
  __lastNav; // last success navigation
447
+ /**
448
+ * @param env Application environment.
449
+ * @param model Application model.
450
+ */
393
451
  constructor(env, model, ..._args) {
394
452
  super();
395
453
  this.env = env;
@@ -398,6 +456,7 @@ class Application extends ui.UIElement {
398
456
  this.invoker = new MiddlewareInvoker(core);
399
457
  this.__abort = new AbortController();
400
458
  }
459
+ /** Type name of the application. */
401
460
  get typeName() { return APP_TYPENAME; }
402
461
  /** Current navigation context. */
403
462
  get current() { return this.__lastNav?.context; }
@@ -410,25 +469,36 @@ class Application extends ui.UIElement {
410
469
  this.__isInited = true;
411
470
  this.onInitialize();
412
471
  middlewares.forEach(middleware => {
413
- var name = middleware.name;
472
+ const name = middleware.name;
414
473
  if (this.__middlewares.hasOwnProperty(name))
415
474
  throw new Error(`Middleware "${name}" already registered.`);
416
475
  this.__middlewares[name] = middleware;
417
476
  this.invoker.next(middleware);
418
477
  });
419
478
  }
420
- /** Initialize application instance. */
479
+ /**
480
+ * Initialize application instance. Override to register additional middlewares.
481
+ * Registers the built-in state and hyperlink middlewares by default.
482
+ */
421
483
  onInitialize() {
422
484
  this.invoker.next(StateMiddlewareFactory());
423
485
  this.invoker.next(HyperLinkMiddlewareFactory());
424
486
  }
425
- /** Begin run application. */
487
+ /**
488
+ * Called at the beginning of application run, before middlewares start.
489
+ * Override to perform async setup work.
490
+ * @returns Promise resolved when starting work is complete.
491
+ */
426
492
  onStarting() { return Promise.resolve(); }
427
- /** Complate run application. */
493
+ /**
494
+ * Called after the application has fully started.
495
+ * Override to perform async work after start.
496
+ * @returns Promise resolved when post-start work is complete.
497
+ */
428
498
  onStared() { return Promise.resolve(); }
429
499
  /**
430
- * Get middleware by type.
431
- * @param type Type of middleware.
500
+ * Get registered middleware by its unique name.
501
+ * @param name Unique name of middleware.
432
502
  * @returns Middleware instance.
433
503
  */
434
504
  middleware(name) {
@@ -467,7 +537,7 @@ class Application extends ui.UIElement {
467
537
  await this.invoker.invoke("loaded", context);
468
538
  console.info("app load success");
469
539
  this.__abort.signal.throwIfAborted();
470
- window.addEventListener("popstate", (e) => this.__onPopState(context, e));
540
+ window.addEventListener("popstate", this.__onPopStateHandler = (e) => this.__onPopState(context, e));
471
541
  element.addEventListener("submit", this.__globalSubmit = (e) => {
472
542
  const form = e.target;
473
543
  if (!form.classList.contains(result.FormClassName))
@@ -476,23 +546,24 @@ class Application extends ui.UIElement {
476
546
  this.__onSubmit({ form, button: e.submitter instanceof HTMLButtonElement ? e.submitter : null })
477
547
  .catch(() => { });
478
548
  }, false);
479
- await this.onStared();
480
- console.info("app runned");
481
549
  }
482
550
  catch (reason) {
483
551
  console.error(`app run error: ${reason}`);
484
552
  throw reason;
485
553
  }
554
+ // Run the first navigation before onStared(): its navigate middleware always
555
+ // closes the startup loading state (in a finally, even on error), so the app
556
+ // can never get stuck "loading" if onStared throws.
486
557
  try {
487
558
  await this.nav({ data: context.data, abort: this.__abort.signal });
488
559
  }
489
560
  catch (reason) {
490
- if (reason === NAV_OVERIDE_ERROR) {
491
- console.info(`app run nav overided`);
492
- return context;
493
- }
494
- throw reason;
561
+ if (reason !== NAV_OVERIDE_ERROR)
562
+ throw reason;
563
+ console.info(`app run nav overided`);
495
564
  }
565
+ await this.onStared();
566
+ console.info("app runned");
496
567
  return context;
497
568
  }
498
569
  /**
@@ -512,54 +583,19 @@ class Application extends ui.UIElement {
512
583
  action = "first";
513
584
  else {
514
585
  const isChangedUrl = this.__lastNav?.context.url.toLowerCase() !== navUrl.url.toLowerCase();
515
- const hasHash = !!this.__lastNav?.context.hash || !!navUrl.hash;
586
+ const hasHash = !!navUrl.hash;
516
587
  if (isChangedUrl)
517
- action = "url-change"; // если изменился url
588
+ action = "url-change"; // url changed
518
589
  else
519
590
  action = hasHash ? "hash" : "url-no-change";
520
591
  }
521
- let parentNav;
522
- if (this.__execNav && this.__execNav.status === "work") {
523
- parentNav = this.__execNav;
524
- parentNav.abort.abort(NAV_OVERIDE_ERROR);
525
- parentNav.context.overided = true;
526
- }
527
- const navIndex = parentNav ? parentNav.context.index + 1 : 1;
528
- const navAbort = new AbortController();
529
- const aborts = [this.__abort.signal, navAbort.signal];
530
- if (abort)
531
- aborts.push(abort);
532
- const complextAbort = AbortSignal.any(aborts);
592
+ const base = this.__beginNav(abort);
533
593
  const context = {
534
- index: navIndex,
535
- id: uiHelpers.Guid.createGuid(),
536
- source: isFirst ? "first" : "nav",
537
- app: this,
538
- abort: complextAbort,
539
- current: this.__lastNav?.context,
540
- parent: parentNav?.context,
541
- overided: false,
542
- action: action,
543
- data,
544
- url: navUrl.url,
545
- origin: navUrl.origin,
546
- pathAndQuery: navUrl.relative,
547
- basePath: navUrl.basePath,
548
- path: navUrl.path,
549
- query: navUrl.query,
550
- hash: navUrl.hash,
551
- external: navUrl.external,
552
- replace,
553
- scope,
554
- redirect: async (options) => {
555
- complextAbort.throwIfAborted();
556
- const result = await this.nav(options);
557
- complextAbort.throwIfAborted();
558
- return result;
559
- }
594
+ ...this.__createContext(base, navUrl, isFirst ? "first" : "nav", action, data, replace),
595
+ scope
560
596
  };
561
- const currentNav = { method: "navigate", context, abort: navAbort, status: "work" };
562
- return await this.__execNavigate(currentNav, parentNav);
597
+ const currentNav = { method: "navigate", context, abort: base.navAbort, status: "work" };
598
+ return await this.__execNavigate(currentNav, base.parentNav);
563
599
  }
564
600
  /**
565
601
  * Reload page with nav.
@@ -575,9 +611,14 @@ class Application extends ui.UIElement {
575
611
  this.__abort.signal.throwIfAborted();
576
612
  window.location.reload();
577
613
  }
614
+ /**
615
+ * Destroy application: abort pending navigations, run the middleware "stop" chain and release listeners.
616
+ * @param contextData Stop context data.
617
+ * @returns Promise of the stop context.
618
+ */
578
619
  async destroy(contextData) {
579
620
  if (this.__abort.signal.aborted)
580
- return Promise.reject('Application already destroyed.');
621
+ return Promise.reject(new Error('Application already destroyed.'));
581
622
  this.__abort.abort();
582
623
  console.info("app destroy begin");
583
624
  if (this.__execNav)
@@ -586,6 +627,8 @@ class Application extends ui.UIElement {
586
627
  this.__lastNav.abort.abort();
587
628
  if (this.__globalSubmit)
588
629
  this.element?.removeEventListener("submit", this.__globalSubmit);
630
+ if (this.__onPopStateHandler)
631
+ window.removeEventListener("popstate", this.__onPopStateHandler);
589
632
  const destroyAbort = new AbortController();
590
633
  const context = {
591
634
  abort: destroyAbort.signal,
@@ -621,6 +664,10 @@ class Application extends ui.UIElement {
621
664
  const { form, button = null, query, data = {} } = opt;
622
665
  if ((!button || !button.formNoValidate) && !form.checkValidity())
623
666
  throw new Error('Form is invalid.');
667
+ // guard before applying any loading class, otherwise a rejected re-entrant
668
+ // submit would leave the button stuck in the loading state
669
+ if (form.classList.contains(result.LoadingElementClass))
670
+ throw new Error('Form already submitting.');
624
671
  let replace = form.hasAttribute(result.NavUrlReplaceAttributeName);
625
672
  let method = form.method;
626
673
  let enctype = form.enctype;
@@ -636,62 +683,30 @@ class Application extends ui.UIElement {
636
683
  if (button.hasAttribute(result.NavUrlReplaceAttributeName))
637
684
  replace = true;
638
685
  }
639
- if (form.classList.contains(result.LoadingElementClass))
640
- throw new Error('Form already submitting.');
641
686
  form.classList.add(result.LoadingElementClass);
642
687
  method = method.toUpperCase();
643
688
  try {
644
- if (method === "GET")
645
- await this.nav({ url, query: new FormData(form), data: data, replace, abort: opt.abort });
689
+ if (method === "GET") {
690
+ const getUrl = urlHelper.parseUrl(this.env.basePath, url);
691
+ urlHelper.extendQuery(getUrl, new FormData(form));
692
+ if (query)
693
+ urlHelper.extendQuery(getUrl, query);
694
+ await this.nav({ url: getUrl.url, data: data, replace, abort: opt.abort });
695
+ }
646
696
  else {
647
697
  const navUrl = urlHelper.parseUrl(this.env.basePath, url);
648
698
  if (query)
649
699
  urlHelper.extendQuery(navUrl, query);
650
- let parentNav;
651
- if (this.__execNav && this.__execNav.status === "work") {
652
- parentNav = this.__execNav;
653
- parentNav.abort.abort(NAV_OVERIDE_ERROR);
654
- parentNav.context.overided = true;
655
- }
656
- const navIndex = parentNav ? parentNav.context.index + 1 : 1;
657
- const submitAbort = new AbortController();
658
- const aborts = [this.__abort.signal, submitAbort.signal];
659
- if (opt.abort)
660
- aborts.push(opt.abort);
661
- const complextAbort = AbortSignal.any(aborts);
662
- let context = {
663
- index: navIndex,
664
- id: uiHelpers.Guid.createGuid(),
665
- source: "submit",
666
- app: this,
667
- abort: complextAbort,
668
- current: this.__lastNav?.context,
669
- parent: parentNav?.context,
670
- overided: false,
671
- action: "submit",
672
- data,
700
+ const base = this.__beginNav(opt.abort);
701
+ const context = {
702
+ ...this.__createContext(base, navUrl, "submit", "submit", data, replace),
673
703
  form,
674
704
  button,
675
705
  method,
676
- enctype,
677
- url: navUrl.url,
678
- origin: navUrl.origin,
679
- pathAndQuery: navUrl.relative,
680
- basePath: navUrl.basePath,
681
- path: navUrl.path,
682
- query: navUrl.query,
683
- hash: navUrl.hash,
684
- external: navUrl.external,
685
- replace,
686
- redirect: async (options) => {
687
- complextAbort.throwIfAborted();
688
- const result = await this.nav(options);
689
- complextAbort.throwIfAborted();
690
- return result;
691
- }
706
+ enctype
692
707
  };
693
- const currentNav = { method: "submit", context, abort: submitAbort, status: "work" };
694
- await this.__execNavigate(currentNav);
708
+ const currentNav = { method: "submit", context, abort: base.navAbort, status: "work" };
709
+ await this.__execNavigate(currentNav, base.parentNav);
695
710
  }
696
711
  }
697
712
  finally {
@@ -703,7 +718,53 @@ class Application extends ui.UIElement {
703
718
  __onPopState(_context, event) {
704
719
  const popUrl = location.href;
705
720
  console.log(`popstate: ${popUrl}`, event.state);
706
- this.nav({ url: popUrl, data: { popstate: event.state } });
721
+ this.nav({ url: popUrl, data: { popstate: event.state } })
722
+ .catch(() => { });
723
+ }
724
+ /** Detect parent (overriding) navigation and compose the abort signal shared by a new navigation. */
725
+ __beginNav(abort) {
726
+ let parentNav;
727
+ if (this.__execNav && this.__execNav.status === "work") {
728
+ parentNav = this.__execNav;
729
+ parentNav.abort.abort(NAV_OVERIDE_ERROR);
730
+ parentNav.context.overided = true;
731
+ }
732
+ const navAbort = new AbortController();
733
+ const aborts = [this.__abort.signal, navAbort.signal];
734
+ if (abort)
735
+ aborts.push(abort);
736
+ return { parentNav, navAbort, abort: AbortSignal.any(aborts) };
737
+ }
738
+ /** Build the navigation context fields shared by nav and submit. */
739
+ __createContext(base, navUrl, source, action, data, replace) {
740
+ const { parentNav, abort } = base;
741
+ return {
742
+ index: parentNav ? parentNav.context.index + 1 : 1,
743
+ id: uiHelpers.Guid.createGuid(),
744
+ source,
745
+ app: this,
746
+ abort,
747
+ current: this.__lastNav?.context,
748
+ parent: parentNav?.context,
749
+ overided: false,
750
+ action,
751
+ data,
752
+ url: navUrl.url,
753
+ origin: navUrl.origin,
754
+ pathAndQuery: navUrl.relative,
755
+ basePath: navUrl.basePath,
756
+ path: navUrl.path,
757
+ query: navUrl.query,
758
+ hash: navUrl.hash,
759
+ external: navUrl.external,
760
+ replace,
761
+ redirect: async (options) => {
762
+ abort.throwIfAborted();
763
+ const result = await this.nav(options);
764
+ abort.throwIfAborted();
765
+ return result;
766
+ }
767
+ };
707
768
  }
708
769
  async __execNavigate(nav, parent) {
709
770
  if (parent)
@@ -739,51 +800,76 @@ class Application extends ui.UIElement {
739
800
  }
740
801
  }
741
802
 
803
+ /** Fluent builder that configures the application type, middlewares and model, then constructs an {@link Application}. */
742
804
  class ApplicationBuilder {
743
805
  __model;
744
806
  __appType = (Application);
745
807
  __middlewares = [];
808
+ /**
809
+ * @param model Application model used when building the application.
810
+ */
746
811
  constructor(model) {
747
812
  this.__model = model;
748
813
  }
814
+ /**
815
+ * Set a custom application type to instantiate instead of the base {@link Application}.
816
+ * @param appType Application class constructor.
817
+ * @returns The builder, for chaining.
818
+ */
749
819
  useApp(appType) {
750
820
  this.__appType = appType;
751
821
  return this;
752
822
  }
823
+ /**
824
+ * Register a middleware via its factory function. Middlewares run in registration order.
825
+ * @param createFunc Factory that creates the middleware instance.
826
+ * @param params Optional arguments passed to the factory.
827
+ * @returns The builder, for chaining.
828
+ */
753
829
  useMiddleware(createFunc, ...params) {
754
830
  let midl = createFunc(...params);
755
831
  this.__middlewares.push(midl);
756
832
  return this;
757
833
  }
834
+ /**
835
+ * Build and initialize the application instance.
836
+ * @param env Application environment (a base path of `/` is normalized to empty).
837
+ * @param args Extra arguments forwarded to the application constructor.
838
+ * @returns The initialized application instance.
839
+ */
758
840
  build(env, ...args) {
759
- if (!env.basePath || env.basePath == '/')
760
- env.basePath = '';
761
- const app = new this.__appType(env, this.__model, ...args);
841
+ const appEnv = { ...env };
842
+ if (!appEnv.basePath || appEnv.basePath == '/')
843
+ appEnv.basePath = '';
844
+ const app = new this.__appType(appEnv, this.__model, ...args);
762
845
  app.initialize(this.__middlewares);
763
846
  return app;
764
847
  }
765
848
  }
766
849
 
767
- HTMLElement.prototype.navUrl = function (url) {
768
- if (this instanceof HTMLAnchorElement)
769
- this.href = url;
770
- else
771
- this.dataset.navUrl = url;
772
- this.classList.add(result.NavUrlClassName);
773
- return this;
774
- };
775
- HTMLElement.prototype.nav = function (app, path, query, hash) {
776
- const url = app.buildUrl(path, query, hash);
777
- return this.navUrl(url);
778
- };
779
- HTMLElement.prototype.navReplace = function () {
780
- this.setAttribute(result.NavUrlReplaceAttributeName, "");
781
- return this;
782
- };
783
- HTMLElement.prototype.navScope = function (scope) {
784
- this.setAttribute(result.NavUrlScopeAttributeName, scope);
785
- return this;
786
- };
850
+ // Guarded so importing the module does not throw in a non-DOM environment (SSR/Node).
851
+ if (typeof HTMLElement !== "undefined") {
852
+ HTMLElement.prototype.navUrl = function (url) {
853
+ if (this instanceof HTMLAnchorElement)
854
+ this.href = url;
855
+ else
856
+ this.dataset.navUrl = url;
857
+ this.classList.add(result.NavUrlClassName);
858
+ return this;
859
+ };
860
+ HTMLElement.prototype.nav = function (app, path, query, hash) {
861
+ const url = app.buildUrl(path, query, hash);
862
+ return this.navUrl(url);
863
+ };
864
+ HTMLElement.prototype.navReplace = function () {
865
+ this.setAttribute(result.NavUrlReplaceAttributeName, "");
866
+ return this;
867
+ };
868
+ HTMLElement.prototype.navScope = function (scope) {
869
+ this.setAttribute(result.NavUrlScopeAttributeName, scope);
870
+ return this;
871
+ };
872
+ }
787
873
 
788
874
  exports.APP_TYPENAME = APP_TYPENAME;
789
875
  exports.Application = Application;