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>