diff --git a/apps/frontend/src/globals.css b/apps/frontend/src/globals.css index adb6ef8b..cb4f1099 100644 --- a/apps/frontend/src/globals.css +++ b/apps/frontend/src/globals.css @@ -83,6 +83,21 @@ h4 { @apply text-lg font-medium; } transform: translateX(0); } +.modal-animation-enter-active, .modal-animation-leave-active { + transition: opacity 160ms, transform 175ms; + transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.modal-animation-enter, .modal-animation-leave-to { + opacity: 0; + transform: scale(0); +} + +.modal-animation-enter-to, .modal-animation-leave { + opacity: 1; + transform: scale(1); +} + body.reduce-motion *, body.reduce-motion *::after, body.reduce-motion *::before { transition-duration: 0.00001ms !important; animation-duration: 0.00001ms !important; diff --git a/apps/frontend/src/ui/App.tsx b/apps/frontend/src/ui/App.tsx index 5fd5fed3..6eedb5d1 100644 --- a/apps/frontend/src/ui/App.tsx +++ b/apps/frontend/src/ui/App.tsx @@ -9,6 +9,7 @@ import { SettingsProvider } from './hooks/useSettings'; import { ClusterModalController } from './components/overlay/cluster/ClusterCreationModal'; import { MultiProvider } from './components/MultiProvider'; import { AccountControllerProvider } from './components/overlay/account/AddAccountModal'; +import { ModalProvider } from './components/overlay/Modal'; import { PROGRAM_INFO } from '~bindings'; function App(props: ParentProps) { @@ -47,9 +48,10 @@ function GlobalContexts(props: ParentProps) { return ( {props.children} diff --git a/apps/frontend/src/ui/components/WindowFrame.tsx b/apps/frontend/src/ui/components/WindowFrame.tsx index 25eb0c63..74cfaf63 100644 --- a/apps/frontend/src/ui/components/WindowFrame.tsx +++ b/apps/frontend/src/ui/components/WindowFrame.tsx @@ -1,8 +1,8 @@ import { Window } from '@tauri-apps/api/window'; import { ChevronLeftIcon, Maximize02Icon, MinusIcon, XCloseIcon } from '@untitled-theme/icons-solid'; import type { JSX } from 'solid-js'; -import { createSignal, onMount } from 'solid-js'; -import Modal from './overlay/Modal'; +import { onMount } from 'solid-js'; +import Modal, { createModal } from './overlay/Modal'; import Button from './base/Button'; import useSettingsContext from '~ui/hooks/useSettings'; @@ -23,7 +23,6 @@ function TitlebarButton(props: TitlebarButtonProps) { } function WindowFrame() { - const [isModalVisible, setModalVisible] = createSignal(false); const settings = useSettingsContext(); const maximize = () => Window.getCurrent().toggleMaximize(); @@ -31,11 +30,22 @@ function WindowFrame() { const quit = () => Window.getCurrent().close(); const kill = () => Window.getCurrent().destroy(); + const modal = createModal(props => ( + props.hide()}>No, + , + ]} + /> + )); + onMount(() => { Window.getCurrent().onCloseRequested((event) => { if (settings.hide_close_prompt !== true) { event.preventDefault(); - setModalVisible(true); + modal.show(); } }); }); @@ -51,17 +61,6 @@ function WindowFrame() { maximize()} /> quit()} danger /> - - setModalVisible(false)}>No, - , - ]} - /> ); } diff --git a/apps/frontend/src/ui/components/overlay/Modal.tsx b/apps/frontend/src/ui/components/overlay/Modal.tsx index e400be64..2977f693 100644 --- a/apps/frontend/src/ui/components/overlay/Modal.tsx +++ b/apps/frontend/src/ui/components/overlay/Modal.tsx @@ -1,42 +1,119 @@ import type { - Accessor, + Context, JSX, ParentProps, Ref, - Setter, } from 'solid-js'; -import { For, createEffect, createSignal, on } from 'solid-js'; +import { For, createContext, createSignal, onCleanup, onMount, splitProps, useContext } from 'solid-js'; import { mergeRefs } from '@solid-primitives/refs'; +import { createStore } from 'solid-js/store'; +import { Transition } from 'solid-transition-group'; import Button from '../base/Button'; import FullscreenOverlay from './FullscreenOverlay'; import type { MakeOptional } from '~types.ts'; -export type ModalProps = { - visible: Accessor; - setVisible: Setter; - zIndex?: number | undefined; - mount?: Node | undefined; - ref?: Ref | undefined; -} & ParentProps; +interface ModalContextType { + displayModal: (modal: () => JSX.Element) => number; + closeModal: (index?: number) => void; +}; + +const ModalContext = createContext() as Context; +type ModalsList = (() => JSX.Element)[]; + +export function ModalProvider(props: ParentProps) { + const [modals, setModals] = createStore([]); + + const context: ModalContextType = { + displayModal: (modal) => { + setModals(prev => [...prev, modal]); + return modals.length - 1; + }, + + closeModal: (index?: number) => { + if (modals.length === 0 || modals.length <= (index || 0)) + return; + + if (index !== undefined) { + setModals(prev => prev.filter((_, i) => i !== index)); + return; + } + + setModals(prev => prev.slice(0, -1)); + }, + }; -// TODO: Implement some sort of stacking control? -function Modal(props: ModalProps) { return ( - - {/*
*/} -
- {props.children} -
- {/*
*/} -
+ + {props.children} + modals.length > 0} + setVisible={(value) => { + if (value === false) + context.closeModal(); + }} + > + + <>{modals.length > 0 && modals[modals.length - 1]!()} + + + ); } +export function useModalController() { + const context = useContext(ModalContext); + + if (!context) + throw new Error('useModal must be used within a ModalProvider'); + + return context; +} + +interface CreateModal { + show: () => void; + hide: () => void; +}; + +export function createModal(el: (props: CreateModal) => JSX.Element): CreateModal { + const controller = useModalController(); + const [index, setIndex] = createSignal(); + + const ctx: CreateModal = { + show: () => { + setIndex(controller.displayModal(() => el(ctx))); + }, + hide: () => { + controller.closeModal(index()); + }, + }; + + return ctx; +} + +export type ModalProps = { + ref?: Ref | undefined; +} & ParentProps & CreateModal; + +function Modal(props: ModalProps) { + return ( +
+ {props.children} +
+ ); +} + +Modal.SplitProps = function (props: T) { + return splitProps(props, ['hide', 'show']); +}; + type ModalButtonProps = string | JSX.Element; export type ModalSimpleProps = { @@ -45,20 +122,16 @@ export type ModalSimpleProps = { } & ModalProps; Modal.Simple = function (props: ModalSimpleProps) { + const [split, rest] = splitProps(props, ['title', 'buttons', 'children']); + return ( - -

{props.title}

+ +

{split.title}

- {props.children} + {split.children}
- + {button => ( )} @@ -75,14 +148,17 @@ type ModalDeleteProps = MakeOptional & { }; Modal.Delete = function (props: ModalDeleteProps) { + const [split, rest] = splitProps(props, ['title', 'buttons', 'onCancel', 'onDelete', 'timeLeft', 'children']); const [timeLeft, setTimeLeft] = createSignal(1); const [intervalId, setIntervalId] = createSignal(undefined); - createEffect(on(() => props.visible(), (visible) => { - startInterval(visible); - if (visible !== true) - onCancel(); - })); + onMount(() => { + startInterval(true); + }); + + onCleanup(() => { + clearIntervalId(); + }); function clearIntervalId() { if (intervalId()) @@ -94,7 +170,7 @@ Modal.Delete = function (props: ModalDeleteProps) { return; clearIntervalId(); - setTimeLeft(props.timeLeft || 3); + setTimeLeft(split.timeLeft || 3); const intervalId = setInterval(() => { setTimeLeft((prev) => { @@ -111,30 +187,24 @@ Modal.Delete = function (props: ModalDeleteProps) { } function onCancel() { - if (props.onCancel) - props.onCancel(); + if (split.onCancel) + split.onCancel(); - if (props.visible() === true) - props.setVisible(false); + props.hide(); } function onDelete() { - if (props.onDelete) - props.onDelete(); + if (split.onDelete) + split.onDelete(); - if (props.visible() === true) - props.setVisible(false); + props.hide(); } return ( , ]} > - {props.children || ( + {split.children || ( <>

Are you sure you want to delete this item?

diff --git a/apps/frontend/src/ui/components/overlay/account/AddAccountModal.tsx b/apps/frontend/src/ui/components/overlay/account/AddAccountModal.tsx index 1b2c59d9..cbd34517 100644 --- a/apps/frontend/src/ui/components/overlay/account/AddAccountModal.tsx +++ b/apps/frontend/src/ui/components/overlay/account/AddAccountModal.tsx @@ -1,6 +1,6 @@ -import { type Accessor, type Context, Match, type ParentProps, type Resource, type Setter, Switch, createContext, createSignal, useContext } from 'solid-js'; +import { type Context, Match, type ParentProps, type Resource, Switch, createContext, createSignal, useContext } from 'solid-js'; import { LinkExternal01Icon } from '@untitled-theme/icons-solid'; -import Modal from '../Modal'; +import Modal, { type ModalProps, createModal } from '../Modal'; import type { MinecraftCredentials } from '~bindings'; import { bridge } from '~imports'; import useCommand, { tryResult } from '~ui/hooks/useCommand'; @@ -20,10 +20,16 @@ const AccountControllerContext = createContext() a export function AccountControllerProvider(props: ParentProps) { const [accounts, { refetch: refetchAccounts }] = useCommand(bridge.commands.getUsers); const [defaultAccount, { refetch: refetchDefaultAccount }] = useCommand(bridge.commands.getDefaultUser); - const [visible, setVisible] = createSignal(false); const [deleteModalUuid, setDeleteModalUuid] = createSignal(); - const deleteModalVisible = () => deleteModalUuid() !== undefined; + + const addAccountModal = createModal(props => ( + + )); + + const deleteAccountModal = createModal(props => ( + _forceRemoveAccount(deleteModalUuid())} /> + )); function refetch() { refetchAccounts(); @@ -35,10 +41,11 @@ export function AccountControllerProvider(props: ParentProps) { } async function removeAccount(uuid: string, force?: boolean) { - if (force !== true) + if (force !== true) { setDeleteModalUuid(uuid); - else - await _forceRemoveAccount(uuid); + deleteAccountModal.show(); + } + else { await _forceRemoveAccount(uuid); } } async function _forceRemoveAccount(uuid: string | undefined) { @@ -50,7 +57,7 @@ export function AccountControllerProvider(props: ParentProps) { } const func: AccountControllerContextFunc = { - displayAddAccount: () => setVisible(true), + displayAddAccount: () => addAccountModal.show(), refetch, accounts, defaultAccount, @@ -61,13 +68,6 @@ export function AccountControllerProvider(props: ParentProps) { return ( {props.children} - - - setDeleteModalUuid(value ? deleteModalUuid() : undefined)} - visible={deleteModalVisible} - onDelete={() => _forceRemoveAccount(deleteModalUuid())} - /> ); } @@ -84,13 +84,12 @@ enum ModalStage { LoggingIn, } -interface AddAccountModalProps { - visible: Accessor; - setVisible: Setter; +interface AddAccountModalProps extends ModalProps { refetch: () => any; } -function AddAccountModal(props: AddAccountModalProps) { +function AddAccountModal(p: AddAccountModalProps) { + const [modalProps, props] = Modal.SplitProps(p); const [stage, setStage] = createSignal(ModalStage.Tasks); function start() { @@ -100,20 +99,19 @@ function AddAccountModal(props: AddAccountModalProps) { } function finish() { - props.setVisible(false); + modalProps.hide(); props.refetch(); } return ( props.setVisible(false)} + onClick={() => modalProps.hide()} />,
- - setVisible(false)} - />, -