Compare commits

...

1 commit

Author SHA1 Message Date
5d42490492
WIP: dropdown menu 2024-05-23 16:50:36 +02:00
19 changed files with 569 additions and 15 deletions

View file

@ -15,11 +15,13 @@
"typesafe-i18n": "tsx scripts/import_translations.ts"
},
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@fontsource-variable/inter": "^5.0.16",
"@mdi/js": "^7.4.47",
"@resreq/event-hub": "^1.6.0",
"daisyui": "^4.7.2",
"fast-average-color": "^9.4.0",
"svelte-floating-ui": "^1.5.8",
"svelte-inview": "^4.0.2",
"svelte-local-storage-store": "^0.6.4",
"typesafe-i18n": "^5.26.2"

View file

@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@floating-ui/core':
specifier: ^1.6.0
version: 1.6.0
'@fontsource-variable/inter':
specifier: ^5.0.16
version: 5.0.16
@ -20,6 +23,9 @@ dependencies:
fast-average-color:
specifier: ^9.4.0
version: 9.4.0
svelte-floating-ui:
specifier: ^1.5.8
version: 1.5.8
svelte-inview:
specifier: ^4.0.2
version: 4.0.2(svelte@4.2.11)
@ -392,18 +398,15 @@ packages:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
'@floating-ui/utils': 0.2.1
dev: true
/@floating-ui/dom@1.6.3:
resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
dev: true
/@floating-ui/utils@0.2.1:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: true
/@fontsource-variable/inter@5.0.16:
resolution: {integrity: sha512-k+BUNqksTL+AN+o+OV7ILeiE9B5M5X+/jA7LWvCwjbV9ovXTqZyKRhA/x7uYv/ml8WQ0XNLBM7cRFIx4jW0/hg==}
@ -2892,6 +2895,13 @@ packages:
svelte: 4.2.11
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):
resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==}
engines: {node: ^12.20 || ^14.13.1 || >= 16}

View 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>

View 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}

View 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>

View 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,
},
],
}
],
}
];

View file

@ -10,8 +10,8 @@ The header sticks to the top of the page when scrolling down. -->
import type { Color } from "$lib/util/types";
import {
COLOR_B3,
DROPDOWN_CFG,
MIN_CONTRAST_TITLE,
POSITIONING_DROPDOWN,
} from "$lib/util/constants";
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 },
builders: { createSubmenu },
states: { open: ddnOpen },
} = createDropdownMenu({ positioning: POSITIONING_DROPDOWN });
} = createDropdownMenu(DROPDOWN_CFG);
</script>
<div

View file

@ -2,14 +2,14 @@
import LL from "$i18n/i18n-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";
const {
elements: { trigger, menu, item },
states: { open },
} = createDropdownMenu({ positioning: POSITIONING_DROPDOWN });
} = createDropdownMenu(DROPDOWN_CFG);
</script>
<button

View file

@ -192,6 +192,12 @@
return;
}
return;
case kbd.HOME:
nextSel = 0;
break;
case kbd.END:
nextSel = tracks.length - 1;
break;
case kbd.PAGE_UP:
nextSel -= LIST_PAGENAV_N;
break;

View file

@ -11,7 +11,7 @@
formatDuration,
} from "$lib/util/functions";
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 Icon from "$lib/components/ui/Icon.svelte";
@ -65,12 +65,12 @@
elements: { trigger: ctxTrigger, menu: ctxMenu, item: ctxItem },
builders: { createSubmenu: createCtxSubmenu },
states: { open: ctxOpen },
} = createContextMenu();
} = createContextMenu(CTXMENU_CFG);
const {
elements: { trigger: ddnTrigger, menu: ddnMenu, item: ddnItem },
builders: { createSubmenu: createDdnSubmenu },
states: { open: ddnOpen },
} = createDropdownMenu({ positioning: POSITIONING_DROPDOWN });
} = createDropdownMenu(DROPDOWN_CFG);
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->

View file

@ -15,7 +15,7 @@
import NavbarMobileItem from "./NavbarMobileItem.svelte";
</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
title={$LL.home()}
icon={mdiHomeOutline}

View file

@ -28,7 +28,7 @@
import { clientState, toggleShowQueue } from "$lib/stores/clientState";
import { formatDuration } from "$lib/util/functions";
import { testTrack } from "$lib/util/testdata";
import { POSITIONING_DROPDOWN } from "$lib/util/constants";
import { DROPDOWN_CFG } from "$lib/util/constants";
let position = 81;
let displayPosition = position;
@ -39,7 +39,7 @@
elements: { trigger, menu, item },
builders: { createSubmenu },
states: { open: ddnOpen },
} = createDropdownMenu({ positioning: POSITIONING_DROPDOWN });
} = createDropdownMenu(DROPDOWN_CFG);
</script>
<div id="playerbar">

View file

@ -69,7 +69,7 @@
<div
id="playerbar-mobile"
class="shadow rounded-md z-50"
class="shadow rounded-md z-20"
class:transition-colors={colorTrans}
style={`background-color: ${bgColorHex}`}
aria-label={$LL.now_playing({ title: track.name, artist: track.artists[0].name })}

View file

@ -5,6 +5,53 @@ import type { Color } from "./types";
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", () => {
it("yellow", () => {
expect(correctBgColor(WHITE, YELLOW, 5)).toEqual({ r: 114, g: 114, b: 57 });

View file

@ -26,6 +26,9 @@ export const POSITIONING_SUBMENU: FloatingConfig = {
export const POSITIONING_DROPDOWN: FloatingConfig = {
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
export const DATE_LANG = "de";

View file

@ -1,6 +1,6 @@
<script lang="ts">
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 { page } from "$app/stores";

View file

@ -12,6 +12,7 @@
import ContentHeader from "$lib/components/header/ContentHeader.svelte";
import AvatarBadge from "$lib/components/ui/AvatarBadge.svelte";
import SimpleWrapper from "$lib/components/layout/SimpleWrapper.svelte";
import BottomSheet from "$lib/components/contextmenu/BottomSheet.svelte";
export let data: PageData;

View 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>

View file

@ -76,3 +76,7 @@ mark {
max-width: 300px;
position: fixed;
}
.thin-line {
@apply border-base-content/20 border-solid border-[1px];
}