@webpieces/nx-webpieces-rules 0.4.977 → 0.4.979

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 (32) hide show
  1. package/README.md +32 -21
  2. package/package.json +7 -7
  3. package/src/lib/graph-filter.client.d.ts +25 -11
  4. package/src/lib/graph-filter.client.js +65 -29
  5. package/src/lib/graph-filter.client.js.map +1 -1
  6. package/src/lib/graph-multi-select.client.d.ts +57 -0
  7. package/src/lib/graph-multi-select.client.js +222 -0
  8. package/src/lib/graph-multi-select.client.js.map +1 -0
  9. package/src/lib/graph-node-menu.d.ts +1 -1
  10. package/src/lib/graph-node-menu.js +1 -1
  11. package/src/lib/graph-node-menu.js.map +1 -1
  12. package/src/lib/graph-page-shell.d.ts +23 -2
  13. package/src/lib/graph-page-shell.js +63 -13
  14. package/src/lib/graph-page-shell.js.map +1 -1
  15. package/src/lib/graph-page-styles.d.ts +6 -0
  16. package/src/lib/graph-page-styles.js +38 -10
  17. package/src/lib/graph-page-styles.js.map +1 -1
  18. package/src/lib/graph-render-model.js +2 -2
  19. package/src/lib/graph-render-model.js.map +1 -1
  20. package/src/lib/graph-visualizer.client.d.ts +39 -19
  21. package/src/lib/graph-visualizer.client.js +96 -110
  22. package/src/lib/graph-visualizer.client.js.map +1 -1
  23. package/src/lib/graph-visualizer.d.ts +7 -11
  24. package/src/lib/graph-visualizer.js +14 -32
  25. package/src/lib/graph-visualizer.js.map +1 -1
  26. package/src/lib/runtime-html-page.d.ts +6 -1
  27. package/src/lib/runtime-html-page.js +13 -6
  28. package/src/lib/runtime-html-page.js.map +1 -1
  29. package/src/lib/runtime-visualizer.client.js +3 -1
  30. package/src/lib/runtime-visualizer.client.js.map +1 -1
  31. package/src/lib/runtime-visualizer.js +5 -3
  32. package/src/lib/runtime-visualizer.js.map +1 -1
@@ -98,7 +98,7 @@ class WpProjectFilter {
98
98
  return selected.size === 0 || carried.some((value) => selected.has(value));
99
99
  }
100
100
  }
101
- /** Architecture interactions bind to each new SVG; logical Lock/Filter state belongs to the page. */
101
+ /** Architecture interactions bind to each new SVG; logical Focus/Keep state belongs to the page. */
102
102
  class GraphHighlighter {
103
103
  svg;
104
104
  chain;
@@ -130,13 +130,16 @@ class GraphHighlighter {
130
130
  el.classList.remove('wp-focus', 'wp-neighbor', 'wp-hl', 'wp-locked');
131
131
  });
132
132
  }
133
+ /**
134
+ * Light the UNION of every focused box's chains, plus the hovered box's. A focused box that is not
135
+ * drawn right now is suspended, its otherwise visible relatives included, until it is drawn again.
136
+ */
133
137
  relight() {
134
138
  this.clear();
135
- const locked = this.page.lockSelection();
136
- if (locked !== null)
137
- this.nodeByName.get(locked)?.classList.add('wp-locked');
138
- // A filtered-out Lock is suspended, including its otherwise visible relatives.
139
- const anchors = [locked, this.hovered].filter((name) => name !== null && this.nodeByName.has(name));
139
+ const focused = [...this.page.focusSelection()].filter((name) => this.nodeByName.has(name));
140
+ for (const name of focused)
141
+ this.nodeByName.get(name)?.classList.add('wp-locked');
142
+ const anchors = this.hovered !== null && this.nodeByName.has(this.hovered) ? [...focused, this.hovered] : focused;
140
143
  if (anchors.length === 0)
141
144
  return;
142
145
  const lit = new Set();
@@ -177,9 +180,9 @@ class GraphHighlighter {
177
180
  const href = designs.get(name);
178
181
  if (href !== undefined)
179
182
  items.push(new WpNodeMenuItem('View Design', () => window.open(href, '_blank')));
180
- const locked = this.page.lockSelection() === name;
181
- items.push(new WpNodeMenuItem(locked ? 'Unlock' : 'Lock', () => locked ? this.page.clearSelection() : this.page.setLock(name)));
182
- items.push(this.page.filterItem(name));
183
+ const focused = this.page.focusSelection().has(name);
184
+ items.push(new WpNodeMenuItem(focused ? 'Remove from Focus' : 'Add to Focus', () => this.page.toggleFocus(name)));
185
+ items.push(this.page.keepItem(name));
183
186
  items.unshift(...this.page.impactItems(name, node));
184
187
  const implemented = window.__WP_RUNTIME__?.implementsItem(name, node);
185
188
  if (implemented)
@@ -301,17 +304,21 @@ class GraphDrawer {
301
304
  page;
302
305
  popover;
303
306
  openers = new Map();
307
+ /** The two multi-selects (#1385): one implementation, two instances. */
308
+ keep;
309
+ focus;
304
310
  constructor(page) {
305
311
  this.page = page;
306
312
  this.popover = new GraphFilterPopover(page);
313
+ this.keep = new WpMultiSelect('keep', new WpKeepSource(page));
314
+ this.focus = new WpMultiSelect('focus', new WpFocusSource(page));
307
315
  }
308
316
  wire() {
309
317
  this.wireModeMenu();
310
318
  this.popover.wire();
311
319
  this.wireImpactKinds();
312
- this.wireLock();
313
- this.byId('wp-lock-toggle')?.addEventListener('click', () => this.page.toggleLock());
314
- this.byId('wp-filter-toggle')?.addEventListener('click', () => this.page.toggleFilter());
320
+ this.keep.wire();
321
+ this.focus.wire();
315
322
  this.toggles('wp-collapse', 'wp-shell', true);
316
323
  this.toggles('wp-help-btn', 'wp-help', false);
317
324
  this.toggles('wp-help-close', 'wp-help', false);
@@ -323,6 +330,10 @@ class GraphDrawer {
323
330
  window.addEventListener('keydown', (ev) => this.onKey(ev), true);
324
331
  window.addEventListener('hashchange', () => this.page.setMode(location.hash.slice(1)));
325
332
  document.addEventListener('click', (ev) => {
333
+ for (const multi of [this.keep, this.focus]) {
334
+ if (multi.isOpen() && !multi.clicked(ev))
335
+ multi.close(false);
336
+ }
326
337
  const target = ev.target;
327
338
  if (target instanceof Node && this.byId('wp-mode-menu')?.parentElement?.contains(target))
328
339
  return;
@@ -390,38 +401,11 @@ class GraphDrawer {
390
401
  if (returnFocus)
391
402
  trigger?.focus({ preventScroll: true });
392
403
  }
393
- /** Type-to-search lock: an exact project id (or a unique short name) locks, empty or Esc unlocks. */
394
- wireLock() {
395
- const input = this.lockInput();
396
- if (input === null)
397
- return;
398
- input.addEventListener('input', () => {
399
- const value = input.value.trim();
400
- if (value === '')
401
- this.page.clearSelection();
402
- else if (this.page.hasNode(value))
403
- this.page.setLock(value);
404
- });
405
- input.addEventListener('change', () => {
406
- const match = this.page.resolveName(input.value.trim());
407
- if (match !== null)
408
- this.page.setLock(match);
409
- else
410
- input.value = this.page.lockSelection() ?? '';
411
- });
412
- input.addEventListener('keydown', (ev) => {
413
- if (ev.key !== 'Escape')
414
- return;
415
- ev.preventDefault();
416
- input.value = '';
417
- this.page.clearSelection();
418
- input.blur();
419
- });
420
- }
421
404
  /**
422
405
  * Page keys, in the CAPTURE phase so the open/closed state of the node menu is read before the
423
406
  * menu's own Escape handler closes it: Esc with a menu open only closes the menu. Esc closes an
424
- * open Color-by menu or Filter popover first, too.
407
+ * open Color-by menu, Filter popover or Keep/Focus pulldown first, too; with nothing open it clears
408
+ * Focus. `/` opens Focus with the cursor in its search.
425
409
  */
426
410
  onKey(ev) {
427
411
  if (ev.altKey || ev.ctrlKey || ev.metaKey)
@@ -434,6 +418,12 @@ class GraphDrawer {
434
418
  this.popover.close();
435
419
  return;
436
420
  }
421
+ const pulldown = [this.keep, this.focus].find((multi) => multi.isOpen());
422
+ if (ev.key === 'Escape' && pulldown !== undefined) {
423
+ ev.preventDefault();
424
+ pulldown.close(true);
425
+ return;
426
+ }
437
427
  if (ev.key === 'Escape' && this.dismissOverlay())
438
428
  return;
439
429
  const target = ev.target;
@@ -444,7 +434,8 @@ class GraphDrawer {
444
434
  this.byId('wp-shell')?.classList.remove('wp-collapsed');
445
435
  this.byId('wp-collapse')?.setAttribute('aria-expanded', 'true');
446
436
  ev.preventDefault();
447
- this.lockInput()?.focus();
437
+ this.keep.close(false);
438
+ this.focus.open();
448
439
  return;
449
440
  }
450
441
  const index = ['1', '2', '3', '4', '5'].indexOf(ev.key);
@@ -452,8 +443,8 @@ class GraphDrawer {
452
443
  this.page.setMode(WpModeState.MODES[index]);
453
444
  return;
454
445
  }
455
- if (ev.key === 'Escape' && !menuOpen && this.page.lockSelection() !== null)
456
- this.page.setLock(null);
446
+ if (ev.key === 'Escape' && !menuOpen && this.page.focusSelection().size > 0)
447
+ this.page.clearFocus();
457
448
  }
458
449
  /** `button` shows/hides `target`; for the drawer itself it toggles the collapsed class instead. */
459
450
  toggles(buttonId, targetId, collapse) {
@@ -507,7 +498,7 @@ class GraphDrawer {
507
498
  }
508
499
  }
509
500
  /** Repaint every control from the page's state. */
510
- sync(modes, locked, filtering, filter) {
501
+ sync(modes, filter) {
511
502
  const reason = modes.impactReason();
512
503
  document.querySelectorAll('.wp-mode[data-wp-mode]').forEach((button) => {
513
504
  const mode = button.dataset['wpMode'] ?? '';
@@ -546,26 +537,10 @@ class GraphDrawer {
546
537
  const title = this.byId('wp-legend-pop-title');
547
538
  if (title !== null)
548
539
  title.textContent = `Legend · ${modes.name(modes.mode)}`;
549
- const input = this.lockInput();
550
- if (input !== null && document.activeElement !== input)
551
- input.value = this.page.focusSelection() ?? '';
552
- const lockToggle = this.byId('wp-lock-toggle');
553
- if (lockToggle !== null) {
554
- lockToggle.setAttribute('aria-pressed', String(locked !== null));
555
- lockToggle.disabled = locked === null && this.page.focusSelection() === null;
556
- lockToggle.title = lockToggle.disabled ? 'Select a node in Focus first' : '';
557
- }
558
- this.syncFilterToggle(locked, filtering);
540
+ this.keep.sync();
541
+ this.focus.sync();
559
542
  this.syncFilter(filter);
560
- this.crumb(modes.name(modes.mode), locked);
561
- }
562
- syncFilterToggle(locked, filtering) {
563
- const toggle = this.byId('wp-filter-toggle');
564
- if (toggle === null)
565
- return;
566
- toggle.setAttribute('aria-pressed', String(filtering));
567
- toggle.disabled = !filtering && locked === null;
568
- toggle.title = toggle.disabled ? 'Lock a project first' : '';
543
+ this.crumb(modes.name(modes.mode), this.page.focusSelection());
569
544
  }
570
545
  /** The drawer button's count badge, and one removable pill per active filter in the top bar. */
571
546
  syncFilter(filter) {
@@ -635,8 +610,8 @@ class GraphDrawer {
635
610
  const named = files.slice(0, 2).join(', ') + (files.length > 2 ? ` and ${files.length - 2} more` : '');
636
611
  return `${who}: ${named} changed (workspace-global input${files.length > 1 ? 's' : ''})`;
637
612
  }
638
- /** "Mode: <b>Runtime</b> · Locked: <b>x</b>", built with text nodes so no name is parsed as HTML. */
639
- crumb(mode, locked) {
613
+ /** "Mode: <b>Runtime</b> · Focus: <b>a</b>, <b>b</b> +N", built with text nodes so no name is parsed as HTML. */
614
+ crumb(mode, focused) {
640
615
  const crumb = this.byId('wp-crumb');
641
616
  if (crumb === null)
642
617
  return;
@@ -646,8 +621,14 @@ class GraphDrawer {
646
621
  return b;
647
622
  };
648
623
  const parts = ['Mode: ', bold(mode)];
649
- if (locked !== null)
650
- parts.push(' · Locked: ', bold(locked));
624
+ const names = [...focused].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
625
+ if (names.length > 0)
626
+ parts.push(' · Focus: ');
627
+ names.slice(0, 2).forEach((name, index) => {
628
+ parts.push(...(index === 0 ? [] : [', ']), bold(name));
629
+ });
630
+ if (names.length > 2)
631
+ parts.push(` +${names.length - 2}`);
651
632
  crumb.replaceChildren(...parts);
652
633
  }
653
634
  text(id, value) {
@@ -655,16 +636,34 @@ class GraphDrawer {
655
636
  if (el !== null)
656
637
  el.textContent = value;
657
638
  }
658
- lockInput() {
659
- return document.getElementById('wp-lock');
660
- }
661
639
  byId(id) {
662
640
  return document.getElementById(id);
663
641
  }
664
642
  }
643
+ /** "Focus": lights the picks' chains; offers only the boxes drawn after Filter and Keep connected to. */
644
+ class WpFocusSource {
645
+ page;
646
+ constructor(page) {
647
+ this.page = page;
648
+ }
649
+ selected() { return this.page.focusSelection(); }
650
+ available(id) { return this.page.isDrawn(id); }
651
+ toggle(id) { this.page.toggleFocus(id); }
652
+ clear() { this.page.clearFocus(); }
653
+ }
654
+ /** "Keep connected to": redraws with the union of the picks' chains; offers every box of the graph. */
655
+ class WpKeepSource {
656
+ page;
657
+ constructor(page) {
658
+ this.page = page;
659
+ }
660
+ selected() { return this.page.keepSelection(); }
661
+ available(id) { return this.page.hasNode(id); }
662
+ toggle(id) { this.page.toggleKeep(id); }
663
+ clear() { this.page.clearKeep(); }
664
+ }
665
665
  class GraphPage extends WpFilterPage {
666
666
  highlighter = null;
667
- selected = null;
668
667
  /** The page's own graph; Goal vs Current swaps in its overlay and back (#1239). */
669
668
  base = this.model;
670
669
  modes = new WpModeState(this.model.nodes.some((node) => node.products.length > 0), this.model.viewer ?? 'architecture', this.goalUnavailable(), this.model.comparison?.menuNote || this.goalUnavailable());
@@ -770,43 +769,39 @@ class GraphPage extends WpFilterPage {
770
769
  this.filterCards();
771
770
  this.syncControls();
772
771
  svg.querySelectorAll('g.node').forEach((node) => {
773
- if (node.querySelector('title')?.textContent === this.anchor)
772
+ if (this.keep.has(node.querySelector('title')?.textContent ?? ''))
774
773
  node.classList.add('wp-filter-anchor');
775
774
  });
776
775
  }
777
- lockSelection() {
778
- return this.locked;
776
+ focusSelection() {
777
+ return this.focused;
778
+ }
779
+ /** Whether `id` is drawn right now: what Focus may offer, and a focused box's chip is not "hidden". */
780
+ isDrawn(id) {
781
+ return this.retained.has(id) && this.hasNode(id);
782
+ }
783
+ toggleFocus(id) {
784
+ if (this.focused.has(id))
785
+ this.focused.delete(id);
786
+ else
787
+ this.focused.add(id);
788
+ this.refocus();
789
+ }
790
+ clearFocus() {
791
+ this.focused.clear();
792
+ this.refocus();
779
793
  }
780
794
  hasNode(id) {
781
795
  return this.model.nodes.some((node) => node.id === id);
782
796
  }
783
797
  noun() { return this.model.viewer === 'runtime' ? 'service' : 'project'; }
784
798
  projectCount() { return this.model.nodes.filter(node => this.projects.isService(node)).length; }
785
- focusSelection() { return this.selected; }
786
- toggleLock() {
787
- if (this.locked !== null)
788
- this.setLock(null);
789
- else if (this.selected !== null)
790
- this.setLock(this.selected);
791
- }
792
- /** An exact id, else the one id whose short name (after any scope) is `text`; null if none or several. */
793
- resolveName(text) {
794
- if (text === '')
795
- return null;
796
- if (this.hasNode(text))
797
- return text;
798
- const matches = this.model.nodes.filter((node) => node.id.split('/').pop() === text);
799
- return matches.length === 1 ? matches[0].id : null;
800
- }
801
- setLock(name) {
802
- this.locked = name;
803
- if (name !== null)
804
- this.selected = name;
799
+ /** Focus changed: relight, refilter the cards, and repaint the drawer — no relayout. */
800
+ refocus() {
805
801
  this.highlighter?.relight();
806
802
  this.filterCards();
807
803
  this.syncControls();
808
804
  }
809
- clearSelection() { this.selected = null; this.setLock(null); }
810
805
  setMode(mode) {
811
806
  if (mode === this.modes.mode || !this.modes.set(mode))
812
807
  return;
@@ -832,13 +827,6 @@ class GraphPage extends WpFilterPage {
832
827
  this.syncControls();
833
828
  this.redraw();
834
829
  }
835
- /** The drawer's "Hide unconnected": filter to the locked box's chain, or turn the filter off. */
836
- toggleFilter() {
837
- if (this.anchor !== null)
838
- this.filter(null);
839
- else if (this.locked !== null)
840
- this.filter(this.locked);
841
- }
842
830
  appliedFilter() {
843
831
  return this.applied;
844
832
  }
@@ -853,12 +841,9 @@ class GraphPage extends WpFilterPage {
853
841
  choice.scope = scope;
854
842
  return this.preview(choice);
855
843
  }
856
- /** How many boxes `choice` would show, with the current Hide-unconnected chain. */
844
+ /** How many boxes `choice` would show, with the current "Keep connected to" picks. */
857
845
  preview(choice) {
858
- const base = this.anchor === null
859
- ? new Set(this.model.nodes.map((node) => node.id))
860
- : this.chain.nodes(this.anchor);
861
- const kept = this.projects.apply(base, choice);
846
+ const kept = this.projects.apply(this.kept(this.keep), choice);
862
847
  return this.model.nodes.filter(node => kept.has(node.id) && this.projects.isService(node)).length;
863
848
  }
864
849
  applyFilter(choice) {
@@ -879,7 +864,7 @@ class GraphPage extends WpFilterPage {
879
864
  }
880
865
  syncControls() {
881
866
  document.body.dataset['wpMode'] = this.modes.mode;
882
- this.drawer.sync(this.modes, this.locked, this.anchor !== null, this.applied);
867
+ this.drawer.sync(this.modes, this.applied);
883
868
  this.toggles.sync(this.productsOn);
884
869
  const count = this.model.nodes.filter(node => this.retained.has(node.id) && this.projects.isService(node)).length;
885
870
  const empty = document.getElementById('wp-empty');
@@ -898,8 +883,9 @@ class GraphPage extends WpFilterPage {
898
883
  (group === 'runtime' ? (value === 'unknown' ? node.tags !== null && node.tags.frameworks.length === 0 : node.tags?.frameworks.includes(value)) : group === 'role' ? node.tags?.role === value : node.products.includes(value)));
899
884
  });
900
885
  }
886
+ /** The cards of the drawn boxes, narrowed by Focus to the union of the focused boxes' chains. */
901
887
  filterCards() {
902
- const lit = this.locked === null ? this.retained : this.chain.nodes(this.locked);
888
+ const lit = this.focused.size === 0 ? this.retained : this.kept(this.focused);
903
889
  document.querySelectorAll('.wp-resp-card').forEach((card) => {
904
890
  const name = card.getAttribute('data-node');
905
891
  card.classList.toggle('wp-hidden', name === null || !this.retained.has(name) || !lit.has(name));