Compare commits
	
		
			1 commit
		
	
	
		
			
				main
			
			...
			
				dropdown-m
			
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5d42490492 | 
					 19 changed files with 569 additions and 15 deletions
				
			
		|  | @ -15,11 +15,13 @@ | ||||||
|     "typesafe-i18n": "tsx scripts/import_translations.ts" |     "typesafe-i18n": "tsx scripts/import_translations.ts" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|  |     "@floating-ui/core": "^1.6.0", | ||||||
|     "@fontsource-variable/inter": "^5.0.16", |     "@fontsource-variable/inter": "^5.0.16", | ||||||
|     "@mdi/js": "^7.4.47", |     "@mdi/js": "^7.4.47", | ||||||
|     "@resreq/event-hub": "^1.6.0", |     "@resreq/event-hub": "^1.6.0", | ||||||
|     "daisyui": "^4.7.2", |     "daisyui": "^4.7.2", | ||||||
|     "fast-average-color": "^9.4.0", |     "fast-average-color": "^9.4.0", | ||||||
|  |     "svelte-floating-ui": "^1.5.8", | ||||||
|     "svelte-inview": "^4.0.2", |     "svelte-inview": "^4.0.2", | ||||||
|     "svelte-local-storage-store": "^0.6.4", |     "svelte-local-storage-store": "^0.6.4", | ||||||
|     "typesafe-i18n": "^5.26.2" |     "typesafe-i18n": "^5.26.2" | ||||||
|  |  | ||||||
							
								
								
									
										16
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							|  | @ -5,6 +5,9 @@ settings: | ||||||
|   excludeLinksFromLockfile: false |   excludeLinksFromLockfile: false | ||||||
| 
 | 
 | ||||||
| dependencies: | dependencies: | ||||||
|  |   '@floating-ui/core': | ||||||
|  |     specifier: ^1.6.0 | ||||||
|  |     version: 1.6.0 | ||||||
|   '@fontsource-variable/inter': |   '@fontsource-variable/inter': | ||||||
|     specifier: ^5.0.16 |     specifier: ^5.0.16 | ||||||
|     version: 5.0.16 |     version: 5.0.16 | ||||||
|  | @ -20,6 +23,9 @@ dependencies: | ||||||
|   fast-average-color: |   fast-average-color: | ||||||
|     specifier: ^9.4.0 |     specifier: ^9.4.0 | ||||||
|     version: 9.4.0 |     version: 9.4.0 | ||||||
|  |   svelte-floating-ui: | ||||||
|  |     specifier: ^1.5.8 | ||||||
|  |     version: 1.5.8 | ||||||
|   svelte-inview: |   svelte-inview: | ||||||
|     specifier: ^4.0.2 |     specifier: ^4.0.2 | ||||||
|     version: 4.0.2(svelte@4.2.11) |     version: 4.0.2(svelte@4.2.11) | ||||||
|  | @ -392,18 +398,15 @@ packages: | ||||||
|     resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} |     resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@floating-ui/utils': 0.2.1 |       '@floating-ui/utils': 0.2.1 | ||||||
|     dev: true |  | ||||||
| 
 | 
 | ||||||
|   /@floating-ui/dom@1.6.3: |   /@floating-ui/dom@1.6.3: | ||||||
|     resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} |     resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@floating-ui/core': 1.6.0 |       '@floating-ui/core': 1.6.0 | ||||||
|       '@floating-ui/utils': 0.2.1 |       '@floating-ui/utils': 0.2.1 | ||||||
|     dev: true |  | ||||||
| 
 | 
 | ||||||
|   /@floating-ui/utils@0.2.1: |   /@floating-ui/utils@0.2.1: | ||||||
|     resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} |     resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} | ||||||
|     dev: true |  | ||||||
| 
 | 
 | ||||||
|   /@fontsource-variable/inter@5.0.16: |   /@fontsource-variable/inter@5.0.16: | ||||||
|     resolution: {integrity: sha512-k+BUNqksTL+AN+o+OV7ILeiE9B5M5X+/jA7LWvCwjbV9ovXTqZyKRhA/x7uYv/ml8WQ0XNLBM7cRFIx4jW0/hg==} |     resolution: {integrity: sha512-k+BUNqksTL+AN+o+OV7ILeiE9B5M5X+/jA7LWvCwjbV9ovXTqZyKRhA/x7uYv/ml8WQ0XNLBM7cRFIx4jW0/hg==} | ||||||
|  | @ -2892,6 +2895,13 @@ packages: | ||||||
|       svelte: 4.2.11 |       svelte: 4.2.11 | ||||||
|     dev: true |     dev: true | ||||||
| 
 | 
 | ||||||
|  |   /svelte-floating-ui@1.5.8: | ||||||
|  |     resolution: {integrity: sha512-dVvJhZ2bT+kQDHlE4Lep8t+sgEc0XD96fXLzAi2DDI2bsaegBbClxXVNMma0C2WsG+n9GJSYx292dTvA8CYRtw==} | ||||||
|  |     dependencies: | ||||||
|  |       '@floating-ui/core': 1.6.0 | ||||||
|  |       '@floating-ui/dom': 1.6.3 | ||||||
|  |     dev: false | ||||||
|  | 
 | ||||||
|   /svelte-hmr@0.15.3(svelte@4.2.11): |   /svelte-hmr@0.15.3(svelte@4.2.11): | ||||||
|     resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} |     resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} | ||||||
|     engines: {node: ^12.20 || ^14.13.1 || >= 16} |     engines: {node: ^12.20 || ^14.13.1 || >= 16} | ||||||
|  |  | ||||||
							
								
								
									
										150
									
								
								src/lib/components/contextmenu/BottomSheet.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								src/lib/components/contextmenu/BottomSheet.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,150 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import swipeable from "$lib/util/actions/swipeable"; | ||||||
|  |   import { type Coords, Direction } from "$lib/util/types"; | ||||||
|  |   import { mdiCog } from "@mdi/js"; | ||||||
|  |   import Icon from "$lib/components/ui/Icon.svelte"; | ||||||
|  |   import { screenHeight } from "$lib/stores/layout"; | ||||||
|  |   import { clamp } from "$lib/util/functions"; | ||||||
|  | 
 | ||||||
|  |   let offset = 50; // 0: top, 50: half | ||||||
|  | 
 | ||||||
|  |   // onMount(reset); | ||||||
|  | 
 | ||||||
|  |   let swiping = false; | ||||||
|  | 
 | ||||||
|  |   function onSwipeStart() { | ||||||
|  |     swiping = true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function onSwipeMove(event: CustomEvent<Coords & { dx: number; dy: number }>) { | ||||||
|  |     let dperc = (event.detail.dy / $screenHeight) * 100; | ||||||
|  |     offset = clamp(offset + dperc, 0, 95); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function onSwipeEnd(event: CustomEvent<{ direction: Direction | null }>) { | ||||||
|  |     if (offset > 60) { | ||||||
|  |       offset = 100; | ||||||
|  |     } else if (offset < 40) { | ||||||
|  |       offset = 0; | ||||||
|  |     } else { | ||||||
|  |       reset(); | ||||||
|  |     } | ||||||
|  |     swiping = false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function reset() { | ||||||
|  |     swiping = false; | ||||||
|  |     offset = 50; | ||||||
|  |   } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div | ||||||
|  |   class="bottomsheet h-full" | ||||||
|  |   class:extended={!swiping && offset === 0} | ||||||
|  |   style="top: {offset}%" | ||||||
|  |   use:swipeable={{ thresholdProvider: () => $screenHeight * 0.1 }} | ||||||
|  |   on:swipeStart={onSwipeStart} | ||||||
|  |   on:swipeMove={onSwipeMove} | ||||||
|  |   on:swipeEnd={onSwipeEnd} | ||||||
|  |   on:swipeFailed={reset} | ||||||
|  | > | ||||||
|  |   <div> | ||||||
|  |     <div class="handle" /> | ||||||
|  | 
 | ||||||
|  |     <ul class="bottomsheet-menu"> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 1</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 2</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 3</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 4</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 5</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 6</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 7</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 8</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 9</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 10</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 11</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 12</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 13</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 14</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 15</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 16</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 17</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 18</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 19</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 20</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 21</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 22</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 23</a></li> | ||||||
|  |       <li><Icon path={mdiCog} size={1.4} /><a>Item 24</a></li> | ||||||
|  |     </ul> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <style lang="postcss"> | ||||||
|  |   .bottomsheet { | ||||||
|  |     @apply fixed flex left-0 bottom-0 z-50 w-full select-none; | ||||||
|  | 
 | ||||||
|  |     & > div { | ||||||
|  |       @apply bg-base-300 rounded-t-xl m-2 w-full h-full; | ||||||
|  |       @apply flex flex-col items-center; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &.extended > div { | ||||||
|  |       @apply overflow-x-auto; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .handle { | ||||||
|  |     @apply w-12 h-1 m-2 flex-shrink-0 rounded-full bg-base-content/50; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .bottomsheet-menu { | ||||||
|  |     width: 100%; | ||||||
|  |     position: relative; | ||||||
|  | 
 | ||||||
|  |     li { | ||||||
|  |       position: relative; | ||||||
|  |       cursor: pointer; | ||||||
|  |       display: flex; | ||||||
|  |       align-items: center; | ||||||
|  |       height: 40px; | ||||||
|  |       @apply p-2 pr-4 gap-2; | ||||||
|  | 
 | ||||||
|  |       .icon-r { | ||||||
|  |         position: absolute; | ||||||
|  |         right: 0; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       & > a { | ||||||
|  |         overflow: hidden; | ||||||
|  |         white-space: nowrap; | ||||||
|  |         text-overflow: ellipsis; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       &:hover { | ||||||
|  |         @apply bg-base-content/10; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       &.noicon { | ||||||
|  |         grid-template-columns: [txt] 1fr; | ||||||
|  |         @apply px-2; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .item-sm { | ||||||
|  |       height: 1.75rem; | ||||||
|  |       @apply py-1; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .sep::before { | ||||||
|  |     border-bottom-width: 1px; | ||||||
|  |     border-bottom-style: solid; | ||||||
|  |     @apply border-b-neutral-content/50; | ||||||
|  |     content: ""; | ||||||
|  |     left: 0; | ||||||
|  |     position: absolute; | ||||||
|  |     right: 0; | ||||||
|  |     top: 0; | ||||||
|  |   } | ||||||
|  | </style> | ||||||
							
								
								
									
										134
									
								
								src/lib/components/contextmenu/TestCtxMenu2.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/lib/components/contextmenu/TestCtxMenu2.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,134 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import { TEST_MENU } from "./menus"; | ||||||
|  |   import { type ContentAction } from "svelte-floating-ui"; | ||||||
|  |   import TestCtxMenu2Item from "./TestCtxMenu2Item.svelte"; | ||||||
|  |   import { kbd } from "@melt-ui/svelte/internal/helpers"; | ||||||
|  |   import { writable, type Writable } from "svelte/store"; | ||||||
|  |   import outclick from "$lib/util/actions/outclick"; | ||||||
|  |   import { LIST_PAGENAV_N } from "$lib/util/constants"; | ||||||
|  |   import { clamp } from "$lib/util/functions"; | ||||||
|  | 
 | ||||||
|  |   export let menu = TEST_MENU; | ||||||
|  |   export let menuOpen = false; | ||||||
|  |   export let floatingContent: ContentAction | (() => void) = () => {}; | ||||||
|  |   export let mainMenuClose: (() => void) | undefined = undefined; | ||||||
|  |   export let submenuClose: (() => void) | undefined = undefined; | ||||||
|  | 
 | ||||||
|  |   let menuElements: TestCtxMenu2Item[] = []; | ||||||
|  |   let openSubmenu: Writable<string | null> = writable(null); | ||||||
|  | 
 | ||||||
|  |   export function open() { | ||||||
|  |     menuOpen = true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   export function close() { | ||||||
|  |     menuOpen = false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   export function toggle() { | ||||||
|  |     if (menuOpen) close(); | ||||||
|  |     else open(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   export function focusFirst() { | ||||||
|  |     if (menuElements.length > 0 && getCurrentIndex() === -1) menuElements[0].focus(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function getCurrentIndex(): number { | ||||||
|  |     return menuElements.findIndex((elm) => elm.getIsActive()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function onKeydown(e: KeyboardEvent) { | ||||||
|  |     if (handleMenuNavigation(e)) { | ||||||
|  |       e.stopPropagation(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // https://github.com/melt-ui/melt-ui/blob/develop/src/lib/builders/menu/create.ts | ||||||
|  | 
 | ||||||
|  |   function handleMenuNavigation(e: KeyboardEvent): boolean { | ||||||
|  |     e.preventDefault(); | ||||||
|  | 
 | ||||||
|  |     // Index of the currently focused item in the candidate nodes array | ||||||
|  |     const currentIndex = getCurrentIndex(); | ||||||
|  |     if (currentIndex === -1) return false; | ||||||
|  | 
 | ||||||
|  |     // Calculate the index of the next menu item | ||||||
|  |     let nextIndex = currentIndex; | ||||||
|  |     switch (e.key) { | ||||||
|  |       case kbd.ARROW_DOWN: | ||||||
|  |         if (e.ctrlKey) nextIndex = menuElements.length - 1; | ||||||
|  |         else nextIndex++; | ||||||
|  |         break; | ||||||
|  |       case kbd.ARROW_UP: | ||||||
|  |         if (e.ctrlKey) nextIndex = 0; | ||||||
|  |         else nextIndex--; | ||||||
|  |         break; | ||||||
|  |       case kbd.HOME: | ||||||
|  |         nextIndex = 0; | ||||||
|  |         break; | ||||||
|  |       case kbd.END: | ||||||
|  |         nextIndex = menuElements.length - 1; | ||||||
|  |         break; | ||||||
|  |       case kbd.PAGE_DOWN: | ||||||
|  |         nextIndex += LIST_PAGENAV_N; | ||||||
|  |         break; | ||||||
|  |       case kbd.PAGE_UP: | ||||||
|  |         nextIndex -= LIST_PAGENAV_N; | ||||||
|  |         break; | ||||||
|  |       case kbd.ARROW_RIGHT: | ||||||
|  |         if (menu[currentIndex].submenu) { | ||||||
|  |           menuElements[currentIndex].focus(true, true); | ||||||
|  |         } | ||||||
|  |         return true; | ||||||
|  |       case kbd.ARROW_LEFT: | ||||||
|  |         if (submenuClose) { | ||||||
|  |           submenuClose(); | ||||||
|  |           return true; | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |       default: | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     nextIndex = clamp(nextIndex, 0, menuElements.length - 1); | ||||||
|  |     menuElements[nextIndex].focus(false); | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function onOutclick() { | ||||||
|  |     console.log("outclick"); | ||||||
|  |     menuOpen = false; | ||||||
|  |     openSubmenu.set(null); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const condOutclick = submenuClose ? () => {} : outclick; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <svelte:window | ||||||
|  |   on:keydown={(e) => { | ||||||
|  |     if (e.key === kbd.ESCAPE) { | ||||||
|  |       onOutclick(); | ||||||
|  |     } | ||||||
|  |   }} | ||||||
|  | /> | ||||||
|  | 
 | ||||||
|  | {#if menuOpen} | ||||||
|  |   <div | ||||||
|  |     class="ctxmenu" | ||||||
|  |     role="menu" | ||||||
|  |     use:floatingContent | ||||||
|  |     tabindex={submenuClose ? -1 : 0} | ||||||
|  |     on:keydown={onKeydown} | ||||||
|  |     use:condOutclick | ||||||
|  |     on:outclick={onOutclick} | ||||||
|  |   > | ||||||
|  |     {#each menu as menuItem, i} | ||||||
|  |       <TestCtxMenu2Item | ||||||
|  |         {menuItem} | ||||||
|  |         {openSubmenu} | ||||||
|  |         mainMenuClose={mainMenuClose ?? close} | ||||||
|  |         bind:this={menuElements[i]} | ||||||
|  |       /> | ||||||
|  |     {/each} | ||||||
|  |   </div> | ||||||
|  | {/if} | ||||||
							
								
								
									
										114
									
								
								src/lib/components/contextmenu/TestCtxMenu2Item.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/lib/components/contextmenu/TestCtxMenu2Item.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,114 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import LL from "$i18n/i18n-svelte"; | ||||||
|  |   import { mdiMenuRight } from "@mdi/js"; | ||||||
|  |   import { type CtxMenuEntry } from "./menus"; | ||||||
|  |   import Icon from "$lib/components/ui/Icon.svelte"; | ||||||
|  |   import { createFloatingActions } from "svelte-floating-ui"; | ||||||
|  |   import { shift, flip, offset } from "svelte-floating-ui/dom"; | ||||||
|  |   import TestCtxMenu2 from "./TestCtxMenu2.svelte"; | ||||||
|  |   import { generateId, kbd, sleep } from "@melt-ui/svelte/internal/helpers"; | ||||||
|  |   import type { Writable } from "svelte/store"; | ||||||
|  | 
 | ||||||
|  |   export let menuItem: CtxMenuEntry; | ||||||
|  |   export let openSubmenu: Writable<string | null>; | ||||||
|  |   export let mainMenuClose: (() => void) | undefined = undefined; | ||||||
|  | 
 | ||||||
|  |   let [floatingRef, floatingContent] = menuItem.submenu | ||||||
|  |     ? createFloatingActions({ | ||||||
|  |         strategy: "absolute", | ||||||
|  |         placement: "right-start", | ||||||
|  |         middleware: [shift(), flip(), offset(4)] | ||||||
|  |       }) | ||||||
|  |     : [() => {}, () => {}]; | ||||||
|  | 
 | ||||||
|  |   let itemElm: HTMLElement; | ||||||
|  |   let submenuElm: TestCtxMenu2 | undefined; | ||||||
|  |   let active = false; | ||||||
|  |   let id = generateId(); | ||||||
|  | 
 | ||||||
|  |   // @ts-expect-error ignore localization arguments | ||||||
|  |   $: title = $LL[menuItem.title](); | ||||||
|  |   $: submenuOpen = $openSubmenu === id; | ||||||
|  | 
 | ||||||
|  |   export function getId(): string { | ||||||
|  |     return id; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   export function getElement(): HTMLElement { | ||||||
|  |     return itemElm; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   export function getIsActive(): boolean { | ||||||
|  |     return active; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   export function focus(sm = false, jump = false) { | ||||||
|  |     active = true; | ||||||
|  |     itemElm.focus(); | ||||||
|  |     if (sm) { | ||||||
|  |       openSubmenu.set(menuItem.submenu ? id : null); | ||||||
|  |       if (jump) { | ||||||
|  |         sleep(1).then(() => submenuElm?.focusFirst()); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function submenuClose() { | ||||||
|  |     openSubmenu.set(null); | ||||||
|  |     focus(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function unfocus() { | ||||||
|  |     active = false; | ||||||
|  |     itemElm.blur(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function onKeyup(e: KeyboardEvent) { | ||||||
|  |     if (e.key === kbd.ENTER) { | ||||||
|  |       clickAction(); | ||||||
|  |       e.stopPropagation(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function onClick(e: MouseEvent) { | ||||||
|  |     focus(true); | ||||||
|  |     clickAction(); | ||||||
|  |     e.stopPropagation(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function clickAction() { | ||||||
|  |     if(menuItem.onClick) menuItem.onClick(); | ||||||
|  |     if (mainMenuClose) mainMenuClose(); | ||||||
|  |   } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div | ||||||
|  |   class="item" | ||||||
|  |   role="menuitem" | ||||||
|  |   use:floatingRef | ||||||
|  |   tabindex={active ? 0 : -1} | ||||||
|  |   bind:this={itemElm} | ||||||
|  |   on:click={onClick} | ||||||
|  |   on:keyup={onKeyup} | ||||||
|  |   on:mouseenter={() => focus(true)} | ||||||
|  |   on:mouseleave={unfocus} | ||||||
|  |   on:focus={() => (active = true)} | ||||||
|  |   on:blur={() => (active = false)} | ||||||
|  | > | ||||||
|  |   {#if menuItem.icon} | ||||||
|  |     <Icon path={menuItem.icon} size={1.4} /> | ||||||
|  |   {/if} | ||||||
|  |   <span>{title}</span> | ||||||
|  |   {#if menuItem.submenu} | ||||||
|  |     <div class="icon-r"><Icon path={mdiMenuRight} /></div> | ||||||
|  | 
 | ||||||
|  |     <TestCtxMenu2 | ||||||
|  |       menu={menuItem.submenu} | ||||||
|  |       menuOpen={submenuOpen} | ||||||
|  |       {floatingContent} | ||||||
|  |       {mainMenuClose} | ||||||
|  |       {submenuClose} | ||||||
|  |       bind:this={submenuElm} | ||||||
|  |     /> | ||||||
|  |   {/if} | ||||||
|  | </div> | ||||||
							
								
								
									
										66
									
								
								src/lib/components/contextmenu/menus.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/lib/components/contextmenu/menus.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | ||||||
|  | import type { TranslationFunctions } from "$i18n/i18n-types"; | ||||||
|  | import { | ||||||
|  |   mdiContentCopy, | ||||||
|  |   mdiDownload, | ||||||
|  |   mdiPlaylistPlay, | ||||||
|  |   mdiPlaylistPlus, | ||||||
|  |   mdiShare, | ||||||
|  | } from "@mdi/js"; | ||||||
|  | 
 | ||||||
|  | export type CtxMenuEntry = { | ||||||
|  |   /** Item text (translation key) */ | ||||||
|  |   title: keyof TranslationFunctions; | ||||||
|  |   /** Item icon */ | ||||||
|  |   icon?: string; | ||||||
|  |   /** Item click action */ | ||||||
|  |   onClick?: () => void; | ||||||
|  |   /** Place a separator before the menu item */ | ||||||
|  |   separator?: boolean; | ||||||
|  |   /** Submenu entries */ | ||||||
|  |   submenu?: CtxMenuEntry[]; | ||||||
|  |   key?: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const TEST_MENU: CtxMenuEntry[] = [ | ||||||
|  |   { | ||||||
|  |     title: "play_next", | ||||||
|  |     icon: mdiPlaylistPlay, | ||||||
|  |     onClick: () => console.log("play_next"), | ||||||
|  |     key: "n", | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: "add_to_queue", | ||||||
|  |     icon: mdiPlaylistPlus, | ||||||
|  |     key: "q", | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: "share", | ||||||
|  |     icon: mdiShare, | ||||||
|  |     separator: true, | ||||||
|  |     submenu: [ | ||||||
|  |       { | ||||||
|  |         title: "copy_url", | ||||||
|  |         icon: mdiContentCopy, | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         title: "download_audio_file", | ||||||
|  |         icon: mdiDownload, | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         title: "share", | ||||||
|  |         icon: mdiShare, | ||||||
|  |         separator: true, | ||||||
|  |         submenu: [ | ||||||
|  |           { | ||||||
|  |             title: "copy_url", | ||||||
|  |             icon: mdiContentCopy, | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             title: "download_audio_file", | ||||||
|  |             icon: mdiDownload, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |   } | ||||||
|  | ]; | ||||||
|  | @ -10,8 +10,8 @@ The header sticks to the top of the page when scrolling down. --> | ||||||
|   import type { Color } from "$lib/util/types"; |   import type { Color } from "$lib/util/types"; | ||||||
|   import { |   import { | ||||||
|     COLOR_B3, |     COLOR_B3, | ||||||
|  |     DROPDOWN_CFG, | ||||||
|     MIN_CONTRAST_TITLE, |     MIN_CONTRAST_TITLE, | ||||||
|     POSITIONING_DROPDOWN, |  | ||||||
|   } from "$lib/util/constants"; |   } from "$lib/util/constants"; | ||||||
| 
 | 
 | ||||||
|   import ImgFrame from "$lib/components/ui/ImgFrame.svelte"; |   import ImgFrame from "$lib/components/ui/ImgFrame.svelte"; | ||||||
|  | @ -50,7 +50,7 @@ The header sticks to the top of the page when scrolling down. --> | ||||||
|     elements: { trigger, menu, item }, |     elements: { trigger, menu, item }, | ||||||
|     builders: { createSubmenu }, |     builders: { createSubmenu }, | ||||||
|     states: { open: ddnOpen }, |     states: { open: ddnOpen }, | ||||||
|   } = createDropdownMenu({ positioning: POSITIONING_DROPDOWN }); |   } = createDropdownMenu(DROPDOWN_CFG); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div | <div | ||||||
|  |  | ||||||
|  | @ -2,14 +2,14 @@ | ||||||
|   import LL from "$i18n/i18n-svelte"; |   import LL from "$i18n/i18n-svelte"; | ||||||
|   import { melt, createDropdownMenu } from "@melt-ui/svelte"; |   import { melt, createDropdownMenu } from "@melt-ui/svelte"; | ||||||
| 
 | 
 | ||||||
|   import { POSITIONING_DROPDOWN } from "$lib/util/constants"; |   import { DROPDOWN_CFG } from "$lib/util/constants"; | ||||||
| 
 | 
 | ||||||
|   import SettingsMenu from "$lib/components/contextmenu/OptionsMenu.svelte"; |   import SettingsMenu from "$lib/components/contextmenu/OptionsMenu.svelte"; | ||||||
| 
 | 
 | ||||||
|   const { |   const { | ||||||
|     elements: { trigger, menu, item }, |     elements: { trigger, menu, item }, | ||||||
|     states: { open }, |     states: { open }, | ||||||
|   } = createDropdownMenu({ positioning: POSITIONING_DROPDOWN }); |   } = createDropdownMenu(DROPDOWN_CFG); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <button | <button | ||||||
|  |  | ||||||
|  | @ -192,6 +192,12 @@ | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|         return; |         return; | ||||||
|  |       case kbd.HOME: | ||||||
|  |       nextSel = 0; | ||||||
|  |         break; | ||||||
|  |       case kbd.END: | ||||||
|  |       nextSel = tracks.length - 1; | ||||||
|  |         break; | ||||||
|       case kbd.PAGE_UP: |       case kbd.PAGE_UP: | ||||||
|         nextSel -= LIST_PAGENAV_N; |         nextSel -= LIST_PAGENAV_N; | ||||||
|         break; |         break; | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ | ||||||
|     formatDuration, |     formatDuration, | ||||||
|   } from "$lib/util/functions"; |   } from "$lib/util/functions"; | ||||||
|   import { mainPhone } from "$lib/stores/layout"; |   import { mainPhone } from "$lib/stores/layout"; | ||||||
|   import { POSITIONING_DROPDOWN } from "$lib/util/constants"; |   import { CTXMENU_CFG, DROPDOWN_CFG } from "$lib/util/constants"; | ||||||
|   import { ListView, type FilteredItem, type Track, Direction } from "$lib/util/types"; |   import { ListView, type FilteredItem, type Track, Direction } from "$lib/util/types"; | ||||||
| 
 | 
 | ||||||
|   import Icon from "$lib/components/ui/Icon.svelte"; |   import Icon from "$lib/components/ui/Icon.svelte"; | ||||||
|  | @ -65,12 +65,12 @@ | ||||||
|     elements: { trigger: ctxTrigger, menu: ctxMenu, item: ctxItem }, |     elements: { trigger: ctxTrigger, menu: ctxMenu, item: ctxItem }, | ||||||
|     builders: { createSubmenu: createCtxSubmenu }, |     builders: { createSubmenu: createCtxSubmenu }, | ||||||
|     states: { open: ctxOpen }, |     states: { open: ctxOpen }, | ||||||
|   } = createContextMenu(); |   } = createContextMenu(CTXMENU_CFG); | ||||||
|   const { |   const { | ||||||
|     elements: { trigger: ddnTrigger, menu: ddnMenu, item: ddnItem }, |     elements: { trigger: ddnTrigger, menu: ddnMenu, item: ddnItem }, | ||||||
|     builders: { createSubmenu: createDdnSubmenu }, |     builders: { createSubmenu: createDdnSubmenu }, | ||||||
|     states: { open: ddnOpen }, |     states: { open: ddnOpen }, | ||||||
|   } = createDropdownMenu({ positioning: POSITIONING_DROPDOWN }); |   } = createDropdownMenu(DROPDOWN_CFG); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <!-- svelte-ignore a11y-click-events-have-key-events --> | <!-- svelte-ignore a11y-click-events-have-key-events --> | ||||||
|  |  | ||||||
|  | @ -15,7 +15,7 @@ | ||||||
|   import NavbarMobileItem from "./NavbarMobileItem.svelte"; |   import NavbarMobileItem from "./NavbarMobileItem.svelte"; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div class="btm-nav btm-nav-sm text-xs bg-base-300 z-50"> | <div class="btm-nav btm-nav-sm text-xs bg-base-300 z-20"> | ||||||
|   <NavbarMobileItem |   <NavbarMobileItem | ||||||
|     title={$LL.home()} |     title={$LL.home()} | ||||||
|     icon={mdiHomeOutline} |     icon={mdiHomeOutline} | ||||||
|  |  | ||||||
|  | @ -28,7 +28,7 @@ | ||||||
|   import { clientState, toggleShowQueue } from "$lib/stores/clientState"; |   import { clientState, toggleShowQueue } from "$lib/stores/clientState"; | ||||||
|   import { formatDuration } from "$lib/util/functions"; |   import { formatDuration } from "$lib/util/functions"; | ||||||
|   import { testTrack } from "$lib/util/testdata"; |   import { testTrack } from "$lib/util/testdata"; | ||||||
|   import { POSITIONING_DROPDOWN } from "$lib/util/constants"; |   import { DROPDOWN_CFG } from "$lib/util/constants"; | ||||||
| 
 | 
 | ||||||
|   let position = 81; |   let position = 81; | ||||||
|   let displayPosition = position; |   let displayPosition = position; | ||||||
|  | @ -39,7 +39,7 @@ | ||||||
|     elements: { trigger, menu, item }, |     elements: { trigger, menu, item }, | ||||||
|     builders: { createSubmenu }, |     builders: { createSubmenu }, | ||||||
|     states: { open: ddnOpen }, |     states: { open: ddnOpen }, | ||||||
|   } = createDropdownMenu({ positioning: POSITIONING_DROPDOWN }); |   } = createDropdownMenu(DROPDOWN_CFG); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div id="playerbar"> | <div id="playerbar"> | ||||||
|  |  | ||||||
|  | @ -69,7 +69,7 @@ | ||||||
| 
 | 
 | ||||||
| <div | <div | ||||||
|   id="playerbar-mobile" |   id="playerbar-mobile" | ||||||
|   class="shadow rounded-md z-50" |   class="shadow rounded-md z-20" | ||||||
|   class:transition-colors={colorTrans} |   class:transition-colors={colorTrans} | ||||||
|   style={`background-color: ${bgColorHex}`} |   style={`background-color: ${bgColorHex}`} | ||||||
|   aria-label={$LL.now_playing({ title: track.name, artist: track.artists[0].name })} |   aria-label={$LL.now_playing({ title: track.name, artist: track.artists[0].name })} | ||||||
|  |  | ||||||
|  | @ -5,6 +5,53 @@ import type { Color } from "./types"; | ||||||
| 
 | 
 | ||||||
| const YELLOW: Color = { r: 255, g: 255, b: 127 }; | const YELLOW: Color = { r: 255, g: 255, b: 127 }; | ||||||
| 
 | 
 | ||||||
|  | describe.each([ | ||||||
|  |   { | ||||||
|  |     hex: "#ffffff", | ||||||
|  |     color: { r: 255, g: 255, b: 255 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#000000", | ||||||
|  |     color: { r: 0, g: 0, b: 0 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#ff0000", | ||||||
|  |     color: { r: 255, g: 0, b: 0 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#00ff00", | ||||||
|  |     color: { r: 0, g: 255, b: 0 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#0000ff", | ||||||
|  |     color: { r: 0, g: 0, b: 255 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#8ECAE6", | ||||||
|  |     color: { r: 142, g: 202, b: 230 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#219EBC", | ||||||
|  |     color: { r: 33, g: 158, b: 188 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#023047", | ||||||
|  |     color: { r: 2, g: 48, b: 71 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#FFB703", | ||||||
|  |     color: { r: 255, g: 183, b: 3 }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     hex: "#FB8500", | ||||||
|  |     color: { r: 251, g: 133, b: 0 }, | ||||||
|  |   }, | ||||||
|  | ])("color conversion", ({ hex, color }) => { | ||||||
|  |   it("colorToHex", () => { | ||||||
|  |     expect(colorToHex(color)).toBe(hex.toLowerCase()); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| describe("correctBgColor", () => { | describe("correctBgColor", () => { | ||||||
|   it("yellow", () => { |   it("yellow", () => { | ||||||
|     expect(correctBgColor(WHITE, YELLOW, 5)).toEqual({ r: 114, g: 114, b: 57 }); |     expect(correctBgColor(WHITE, YELLOW, 5)).toEqual({ r: 114, g: 114, b: 57 }); | ||||||
|  |  | ||||||
|  | @ -26,6 +26,9 @@ export const POSITIONING_SUBMENU: FloatingConfig = { | ||||||
| export const POSITIONING_DROPDOWN: FloatingConfig = { | export const POSITIONING_DROPDOWN: FloatingConfig = { | ||||||
|   placement: "bottom-start", |   placement: "bottom-start", | ||||||
| }; | }; | ||||||
|  | export const SUBMENU_CFG = { arrowSize: 0, positioning: POSITIONING_SUBMENU }; | ||||||
|  | export const CTXMENU_CFG = { positioning: POSITIONING_DROPDOWN, typeahead: false }; | ||||||
|  | export const DROPDOWN_CFG = { positioning: POSITIONING_DROPDOWN }; | ||||||
| 
 | 
 | ||||||
| // Future config options
 | // Future config options
 | ||||||
| export const DATE_LANG = "de"; | export const DATE_LANG = "de"; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import "@fontsource-variable/inter"; |   import "@fontsource-variable/inter"; | ||||||
|   import "../style/app.css"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths |   import "../style/app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths | ||||||
| 
 | 
 | ||||||
|   import { onMount } from "svelte"; |   import { onMount } from "svelte"; | ||||||
|   import { page } from "$app/stores"; |   import { page } from "$app/stores"; | ||||||
|  |  | ||||||
|  | @ -12,6 +12,7 @@ | ||||||
|   import ContentHeader from "$lib/components/header/ContentHeader.svelte"; |   import ContentHeader from "$lib/components/header/ContentHeader.svelte"; | ||||||
|   import AvatarBadge from "$lib/components/ui/AvatarBadge.svelte"; |   import AvatarBadge from "$lib/components/ui/AvatarBadge.svelte"; | ||||||
|   import SimpleWrapper from "$lib/components/layout/SimpleWrapper.svelte"; |   import SimpleWrapper from "$lib/components/layout/SimpleWrapper.svelte"; | ||||||
|  |   import BottomSheet from "$lib/components/contextmenu/BottomSheet.svelte"; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								src/routes/test/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/routes/test/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import TestCtxMenu2 from "$lib/components/contextmenu/TestCtxMenu2.svelte"; | ||||||
|  |   import { kbd } from "@melt-ui/svelte/internal/helpers"; | ||||||
|  | 
 | ||||||
|  |   let ctxmenu: TestCtxMenu2; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div class="m-4"> | ||||||
|  |   <button class="btn" data-melt-menu-id="1" on:click={() => ctxmenu.toggle()} on:keydown={(e) => { | ||||||
|  |     if (e.key === kbd.ARROW_DOWN) { | ||||||
|  |       ctxmenu.open(); | ||||||
|  |       ctxmenu.focusFirst(); | ||||||
|  |     } | ||||||
|  |   }}>Dropdown</button> | ||||||
|  | 
 | ||||||
|  |   <TestCtxMenu2 bind:this={ctxmenu} /> | ||||||
|  | </div> | ||||||
|  | @ -76,3 +76,7 @@ mark { | ||||||
|   max-width: 300px; |   max-width: 300px; | ||||||
|   position: fixed; |   position: fixed; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .thin-line { | ||||||
|  |   @apply border-base-content/20 border-solid border-[1px]; | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue