-
Notifications
You must be signed in to change notification settings - Fork 57
/
Copy pathNavigatorCard.vue
1191 lines (1150 loc) · 42 KB
/
NavigatorCard.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!--
This source file is part of the Swift.org open source project
Copyright (c) 2022-2023 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception
See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-->
<template>
<BaseNavigatorCard
:class="{ 'filter-on-top': renderFilterOnTop }"
v-bind="{
technology,
isTechnologyBeta,
technologyPath,
}"
@close="$emit('close')"
@head-click-alt="toggleAllNodes"
>
<template #body="{ className }">
<slot name="post-head" />
<div
:class="className"
@keydown.alt.up.capture.prevent="focusFirst"
@keydown.alt.down.capture.prevent="focusLast"
@keydown.up.exact.capture.prevent="focusPrev"
@keydown.down.exact.capture.prevent="focusNext"
>
<DynamicScroller
v-show="hasNodes"
:id="scrollLockID"
ref="scroller"
class="scroller"
:aria-label="$t('navigator.title')"
:items="nodesToRender"
:min-item-size="itemSize"
emit-update
key-field="uid"
v-slot="{ item, active, index }"
@focusin.native="handleFocusIn"
@focusout.native="handleFocusOut"
@update="handleScrollerUpdate"
@keydown.alt.up.capture.prevent="focusFirst"
@keydown.alt.down.capture.prevent="focusLast"
@keydown.up.exact.capture.prevent="focusPrev"
@keydown.down.exact.capture.prevent="focusNext"
>
<DynamicScrollerItem
v-bind="{ active, item, dataIndex: index }"
:ref="`dynamicScroller_${item.uid}`"
>
<NavigatorCardItem
:item="item"
:isRendered="active"
:filter-pattern="filterPattern"
:is-active="item.uid === activeUID"
:is-bold="activePathMap[item.uid]"
:expanded="openNodes[item.uid]"
:api-change="apiChangesObject[item.path]"
:isFocused="focusedIndex === index"
:enableFocus="!externalFocusChange"
:navigator-references="navigatorReferences"
@toggle="toggle"
@toggle-full="toggleFullTree"
@toggle-siblings="toggleSiblings"
@navigate="handleNavigationChange"
@focus-parent="focusNodeParent"
/>
</DynamicScrollerItem>
</DynamicScroller>
<div aria-live="polite" class="visuallyhidden">
{{ politeAriaLive }}
</div>
<div aria-live="assertive" class="no-items-wrapper">
<p class="no-items">
{{ $t(assertiveAriaLive) }}
</p>
</div>
</div>
<div class="filter-wrapper" v-if="!errorFetching">
<div class="navigator-filter">
<div class="input-wrapper">
<FilterInput
v-model="filter"
:tags="availableTags"
:translatableTags="translatableTags"
:selected-tags.sync="selectedTagsModelValue"
:placeholder="$t('filter.title')"
:should-keep-open-on-blur="false"
:position-reversed="!renderFilterOnTop"
:clear-filter-on-tag-select="false"
class="filter-component"
@clear="clearFilters"
/>
</div>
<slot name="filter" />
</div>
</div>
</template>
</BaseNavigatorCard>
</template>
<script>
/* eslint-disable prefer-destructuring,no-continue,no-param-reassign,no-restricted-syntax */
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import { clone } from 'docc-render/utils/data';
import { waitFrames, waitFor } from 'docc-render/utils/loading';
import debounce from 'docc-render/utils/debounce';
import { sessionStorage } from 'docc-render/utils/storage';
import {
INDEX_ROOT_KEY,
SIDEBAR_ITEM_SIZE,
} from 'docc-render/constants/sidebar';
import { safeHighlightPattern } from 'docc-render/utils/search-utils';
import NavigatorCardItem from 'docc-render/components/Navigator/NavigatorCardItem.vue';
import BaseNavigatorCard from 'docc-render/components/Navigator/BaseNavigatorCard.vue';
import { TopicTypes } from 'docc-render/constants/TopicTypes';
import FilterInput from 'docc-render/components/Filter/FilterInput.vue';
import keyboardNavigation from 'docc-render/mixins/keyboardNavigation';
import { isEqual, last } from 'docc-render/utils/arrays';
import { ChangeNames, ChangeNameToType } from 'docc-render/constants/Changes';
import {
convertChildrenArrayToObject,
getAllChildren,
getChildren,
getParents,
getSiblings,
} from 'docc-render/utils/navigatorData';
const STORAGE_KEY = 'navigator.state';
const FILTER_TAGS = {
sampleCode: 'sampleCode',
tutorials: 'tutorials',
articles: 'articles',
};
const FILTER_TAGS_TO_LABELS = {
[FILTER_TAGS.sampleCode]: 'Sample Code',
[FILTER_TAGS.tutorials]: 'Tutorials',
[FILTER_TAGS.articles]: 'Articles',
};
const FILTER_LABELS_TO_TAGS = Object.fromEntries(
Object
.entries(FILTER_TAGS_TO_LABELS)
.map(([key, value]) => [value, key]),
);
const TOPIC_TYPE_TO_TAG = {
[TopicTypes.article]: FILTER_TAGS.articles,
[TopicTypes.learn]: FILTER_TAGS.tutorials,
[TopicTypes.overview]: FILTER_TAGS.tutorials,
[TopicTypes.resources]: FILTER_TAGS.tutorials,
[TopicTypes.sampleCode]: FILTER_TAGS.sampleCode,
[TopicTypes.section]: FILTER_TAGS.tutorials,
[TopicTypes.tutorial]: FILTER_TAGS.tutorials,
[TopicTypes.project]: FILTER_TAGS.tutorials,
};
const NO_RESULTS = 'navigator.no-results';
const NO_CHILDREN = 'navigator.no-children';
const ERROR_FETCHING = 'navigator.error-fetching';
const ITEMS_FOUND = 'navigator.items-found';
const HIDE_DEPRECATED = 'navigator.tags.hide-deprecated';
/**
* Renders the card for a technology and it's child symbols, in the navigator.
* For performance reasons, the component uses watchers over computed, so we can more precisely
* manage when re-calculations and re-rendering is done.
*/
export default {
name: 'NavigatorCard',
constants: {
STORAGE_KEY,
FILTER_TAGS,
FILTER_TAGS_TO_LABELS,
FILTER_LABELS_TO_TAGS,
TOPIC_TYPE_TO_TAG,
ERROR_FETCHING,
ITEMS_FOUND,
HIDE_DEPRECATED,
},
components: {
FilterInput,
NavigatorCardItem,
DynamicScroller,
DynamicScrollerItem,
BaseNavigatorCard,
},
props: {
...BaseNavigatorCard.props,
children: {
type: Array,
required: true,
},
activePath: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
scrollLockID: {
type: String,
default: '',
},
errorFetching: {
type: Boolean,
default: false,
},
apiChanges: {
type: Object,
default: null,
},
isTechnologyBeta: {
type: Boolean,
default: false,
},
navigatorReferences: {
type: Object,
default: () => {},
},
renderFilterOnTop: {
type: Boolean,
default: false,
},
hideAvailableTags: {
type: Boolean,
default: false,
},
},
mixins: [
keyboardNavigation,
],
data() {
return {
// value to v-model the filter to
filter: '',
// debounced filter value, to reduce the computed property computations. Used in filter logic.
debouncedFilter: '',
selectedTags: [],
/** @type {Object.<string, boolean>} */
openNodes: Object.freeze({}),
/** @type {NavigatorFlatItem[]} */
nodesToRender: Object.freeze([]),
activeUID: null,
lastFocusTarget: null,
allNodesToggled: false,
translatableTags: [HIDE_DEPRECATED],
};
},
computed: {
politeAriaLive() {
const { hasNodes, nodesToRender } = this;
if (!hasNodes) return '';
return this.$tc(ITEMS_FOUND, nodesToRender.length, { number: nodesToRender.length });
},
assertiveAriaLive: ({
hasNodes, hasFilter, errorFetching,
}) => {
if (hasNodes) return '';
if (hasFilter) return NO_RESULTS;
if (errorFetching) return ERROR_FETCHING;
return NO_CHILDREN;
},
/**
* Generates an array of tag labels for filtering.
* Shows only tags, that have children matches.
*/
availableTags({
selectedTags,
renderableChildNodesMap,
apiChangesObject,
hideAvailableTags,
}) {
if (hideAvailableTags || selectedTags.length) return [];
const apiChangesTypesSet = new Set(Object.values(apiChangesObject));
const tagLabelsSet = new Set(Object.values(FILTER_TAGS_TO_LABELS));
const generalTags = new Set([HIDE_DEPRECATED]);
// when API changes are available, remove the `HIDE_DEPRECATED` option
if (apiChangesTypesSet.size) {
generalTags.delete(HIDE_DEPRECATED);
}
const availableTags = {
type: [],
changes: [],
other: [],
};
// iterate over the nodes to render
for (const childID in renderableChildNodesMap) {
if (!Object.hasOwnProperty.call(renderableChildNodesMap, childID)) {
continue;
}
// if there are no more tags to iterate over, end early
if (!tagLabelsSet.size && !apiChangesTypesSet.size && !generalTags.size) {
break;
}
// extract props
const { type, path, deprecated } = renderableChildNodesMap[childID];
// grab the tagLabel
const tagLabel = FILTER_TAGS_TO_LABELS[TOPIC_TYPE_TO_TAG[type]];
const changeType = apiChangesObject[path];
// try to match a tag
if (tagLabelsSet.has(tagLabel)) {
// if we have a match, store it
availableTags.type.push(tagLabel);
// remove the match, so we can end the filter early
tagLabelsSet.delete(tagLabel);
}
if (changeType && apiChangesTypesSet.has(changeType)) {
availableTags.changes.push(this.$t(ChangeNames[changeType]));
apiChangesTypesSet.delete(changeType);
}
if (deprecated && generalTags.has(HIDE_DEPRECATED)) {
availableTags.other.push(HIDE_DEPRECATED);
generalTags.delete(HIDE_DEPRECATED);
}
}
return availableTags.type.concat(availableTags.changes, availableTags.other);
},
selectedTagsModelValue: {
get() {
return this.selectedTags.map(tag => (
FILTER_TAGS_TO_LABELS[tag] || this.$t(ChangeNames[tag]) || tag
));
},
set(values) {
// guard against accidental clearings
if (!this.selectedTags.length && !values.length) return;
this.selectedTags = values.map(label => (
FILTER_LABELS_TO_TAGS[label] || ChangeNameToType[label] || label
));
},
},
filterPattern: ({ debouncedFilter }) => (!debouncedFilter
? null
// remove the `g` for global, as that causes bugs when matching
: new RegExp(safeHighlightPattern(debouncedFilter), 'i')),
/**
* Return the item size for the Scroller element.
*/
itemSize: () => SIDEBAR_ITEM_SIZE,
/**
* Generates a map of the children, with the uid as the key.
* @return {Object.<string, NavigatorFlatItem>}
*/
childrenMap({ children }) {
return convertChildrenArrayToObject(children);
},
/**
* Returns an array of {NavigatorFlatItem}, from the current active UUID
* @return NavigatorFlatItem[]
*/
activePathChildren({ activeUID, childrenMap }) {
// if we have an activeUID and its not stale by any chance, fetch its parents
return activeUID && childrenMap[activeUID]
? getParents(activeUID, childrenMap)
: [];
},
activePathMap: ({ activePathChildren }) => (
Object.fromEntries(activePathChildren.map(({ uid }) => [uid, true]))
),
activeIndex: ({ activeUID, nodesToRender }) => (
nodesToRender.findIndex(node => node.uid === activeUID)
),
/**
* Returns a list of the child nodes, that match the filter pattern.
* @returns NavigatorFlatItem[]
*/
filteredChildren({
hasFilter, children, filterPattern, selectedTags, apiChanges,
}) {
if (!hasFilter) return [];
const tagsSet = new Set(selectedTags);
// find children that match current filters
return children.filter(({
title, path, type, deprecated, deprecatedChildrenCount, childUIDs,
}) => {
// groupMarkers know how many children they have and how many are deprecated
const isDeprecated = deprecated || deprecatedChildrenCount === childUIDs.length;
// check if `title` matches the pattern, if provided
const titleMatch = filterPattern ? filterPattern.test(title) : true;
// check if `type` matches any of the selected tags
let tagMatch = true;
if (tagsSet.size) {
tagMatch = tagsSet.has(TOPIC_TYPE_TO_TAG[type]);
// if there are API changes and there is no tag match, try to match change types
if (apiChanges && !tagMatch) {
tagMatch = tagsSet.has(apiChanges[path]);
}
if (!isDeprecated && tagsSet.has(HIDE_DEPRECATED)) {
tagMatch = true;
}
}
// find items, that have API changes
const hasAPIChanges = apiChanges ? !!apiChanges[path] : true;
// make sure groupMarker's dont get matched
return titleMatch && tagMatch && hasAPIChanges;
});
},
/**
* This generates a map of all the nodes we are allowed to render at a certain time.
* This is used on both toggling, as well as on navigation and filtering.
* @return {Object.<string, NavigatorFlatItem>}
*/
renderableChildNodesMap({
hasFilter, childrenMap, deprecatedHidden, filteredChildren, removeDeprecated,
}) {
if (!hasFilter) return childrenMap;
const childrenLength = filteredChildren.length - 1;
const filteredChildrenUpToRootSet = new Set([]);
// iterate backwards
for (let i = childrenLength; i >= 0; i -= 1) {
// get item
const child = filteredChildren[i];
const groupMarker = childrenMap[child.groupMarkerUID];
if (groupMarker) {
filteredChildrenUpToRootSet.add(groupMarker);
}
// check if item is already added to list,
// if yes, continue with next item, as this one is probably a parent of a prev match.
if (filteredChildrenUpToRootSet.has(child)) continue;
// if the current item's parent is already in the list, and its not a GroupMarker
// a sibling already did the heavy work, so we just add it and continue.
if (
filteredChildrenUpToRootSet.has(childrenMap[child.parent])
&& child.type !== TopicTypes.groupMarker
) {
filteredChildrenUpToRootSet.add(child);
continue;
}
let allChildren = [];
// check if it has children. This is for Parents and GroupMarkers
if (child.childUIDs.length) {
// if yes, add them all, so we can expand to see them
allChildren = removeDeprecated(
getAllChildren(child.uid, childrenMap), deprecatedHidden,
);
}
// add item and all of it's parents + closest group marker
allChildren
.concat(getParents(child.uid, childrenMap))
.forEach(v => filteredChildrenUpToRootSet.add(v));
}
return convertChildrenArrayToObject([...filteredChildrenUpToRootSet]);
},
/**
* Creates a computed for the items, that the openNodes calc depends on
*/
nodeChangeDeps: ({
filteredChildren, activePathChildren, debouncedFilter, selectedTags,
}) => ([
filteredChildren,
activePathChildren,
debouncedFilter,
selectedTags,
]),
// determine if we should use the filtered items for rendering nodes
hasFilter({ debouncedFilter, selectedTags, apiChanges }) {
return Boolean(debouncedFilter.length || selectedTags.length || apiChanges);
},
/**
* Determine if "Hide Deprecated" tag is selected.
* If we enable multiple tags, this should be an include instead.
* @returns boolean
*/
deprecatedHidden: ({ selectedTags }) => (
selectedTags[0] === HIDE_DEPRECATED
),
apiChangesObject() {
return this.apiChanges || {};
},
hasNodes: ({ nodesToRender }) => !!nodesToRender.length,
totalItemsToNavigate: ({ nodesToRender }) => nodesToRender.length,
lastActivePathItem: ({ activePath }) => last(activePath),
},
created() {
this.restorePersistedState();
},
watch: {
filter: 'debounceInput',
nodeChangeDeps: 'trackOpenNodes',
activePath: 'handleActivePathChange',
apiChanges(value) {
if (value) return;
// if we remove APIChanges, remove all related tags as well
this.selectedTags = this.selectedTags.filter(t => !this.$t(ChangeNames[t]));
},
async activeUID(newUid, oldUID) {
// Update the dynamicScroller item's size, when we change the UID,
// to fix cases where applying styling that changes
// the size of active items.
await this.$nextTick();
const item = this.$refs[`dynamicScroller_${oldUID}`];
if (item && item.updateSize) {
// call the `updateSize` method on the `DynamicScrollerItem`, since it wont get triggered,
// on its own from changing the active item.
item.updateSize();
}
},
},
methods: {
setUnlessEqual(property, data) {
if (isEqual(data, this[property])) return;
this[property] = Object.freeze(data);
},
toggleAllNodes() {
const parentNodes = this.children.filter(child => child.parent === INDEX_ROOT_KEY
&& child.type !== TopicTypes.groupMarker && child.childUIDs.length);
// make sure all nodes get either open or close
this.allNodesToggled = !this.allNodesToggled;
if (this.allNodesToggled) {
this.openNodes = {};
this.generateNodesToRender();
}
parentNodes.forEach((node) => {
this.toggleFullTree(node);
});
},
clearFilters() {
this.filter = '';
this.debouncedFilter = '';
this.selectedTags = [];
},
scrollToFocus() {
this.$refs.scroller.scrollToItem(this.focusedIndex);
},
debounceInput: debounce(function debounceInput(value) {
// store the new filter value
this.debouncedFilter = value;
// reset the last focus target
this.lastFocusTarget = null;
}, 200),
/**
* Finds which nodes need to be opened.
* Initiates a watcher, that reacts to filtering and page navigation.
*/
trackOpenNodes(
[filteredChildren, activePathChildren, filter, selectedTags],
[, activePathChildrenBefore = [], filterBefore = '', selectedTagsBefore = []] = [],
) {
// skip in case this is a first mount and we are syncing the `filter` and `selectedTags`.
if (
(filter !== filterBefore && !filterBefore && this.getFromStorage('filter'))
|| (
!isEqual(selectedTags, selectedTagsBefore)
&& !selectedTagsBefore.length
&& this.getFromStorage('selectedTags', []).length
)
) {
return;
}
// if the activePath items change, we navigated to another page
const pageChange = !isEqual(activePathChildrenBefore, activePathChildren);
// store the childrenMap into a var, so we dont register multiple deps to it
const { childrenMap } = this;
// decide which items are open
// if "Hide Deprecated" is picked, there is no filter,
// or navigate to page while filtering, we open the items leading to the activeUID
let nodes = activePathChildren;
if (!((this.deprecatedHidden && !this.debouncedFilter.length)
|| (pageChange && this.hasFilter)
|| !this.hasFilter)) {
const nodesSet = new Set();
// gather all the parents of all the matches.
// we do this in reverse, so deep children do all the work.
const len = filteredChildren.length - 1;
for (let i = len; i >= 0; i -= 1) {
const child = filteredChildren[i];
// check if the parent or the child itself is already gathered
if (nodesSet.has(childrenMap[child.parent]) || nodesSet.has(child)) {
// if so, just skip iterating over them
continue;
}
// otherwise gather all the parents excluding the child itself, and add to the set
getParents(child.uid, childrenMap)
.slice(0, -1)
.forEach(c => nodesSet.add(c));
}
// dump the set into the nodes array
nodes = [...nodesSet];
}
// if we navigate across pages, persist the previously open nodes
const nodesToStartFrom = pageChange ? { ...this.openNodes } : {};
// generate a new list of open nodes
const newNodes = nodes.reduce((all, current) => {
all[current.uid] = true;
return all;
}, nodesToStartFrom);
this.setUnlessEqual('openNodes', newNodes);
// merge in the new open nodes with the base nodes
this.generateNodesToRender();
// update the focus index, based on the activeUID
this.updateFocusIndexExternally();
},
/**
* Toggle a node open/close
* @param {NavigatorFlatItem} node
*/
toggle(node) {
// check if the item is open
const isOpen = this.openNodes[node.uid];
let include = [];
let exclude = [];
// if open, we need to close it
if (isOpen) {
// clone the open nodes map
const openNodes = clone(this.openNodes);
// remove current node and all of it's children, from the open list
const allChildren = getAllChildren(node.uid, this.childrenMap);
allChildren.forEach(({ uid }) => {
delete openNodes[uid];
});
// set the new open nodes. Should be faster than iterating each and calling `this.$delete`.
this.setUnlessEqual('openNodes', openNodes);
// exclude all items, but the first
exclude = allChildren.slice(1);
} else {
this.setUnlessEqual('openNodes', { ...this.openNodes, [node.uid]: true });
// include all childUIDs to get opened
include = getChildren(node.uid, this.childrenMap, this.children)
.filter(child => this.renderableChildNodesMap[child.uid]);
}
this.augmentRenderNodes({ uid: node.uid, include, exclude });
},
/**
* Handle toggling the entire tree open/close, using alt + click
*/
toggleFullTree(node) {
const isOpen = this.openNodes[node.uid];
const openNodes = clone(this.openNodes);
const allChildren = getAllChildren(node.uid, this.childrenMap);
let exclude = [];
let include = [];
allChildren.forEach(({ uid }) => {
if (isOpen) {
delete openNodes[uid];
} else {
openNodes[uid] = true;
}
});
// figure out which items to include and exclude
if (isOpen) {
exclude = allChildren.slice(1);
} else {
include = allChildren.slice(1).filter(child => this.renderableChildNodesMap[child.uid]);
}
this.setUnlessEqual('openNodes', openNodes);
this.augmentRenderNodes({ uid: node.uid, exclude, include });
},
toggleSiblings(node) {
const isOpen = this.openNodes[node.uid];
const openNodes = clone(this.openNodes);
const siblings = getSiblings(node.uid, this.childrenMap, this.children);
siblings.forEach(({ uid, childUIDs, type }) => {
// if the item has no children or is a groupMarker, exit early
if (!childUIDs.length || type === TopicTypes.groupMarker) return;
if (isOpen) {
const children = getAllChildren(uid, this.childrenMap);
// remove all children
children.forEach((child) => {
delete openNodes[child.uid];
});
// remove the sibling as well
delete openNodes[uid];
// augment the nodesToRender
this.augmentRenderNodes({ uid, exclude: children.slice(1), include: [] });
} else {
// add it
openNodes[uid] = true;
const children = getChildren(uid, this.childrenMap, this.children)
.filter(child => this.renderableChildNodesMap[child.uid]);
// augment the nodesToRender
this.augmentRenderNodes({ uid, exclude: [], include: children });
}
});
this.setUnlessEqual('openNodes', openNodes);
// persist all the open nodes, as we change the openNodes after the node augment runs
this.persistState();
},
/**
* Removes deprecated items from a list
* @param {NavigatorFlatItem[]} items
* @param {boolean} deprecatedHidden
* @returns {NavigatorFlatItem[]}
*/
removeDeprecated(items, deprecatedHidden) {
if (!deprecatedHidden) return items;
return items.filter(({ deprecated }) => !deprecated);
},
/**
* Stores all the nodes we should render at this point.
* This gets called everytime you open/close a node,
* or when you start filtering.
* @return void
*/
generateNodesToRender() {
const { children, openNodes, renderableChildNodesMap } = this;
// create a set of all matches and their parents
// generate the list of nodes to render
this.setUnlessEqual('nodesToRender', children
.filter(child => (
// make sure the item can be rendered
renderableChildNodesMap[child.uid]
// and either its parent is open, or its a root item
&& (child.parent === INDEX_ROOT_KEY || openNodes[child.parent])
)));
// persist all the open nodes
this.persistState();
// wait a frame, so the scroller is ready, `nextTick` is not enough.
this.scrollToElement();
},
/**
* Augments the nodesToRender, by injecting or removing items.
* Used mainly to toggle items on/off
*/
augmentRenderNodes({ uid, include = [], exclude = [] }) {
const index = this.nodesToRender.findIndex(n => n.uid === uid);
// decide if should remove or add
if (include.length) {
// remove duplicates
const duplicatesRemoved = include.filter(i => !this.nodesToRender.includes(i));
// clone the nodes
const clonedNodes = this.nodesToRender.slice(0);
// inject the nodes at the index
clonedNodes.splice(index + 1, 0, ...duplicatesRemoved);
this.setUnlessEqual('nodesToRender', clonedNodes);
} else if (exclude.length) {
// if remove, filter out those items
const excludeSet = new Set(exclude);
this.setUnlessEqual('nodesToRender', this.nodesToRender.filter(item => !excludeSet.has(item)));
}
this.persistState();
},
/**
* Get items from PersistedStorage, for the current technology.
* Can fetch a specific `key` or the entire state.
* @param {string} [key] - key to fetch
* @param {*} [fallback] - fallback property, if `key is not found`
* @return *
*/
getFromStorage(key, fallback = null) {
const state = sessionStorage.get(STORAGE_KEY, {});
const technologyState = state[this.technologyPath];
if (!technologyState) return fallback;
if (key) {
return technologyState[key] || fallback;
}
return technologyState;
},
/**
* Persists the current state, so its not lost if you refresh or navigate away
*/
persistState() {
// fallback to using the activePath items
const fallback = { path: this.lastActivePathItem };
// try to get the `path` for the current activeUID
const { path } = this.activeUID
? (this.childrenMap[this.activeUID] || fallback)
: fallback;
const technologyState = {
technology: this.technology,
// find the path buy the activeUID, because the lastActivePath wont be updated at this point
path,
hasApiChanges: !!this.apiChanges,
// store the keys of the openNodes map, converting to number, to reduce space
openNodes: Object.keys(this.openNodes).map(Number),
// we need only the UIDs
nodesToRender: this.nodesToRender.map(({ uid }) => uid),
activeUID: this.activeUID,
filter: this.filter,
selectedTags: this.selectedTags,
};
const state = {
...sessionStorage.get(STORAGE_KEY, {}),
[this.technologyPath]: technologyState,
};
sessionStorage.set(STORAGE_KEY, state);
},
clearPersistedState() {
const state = {
...sessionStorage.get(STORAGE_KEY, {}),
[this.technologyPath]: {},
};
sessionStorage.set(STORAGE_KEY, state);
},
/**
* Restores the persisted state from sessionStorage. Called on `create` hook.
*/
restorePersistedState() {
// get the entire state for the technology
const persistedState = this.getFromStorage();
// if there is no state or it's last path is not the same, clear the storage
if (!persistedState || persistedState.path !== this.lastActivePathItem) {
this.clearPersistedState();
this.handleActivePathChange(this.activePath);
return;
}
const {
technology,
nodesToRender = [],
filter = '',
hasAPIChanges = false,
activeUID = null,
selectedTags = [],
openNodes,
} = persistedState;
// if for some reason there are no nodes and no filter, we can assume its bad cache
if (!nodesToRender.length && !filter && !selectedTags.length) {
// clear the sessionStorage before continuing
this.clearPersistedState();
this.handleActivePathChange(this.activePath);
return;
}
const { childrenMap } = this;
// make sure all nodes exist in the childrenMap
const allNodesMatch = nodesToRender.every(uid => childrenMap[uid]);
// check if activeUID node matches the current page path
const activeUIDMatchesCurrentPath = activeUID
? ((this.childrenMap[activeUID] || {}).path === this.lastActivePathItem)
: this.activePath.length === 1;
// take a second pass at validating data
if (
// if the technology is different
technology !== this.technology
// if not all nodes to render match the ones we have
|| !allNodesMatch
// if API the existence of apiChanges differs
|| (hasAPIChanges !== Boolean(this.apiChanges))
|| !activeUIDMatchesCurrentPath
// if there is an activeUID and its not in the nodesToRender
|| (activeUID && !filter && !selectedTags.length && !nodesToRender.includes(activeUID))
) {
// clear the sessionStorage before continuing
this.clearPersistedState();
this.handleActivePathChange(this.activePath);
return;
}
// create the openNodes map
this.setUnlessEqual('openNodes', Object.fromEntries(openNodes.map(n => [n, true])));
// get all the nodes to render
// generate the array of flat children objects to render
this.setUnlessEqual('nodesToRender', nodesToRender.map(uid => childrenMap[uid]));
// finally fetch any previously assigned filters or tags
this.selectedTags = selectedTags;
this.filter = filter;
this.debouncedFilter = this.filter;
this.activeUID = activeUID;
// scroll to the active element
this.scrollToElement();
},
async scrollToElement() {
await waitFrames(1);
if (!this.$refs.scroller) return;
// if we are filtering, it makes more sense to scroll to top of list
if (this.hasFilter && !this.deprecatedHidden) {
this.$refs.scroller.scrollToItem(0);
return;
}
// check if the current element is visible and needs scrolling into
const element = document.getElementById(this.activeUID);
// check if there is such an item AND the item is inside scroller area
if (element && this.getChildPositionInScroller(element) === 0) return;
// find the index of the current active UID in the nodes to render
const index = this.nodesToRender.findIndex(child => child.uid === this.activeUID);
if (index === -1) return;
// check if the element is visible
// call the scroll method on the `scroller` component.
this.$refs.scroller.scrollToItem(index);
},
/**
* Determine where a child element is positioned, inside the scroller container.
* returns -1, if above the viewport
* returns 0, if inside the viewport
* returns 1, if below the viewport
*
* @param {HTMLAnchorElement} element - child element
* @return Number
*/
getChildPositionInScroller(element) {
if (!element) return 0;
const { paddingTop, paddingBottom } = getComputedStyle(this.$refs.scroller.$el);
// offset for better visibility
const offset = {
top: parseInt(paddingTop, 10) || 0,
bottom: parseInt(paddingBottom, 10) || 0,
};
// get the position of the scroller in the screen
const { y: areaY, height: areaHeight } = this.$refs.scroller.$el.getBoundingClientRect();
// get the position of the active element
const { y: elY } = element.getBoundingClientRect();
const elHeight = element.offsetParent.offsetHeight;
// calculate where element starts from
const elementStart = elY - areaY - offset.top;
// element is above the scrollarea
if (elementStart < 0) {
return -1;
}
// element ends below the scrollarea
if ((elementStart + elHeight) >= (areaHeight - offset.bottom)) {
return 1;
}
// element is inside the scrollarea
return 0;
},
isInsideScroller(element) {
return this.$refs.scroller.$el.contains(element);
},
handleFocusIn({ target }) {
this.lastFocusTarget = target;
const positionIndex = this.getChildPositionInScroller(target);
// if multiplier is 0, the item is inside the scrollarea, no need to scroll
if (positionIndex === 0) return;
// get the height of the closest positioned item.
const { offsetHeight } = target.offsetParent;
// scroll the area, up/down, based on position of child item
this.$refs.scroller.$el.scrollBy({
top: offsetHeight * positionIndex,
left: 0,
});
},
handleFocusOut(event) {
if (!event.relatedTarget) return;
// reset the `lastFocusTarget`, if the focsOut target is not in the scroller
if (!this.isInsideScroller(event.relatedTarget)) {
this.lastFocusTarget = null;
}
},
handleScrollerUpdate: debounce(async function handleScrollerUpdate() {
// wait is long, because the focus change is coming in very late
await waitFor(300);
if (
!this.lastFocusTarget
// check if the lastFocusTarget is inside the scroller. (can happen if we scroll to fast)
|| !this.isInsideScroller(this.lastFocusTarget)
// check if the activeElement is identical to the lastFocusTarget
|| document.activeElement === this.lastFocusTarget
) {
return;
}
this.lastFocusTarget.focus({
preventScroll: true,
});
}, 50),
/**
* Stores the newly clicked item's UID, so we can highlight it
*/
setActiveUID(uid) {
this.activeUID = uid;
},
/**
* Handles the `navigate` event from NavigatorCardItem, guarding from selecting an item,
* that points to another technology.
*/
handleNavigationChange(uid) {
// force-close the navigator on mobile
this.$emit('navigate', uid);
// if the path is outside of this technology tree, dont store the uid
if (this.childrenMap[uid].path.startsWith(this.technologyPath)) {
this.setActiveUID(uid);
}
},
/**
* Returns an array of {NavigatorFlatItem}, from a breadcrumbs list
* @return NavigatorFlatItem[]
*/
pathsToFlatChildren(paths) {
// get the stack to iterate
const stack = paths.slice(0).reverse();
const { childrenMap } = this;
// the items to loop over. First iteration is over all items
let childrenStack = this.children;
const result = [];
// loop as long as there are items
while (stack.length) {
// get the last item (first parent, as we reversed it)
const currentPath = stack.pop();
// find it by path (we dont have the UID yet)
const currentNode = childrenStack.find(c => c.path === currentPath);
if (!currentNode) break;
// push the object to the results
result.push(currentNode);
if (stack.length) {
// get the children, so we search in those
childrenStack = currentNode.childUIDs.map(c => childrenMap[c]);
}