Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions src/lib/components/Modals.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<script module lang="ts">
import type { Component } from "svelte"
import { fade, fly } from "svelte/transition"
import { circInOut, circOut } from "svelte/easing"
import dismiss from "@fluentui/svg-icons/icons/dismiss_20_filled.svg?raw"

const stack: Modal<any, any>[] = $state([])

export type ModalData = null | Record<string, any>

export type ModalProps<Data extends ModalData = null, ReturnType = undefined> = {
modal: Modal<ModalComponent<Data, ReturnType>, Data>,
$INTERNAL_TYPE_HELPER_PROVIDING_RETURN_TYPE_DO_NOT_USE?: {t: ReturnType}
}

export type ModalComponent<Data extends ModalData, ReturnType = undefined> =
Component<ModalProps<Data, ReturnType>>

type ReturnTypeForModal<Mdl extends Modal<any, any>> = Mdl extends Modal<infer Component, infer Data> ? Exclude<Parameters<Component>[1]["$INTERNAL_TYPE_HELPER_PROVIDING_RETURN_TYPE_DO_NOT_USE"], undefined>["t"] : never

export class Modal<Component extends ModalComponent<any, any>, Data extends ModalData> {

// This is a shortcut but we won't have types so precise as to require a Component
// type param so who gives a shit
readonly component: Component
readonly data: Data
canBeDismissedByUser: boolean = $state(true)
title: string | undefined = $state()

get inStack() {
return stack.includes(this)
}

get isOpen() {
return stack[0] === this
}

/**
* Dismiss the modal
* @param byUser Whether or not this action was invoked by a user
*/
dismiss(byUser: boolean = false, data?: ReturnTypeForModal<this>) {
// do we really need this? maybe we could remove this?
if (!this.canBeDismissedByUser && byUser)
return
// perf: we can probably just pop off the first item honestly no need for indexof but just in case
stack.splice( stack.indexOf(this), 1 )
this.onDismissal?.(byUser, data)
}

onDismissal?: (byUser: boolean, data?: ReturnTypeForModal<this>) => void

constructor(component: Component, data: Data) {
this.component = component, this.data = data
stack.push(this)
}

}

const popin = (node: HTMLElement, params: { duration: number, easing: (t: number) => number, y: string, scale: number}, { direction }: { direction: "in" | "out" | "both" }) => {
const existing = getComputedStyle(node).transform.replace("none", "")

return {
duration: params.duration || 200,
easing: params.easing || circOut,
css: (t: number, u: number) => `transform: ${existing} scale(${params.scale + ((1-params.scale)*t)}) translateY(calc( ${params.y} * ${u} )); opacity: ${t}`
}
}
</script>
<script lang="ts">
const modal = $derived(stack[0])
let backdrop: HTMLDivElement | undefined = $state()
</script>
<!--TODO (stretch goal): accessibility stuff -may-->
<svelte:document onkeyup={(e) => {
if (e.key === "Escape" && modal?.canBeDismissedByUser)
modal.dismiss(true)
}} />
<!-- this is like 2 lines so maybe this shouldn't be a snippet -->
{#snippet closeButton(isSecretAndForAccessibilityOnlyEvenThoughYouCanJustHitEscape: boolean)}
{#if modal && modal.canBeDismissedByUser}
<button
aria-label="Close dialog"
class="close-button secondary"
class:secret={isSecretAndForAccessibilityOnlyEvenThoughYouCanJustHitEscape}
onclick={() => modal.dismiss(true)}
>
{@html dismiss}
</button>
{/if}
{/snippet}
{#if modal}
<!--
svelte-ignore a11y_click_events_have_key_events
We put the event listener on the document in this case, not
on the element, so this can be ignored.
-->
<!--
svelte-ignore a11y_no_static_element_interactions
This is but an alternate way to "tap out" of a modal, so I don't believe it's
necessary to give it a role, considering the modal div already has the dialog
role.
-->
<!-- cc @Jack5079 for general recommendations on accessibility? -->
<div class="modal-backdrop" onclick={(e) => e.target === backdrop && modal.canBeDismissedByUser && modal.dismiss(true)} in:fade={{duration: 200, easing: circOut}} out:fade={{duration: 300, easing: circOut}} bind:this={backdrop}>
{#key modal}
<div class="modal" role="dialog" aria-modal="true" in:popin|global={{duration: 200, easing: circOut, y: "1em", scale: 0.8}} out:popin|global={
stack.length === 0
? {duration: 300, easing: circOut, y: "0em", scale: 0.8}
: {duration: 200, easing: circOut, y: "-2em", scale: 0.9}
}>
{#if modal.title}
<div class="modal-title">
<h1>{modal.title}</h1>
{@render closeButton(false)}
</div>
{:else}
{@render closeButton(true)}
{/if}
<div class="modal-content">
<modal.component {modal} />
</div>
</div>
{/key}
</div>
{/if}

<style lang="scss">
.modal-backdrop {
background-color: var(--fg-color);
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 2em;
z-index: 100000000;
backdrop-filter: blur(4px);
}
.modal {
max-width: 100%;
background-color: var(--panel-bg-opaque);
display: flex;
flex-direction: column;
padding: var(--page-padding);
border-radius: var(--panel-border-radius);
border: 1px solid var(--attention);
position: absolute;
box-sizing: border-box;
// can't use flex here due to how transitrions owrk
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
box-shadow: 0px 0px 10px var(--panel-bg-highlight);
border: 1px solid var(--panel-stroke);
gap: .5em;

.modal-title {
display: flex;
gap: 1em;
flex-direction: row;
width: 100%;
align-items: center;
justify-content: space-between;
h1 {
font-size: 14pt;
margin: 0;
font-weight: 600;
font-stretch: 125%;
}
}
}
.close-button {
width: fit-content;
padding: 0.25em;
display: flex;
}
.close-button.secret {
position: absolute;
right: 100vw;
top: -100vh;
opacity: 0;
&:focus-visible {
opacity: 1;
right: 0px;
top: 0px;
}
}
</style>
9 changes: 9 additions & 0 deletions src/lib/components/testmodal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
import type { ModalProps } from "./Modals.svelte"

const { modal }: ModalProps<{message: string, title?: string}, { hi: "hi" }> = $props()
modal.title = modal.data.title ?? "Test modal"

</script>

Hello world! I wanted to tell you this: {modal.data.message}
12 changes: 12 additions & 0 deletions src/routes/(app)/(nonuser)/home/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import Announcement from "$lib/components/generic/Announcement.svelte"
import Banner from "$lib/components/generic/Banner.svelte"
import Song from "$lib/components/generic/Song.svelte"
import { Modal } from "$lib/components/Modals.svelte"
import Testmodal from "$lib/components/testmodal.svelte"

import LL from "$lib/i18n/i18n-svelte";

Expand Down Expand Up @@ -37,6 +39,16 @@
}}></Song>
{/each}
</div>
<button onclick={() => {
new Modal(Testmodal, {message: "I'm #1!"});
}}>Queue 1 modal</button>
<button onclick={() => {
new Modal(Testmodal, {message: "I'm #1!"});
new Modal(Testmodal, {message: "I'm #2!"});
}}>Queue 2 modals</button>
<button onclick={() => {
new Modal(Testmodal, {message: "I'm #1!aaa", title: ""})
}}>Queue modal no title</button>
<style lang="scss">
.song-list {
display: flex;
Expand Down
5 changes: 4 additions & 1 deletion src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import Navigator from "$lib/components/Navigator.svelte"
import type { Action } from "svelte/action"
import Modals from "$lib/components/Modals.svelte"
</script>
<!-- when setting the user's background image, add "page-background-image" class -->
<div class="page-background"></div>
Expand All @@ -17,4 +18,6 @@
<main>
{@render children()}
</main>
</div>
</div>

<Modals />