/ shared / components / src / components / Navigation / NavigationItems.svelte
NavigationItems.svelte
  1  <script lang="ts">
  2      import { createEventDispatcher, onMount } from 'svelte';
  3      import type { Writable } from 'svelte/store';
  4      import type { NavigationId } from '@amp/web-app-components/src/types';
  5      import { menuIsExpanded } from '@amp/web-app-components/src/components/Navigation/store/menu-state';
  6      import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types';
  7      import {
  8          isSameTab,
  9          getItemComponent,
 10      } from '@amp/web-app-components/src/components/Navigation/utils';
 11      import Folder from './Folder.svelte';
 12      import { shouldShowNavigationItem } from '@amp/web-app-components/src/utils/should-show-navigation-item';
 13      import allowDrop from '@amp/web-app-components/src/actions/allow-drop';
 14      import { listKeyboardAccess } from '@amp/web-app-components/src/actions/list-keyboard-access';
 15  
 16      let isEditing = false;
 17  
 18      /**
 19       * The local storage key with the prefs of what library items to be visible
 20       */
 21      export let visibilityPreferencesKey: string | null = null;
 22  
 23      /**
 24       * The navigation tabs to display.
 25       */
 26      export let items: NavigationItem[];
 27  
 28      /**
 29       * The type of navigation item to display
 30       */
 31      export let type: string | null = null;
 32  
 33      /**
 34       * Retrieve UI translations for a given localization key.
 35       */
 36      export let translateFn: (key: string) => string;
 37  
 38      /**
 39       * The navigation title header -- this appears right over the items.
 40       */
 41      export let header: string | null;
 42  
 43      /**
 44       * The store containing the currently selected tab.
 45       */
 46      export let currentTab: Writable<NavigationId | null>;
 47  
 48      /**
 49       * Boolean or method to indicate if it allows drop on header
 50       */
 51      export let headerDropEnabled: boolean | ((type: string) => boolean) = false;
 52  
 53      /**
 54       * Optional function to map item to drag data
 55       */
 56      export let getItemDragData: (item: NavigationItem) => any = null;
 57  
 58      /**
 59       * Boolean or method to indicate if it allows dragging an item
 60       */
 61      export let itemDragEnabled: boolean | ((item: NavigationItem) => boolean) =
 62          false;
 63  
 64      /**
 65       * Boolean or method to indicate if it allows drop on an item
 66       */
 67      export let itemDropEnabled: boolean | ((item: NavigationItem) => boolean) =
 68          false;
 69  
 70      export let listGroupElement: HTMLElement = null;
 71  
 72      const dispatch = createEventDispatcher();
 73  
 74      const setCurrentActiveItem = (event: CustomEvent<{ id: NavigationId }>) => {
 75          currentTab.set(event.detail.id);
 76  
 77          // Always immediately close the menu (in XS breakpoint)
 78          menuIsExpanded.set(false);
 79  
 80          dispatch('menuItemClick', event.detail);
 81      };
 82  
 83      $: ariaRole = items.find((item) => item?.children) ? 'tree' : null;
 84      $: containingClassName = type ? `navigation-items--${type}` : '';
 85      $: isHeaderDropEnabled =
 86          typeof headerDropEnabled === 'function'
 87              ? headerDropEnabled(type)
 88              : headerDropEnabled;
 89  
 90      function toggleEdit() {
 91          isEditing = !isEditing;
 92      }
 93  
 94      let data = {};
 95  
 96      function visibilityChangeItem(storageKey: string) {
 97          const currentSetting = data[storageKey];
 98          data = { ...data, [storageKey]: !currentSetting };
 99          localStorage.setItem(visibilityPreferencesKey, JSON.stringify(data));
100      }
101  
102      function displayOptions() {
103          const current = localStorage?.getItem(visibilityPreferencesKey);
104  
105          if (current) {
106              data = JSON.parse(current);
107          } else {
108              data = Object.fromEntries(
109                  items.map(({ storageKey }) => [storageKey, true]),
110              );
111              localStorage?.setItem(
112                  visibilityPreferencesKey,
113                  JSON.stringify(data),
114              );
115          }
116      }
117  
118      onMount(() => {
119          if (visibilityPreferencesKey) {
120              displayOptions();
121          }
122      });
123  </script>
124  
125  <div
126      data-testid={`navigation-items-${type}`}
127      class={`navigation-items ${containingClassName}`}
128  >
129      {#if header}
130          <div
131              aria-hidden="true"
132              class="navigation-items__header"
133              class:drop-reset={isHeaderDropEnabled}
134              data-testid={`navigation-items-header`}
135              use:allowDrop={isHeaderDropEnabled &&
136                  !isEditing && {
137                      dropEnabled: true,
138                      onDrop: (dropData) =>
139                          dispatch('dropOnHeader', { type, dropData }),
140                  }}
141          >
142              <span>
143                  {header}
144              </span>
145              {#if visibilityPreferencesKey}
146                  <button
147                      data-testid="navigation-items__toggler"
148                      on:click={toggleEdit}
149                      class="edit-toggle-button"
150                      class:is-editing={isEditing}
151                  >
152                      {#if isEditing}
153                          <span data-testid="navigation-items__editing-done"
154                              >{translateFn('AMP.Shared.Done')}</span
155                          >
156                      {:else}
157                          <span data-testid="navigation-items__editing-edit"
158                              >{translateFn('AMP.Shared.Edit')}</span
159                          >
160                      {/if}
161                  </button>
162              {/if}
163          </div>
164      {/if}
165  
166      <ul
167          role={ariaRole}
168          aria-label={header}
169          class="navigation-items__list"
170          use:listKeyboardAccess={{
171              listItemClassNames:
172                  'navigation-item__link, navigation-item__folder, click-action',
173              isRoving: true,
174              listGroupElement: listGroupElement,
175          }}
176      >
177          {#each items as item (item.id)}
178              {#if item.id.type === 'folder'}
179                  <Folder
180                      item={{ ...item }}
181                      {isEditing}
182                      {currentTab}
183                      {translateFn}
184                      {getItemDragData}
185                      {itemDragEnabled}
186                      {itemDropEnabled}
187                      on:selectItem={setCurrentActiveItem}
188                      on:dropOnItem
189                  />
190              {:else if shouldShowNavigationItem(visibilityPreferencesKey, isEditing, data, item.storageKey)}
191                  <svelte:component
192                      this={getItemComponent(item)}
193                      {item}
194                      selected={isSameTab(item.id, $currentTab)}
195                      on:selectItem={setCurrentActiveItem}
196                      isChecked={data && data[item.storageKey]}
197                      {isEditing}
198                      {translateFn}
199                      getDragData={getItemDragData}
200                      dragEnabled={itemDragEnabled}
201                      dropEnabled={itemDropEnabled}
202                      on:drop={({ detail: dropData }) =>
203                          dispatch('dropOnItem', { item, dropData })}
204                      on:visibilityChangeItem={() =>
205                          visibilityChangeItem(item.storageKey)}
206                  />
207              {/if}
208          {/each}
209      </ul>
210  </div>
211  
212  <style lang="scss">
213      @use '@amp/web-shared-styles/app/core/globalvars' as *;
214      @use 'amp/stylekit/core/mixins/overflow-bleed' as *;
215  
216      .navigation-items {
217          grid-area: navigation-items;
218          padding-top: 7px;
219      }
220  
221      .navigation-items--primary {
222          padding-top: 9px;
223      }
224  
225      .navigation-items--library {
226          grid-area: library-navigation-items;
227      }
228  
229      .navigation-items--personalized {
230          grid-area: personalized-navigation-items;
231      }
232  
233      .navigation-items__header {
234          color: var(--systemSecondary);
235          padding: 15px 26px 3px;
236          display: flex;
237          justify-content: space-between;
238          font: var(--body-emphasized);
239  
240          @media (--sidebar-visible) {
241              margin: 0 20px -3px;
242              padding: 4px 6px;
243              border-radius: 6px;
244              font: var(--footnote-emphasized);
245          }
246  
247          &:global(.is-drag-over) {
248              --drag-over-color: white;
249              color: var(--drag-over-color);
250              background-color: var(--selectionColor);
251          }
252      }
253  
254      .edit-toggle-button {
255          color: var(--systemPrimary);
256  
257          @media (--sidebar-visible) {
258              opacity: 0;
259              transition: var(--global-transition);
260  
261              &:focus {
262                  opacity: 1;
263              }
264          }
265      }
266  
267      .edit-toggle-button.is-editing,
268      .navigation-items__header:hover .edit-toggle-button {
269          opacity: 1;
270      }
271  
272      .navigation-items__list {
273          font: var(--title-2);
274          padding: 3px 26px;
275  
276          @media (--sidebar-visible) {
277              font: var(--title-3);
278              padding: 0 $web-navigation-inline-padding 9px;
279          }
280      }
281  </style>