refactor: Modal stacking system + transitions :D

This commit is contained in:
LynithDev 2024-08-04 15:43:09 +02:00
parent d55e97957b
commit 91aa2e03a7
8 changed files with 277 additions and 188 deletions

View file

@ -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;

View file

@ -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 (
<MultiProvider
values={[
ModalProvider,
SettingsProvider,
ClusterModalController,
AccountControllerProvider,
ClusterModalController,
]}
>
{props.children}

View file

@ -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 => (
<Modal.Simple
{...props}
title="Close OneLauncher?"
buttons={[
<Button buttonStyle="secondary" onClick={() => props.hide()}>No</Button>,
<Button buttonStyle="danger" onClick={() => kill()}>Yes</Button>,
]}
/>
));
onMount(() => {
Window.getCurrent().onCloseRequested((event) => {
if (settings.hide_close_prompt !== true) {
event.preventDefault();
setModalVisible(true);
modal.show();
}
});
});
@ -51,17 +61,6 @@ function WindowFrame() {
<TitlebarButton icon={Maximize02Icon} onClick={() => maximize()} />
<TitlebarButton icon={XCloseIcon} onClick={() => quit()} danger />
</div>
<Modal.Simple
title="Close OneLauncher?"
visible={isModalVisible}
setVisible={setModalVisible}
zIndex={999999}
buttons={[
<Button buttonStyle="secondary" onClick={() => setModalVisible(false)}>No</Button>,
<Button buttonStyle="danger" onClick={() => kill()}>Yes</Button>,
]}
/>
</div>
);
}

View file

@ -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<boolean>;
setVisible: Setter<boolean>;
zIndex?: number | undefined;
mount?: Node | undefined;
ref?: Ref<HTMLDivElement> | undefined;
} & ParentProps;
interface ModalContextType {
displayModal: (modal: () => JSX.Element) => number;
closeModal: (index?: number) => void;
};
const ModalContext = createContext() as Context<ModalContextType>;
type ModalsList = (() => JSX.Element)[];
export function ModalProvider(props: ParentProps) {
const [modals, setModals] = createStore<ModalsList>([]);
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 (
<FullscreenOverlay
visible={props.visible}
setVisible={props.setVisible}
mount={props.mount}
zIndex={props.zIndex || 1000}
>
{/* <div ref={mergeRefs(props.ref)} class="absolute top-0 bottom-0 left-1/2 flex items-center"> */}
<div ref={mergeRefs(props.ref)} class="bg-primary border border-white/5 p-4 rounded-lg text-center flex flex-col gap-y-2 min-w-xs">
{props.children}
</div>
{/* </div> */}
</FullscreenOverlay>
<ModalContext.Provider value={context}>
{props.children}
<FullscreenOverlay
visible={() => modals.length > 0}
setVisible={(value) => {
if (value === false)
context.closeModal();
}}
>
<Transition
mode="outin"
enterClass="modal-animation-enter"
enterActiveClass="modal-animation-enter-active"
enterToClass="modal-animation-enter-to"
exitClass="modal-animation-leave"
exitActiveClass="modal-animation-leave-active"
exitToClass="modal-animation-leave-to"
>
<>{modals.length > 0 && modals[modals.length - 1]!()}</>
</Transition>
</FullscreenOverlay>
</ModalContext.Provider>
);
}
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<number>();
const ctx: CreateModal = {
show: () => {
setIndex(controller.displayModal(() => el(ctx)));
},
hide: () => {
controller.closeModal(index());
},
};
return ctx;
}
export type ModalProps = {
ref?: Ref<HTMLDivElement> | undefined;
} & ParentProps & CreateModal;
function Modal(props: ModalProps) {
return (
<div ref={mergeRefs(props.ref)} class="bg-primary border border-white/5 p-4 rounded-lg text-center flex flex-col gap-y-2 min-w-xs">
{props.children}
</div>
);
}
Modal.SplitProps = function <T extends CreateModal>(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 (
<Modal
ref={props.ref}
setVisible={props.setVisible}
visible={props.visible}
mount={props.mount}
zIndex={props.zIndex}
>
<h2>{props.title}</h2>
<Modal {...rest}>
<h2>{split.title}</h2>
<div class="flex flex-col">
{props.children}
{split.children}
</div>
<div class="flex flex-row gap-x-4 [&>*]:flex-1 mt-2">
<For each={props.buttons}>
<For each={split.buttons}>
{button => (
<ModalButton props={button} />
)}
@ -75,14 +148,17 @@ type ModalDeleteProps = MakeOptional<ModalSimpleProps, 'title'> & {
};
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<NodeJS.Timeout | undefined>(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 (
<Modal.Simple
title={props.title || 'Confirm Delete'}
ref={props.ref}
setVisible={props.setVisible}
visible={props.visible}
mount={props.mount}
zIndex={props.zIndex}
buttons={props.buttons || [
{...rest}
title={split.title || 'Confirm Delete'}
buttons={split.buttons || [
<Button
buttonStyle="secondary"
children="Cancel"
@ -148,7 +218,7 @@ Modal.Delete = function (props: ModalDeleteProps) {
/>,
]}
>
{props.children || (
{split.children || (
<>
<div class="flex flex-col justify-center items-center gap-y-3">
<p>Are you sure you want to delete this item?</p>

View file

@ -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<AccountControllerContextFunc>() 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<string>();
const deleteModalVisible = () => deleteModalUuid() !== undefined;
const addAccountModal = createModal(props => (
<AddAccountModal {...props} refetch={refetch} />
));
const deleteAccountModal = createModal(props => (
<Modal.Delete {...props} onDelete={() => _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 (
<AccountControllerContext.Provider value={func}>
{props.children}
<AddAccountModal visible={visible} setVisible={setVisible} refetch={refetch} />
<Modal.Delete
setVisible={value => setDeleteModalUuid(value ? deleteModalUuid() : undefined)}
visible={deleteModalVisible}
onDelete={() => _forceRemoveAccount(deleteModalUuid())}
/>
</AccountControllerContext.Provider>
);
}
@ -84,13 +84,12 @@ enum ModalStage {
LoggingIn,
}
interface AddAccountModalProps {
visible: Accessor<boolean>;
setVisible: Setter<boolean>;
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 (
<Modal.Simple
{...modalProps}
title="Add Account"
visible={props.visible}
setVisible={props.setVisible}
buttons={[
<Button
buttonStyle="secondary"
children="Cancel"
onClick={() => props.setVisible(false)}
onClick={() => modalProps.hide()}
/>,
<Button
buttonStyle="primary"
@ -141,4 +139,4 @@ function AddAccountModal(props: AddAccountModalProps) {
</div>
</Modal.Simple>
);
}
};

View file

@ -1,12 +1,10 @@
import { type Params, useSearchParams } from '@solidjs/router';
import { Download01Icon } from '@untitled-theme/icons-solid';
import { For, createSignal, onMount } from 'solid-js';
import { onMount } from 'solid-js';
import { bridge } from '~imports';
import Button from '~ui/components/base/Button';
import Dropdown from '~ui/components/base/Dropdown';
import ModCard from '~ui/components/content/ModCard';
import Modal from '~ui/components/overlay/Modal';
import useCommand, { tryResult } from '~ui/hooks/useCommand';
import useCommand from '~ui/hooks/useCommand';
interface BrowserModParams extends Params {
id: string;
@ -16,31 +14,30 @@ interface BrowserModParams extends Params {
// TODO: Refactor the entire browser page and subpages
function BrowserMod() {
const [params] = useSearchParams<BrowserModParams>();
const _isInvalid = !params.id || !params.provider;
const [visible, setVisible] = createSignal(false);
const [cluster, setCluster] = createSignal<string>();
const [clusters, setClusters] = createSignal<[string, string][]>();
// const _isInvalid = !params.id || !params.provider;
// const [cluster, setCluster] = createSignal<string>();
// const [clusters, setClusters] = createSignal<[string, string][]>();
const [pkg] = useCommand(bridge.commands.getMod, params.id!);
function installTo() {
setVisible(true);
// setVisible(true);
}
function download() {
setVisible(false);
tryResult(bridge.commands.downloadMod, cluster()!, params.id!).then().catch(err => console.error(err));
}
// function download() {
// setVisible(false);
// tryResult(bridge.commands.downloadMod, cluster()!, params.id!).then().catch(err => console.error(err));
// }
function onChange(selected: number) {
setCluster(clusters()![selected]![1]);
}
// function onChange(selected: number) {
// setCluster(clusters()![selected]![1]);
// }
onMount(() => {
tryResult(bridge.commands.getClusters).then((res) => {
const list: [string, string][] = res.map(cluster => [cluster.meta.name, cluster.uuid]);
setClusters(list);
setCluster(clusters()![0]![1]);
});
// tryResult(bridge.commands.getClusters).then((res) => {
// const list: [string, string][] = res.map(cluster => [cluster.meta.name, cluster.uuid]);
// setClusters(list);
// setCluster(clusters()![0]![1]);
// });
});
return (
@ -60,37 +57,39 @@ function BrowserMod() {
<p>TO DO</p>
</div>
</div>
<Modal.Simple
visible={visible}
setVisible={setVisible}
title="Install To..."
buttons={[
<Button
buttonStyle="secondary"
children="Cancel"
onClick={() => setVisible(false)}
/>,
<Button
buttonStyle="primary"
children="Download"
onClick={download}
/>,
]}
>
<Dropdown onChange={onChange}>
<For each={clusters()}>
{cluster => (
<Dropdown.Row>{cluster[0]}</Dropdown.Row>
)}
</For>
</Dropdown>
</Modal.Simple>
</>
);
}
// function InstallToCluster(props: CreateModal) {
// return (
// <Modal.Simple
// {...props}
// title="Install To..."
// buttons={[
// <Button
// buttonStyle="secondary"
// children="Cancel"
// onClick={() => props.hide()}
// />,
// <Button
// buttonStyle="primary"
// children="Download"
// onClick={download}
// />,
// ]}
// >
// <Dropdown onChange={onChange}>
// <For each={clusters()}>
// {cluster => (
// <Dropdown.Row>{cluster[0]}</Dropdown.Row>
// )}
// </For>
// </Dropdown>
// </Modal.Simple>
// );
// }
BrowserMod.getUrl = function (params: BrowserModParams): string {
return `/browser/mod?id=${params.id}&provider=${params.provider}`;
};

View file

@ -3,6 +3,7 @@ import { type Accessor, type Setter, Show, createSignal, untrack } from 'solid-j
import { useBeforeLeave, useNavigate } from '@solidjs/router';
import * as dialog from '@tauri-apps/plugin-dialog';
import { open } from '@tauri-apps/plugin-shell';
import { join } from 'pathe';
import ClusterCover from '../../components/game/ClusterCover';
import LoaderIcon from '../../components/game/LoaderIcon';
import Button from '../../components/base/Button';
@ -12,18 +13,16 @@ import Sidebar from '~ui/components/Sidebar';
import type { Cluster } from '~bindings';
import { secondsToWords, upperFirst } from '~utils';
import useClusterContext from '~ui/hooks/useCluster';
import Modal from '~ui/components/overlay/Modal';
import { bridge } from '~imports';
import TextField from '~ui/components/base/TextField';
import SettingsRow from '~ui/components/SettingsRow';
import useSettingsContext from '~ui/hooks/useSettings';
import { join } from 'pathe';
import type { ModalProps } from '~ui/components/overlay/Modal';
import Modal, { createModal } from '~ui/components/overlay/Modal';
function ClusterOverview() {
const settings = useSettingsContext();
const [cluster, { refetch }] = useClusterContext();
const [deleteVisible, setDeleteVisible] = createSignal(false);
const [saveVisible, setSaveVisible] = createSignal(false);
const [editMode, setEditMode] = createSignal(false);
const [newName, setNewName] = createSignal('');
@ -31,10 +30,18 @@ function ClusterOverview() {
const navigate = useNavigate();
const saveModal = createModal(props => (
<SaveModal {...props} save={save} dontSave={dontSave} />
));
const deleteModal = createModal(props => (
<Modal.Delete {...props} onDelete={deleteCluster} />
));
useBeforeLeave((e) => {
if (editMode() && madeChanges()) {
e.preventDefault();
setSaveVisible(true);
saveModal.show();
}
});
@ -75,7 +82,7 @@ function ClusterOverview() {
const next = !untrack(() => editMode());
if (next === false && madeChanges()) {
setSaveVisible(true);
saveModal.show();
return;
}
@ -98,13 +105,13 @@ function ClusterOverview() {
}
function dontSave() {
setSaveVisible(false);
saveModal.hide();
setEditMode(false);
}
function save() {
pushEdits();
setSaveVisible(false);
saveModal.hide();
setEditMode(false);
}
@ -116,9 +123,6 @@ function ClusterOverview() {
cluster={cluster()!}
refetch={refetch}
saveVisible={saveVisible}
setSaveVisible={setSaveVisible}
editMode={editMode}
newName={newName}
setNewName={setNewName}
@ -174,50 +178,52 @@ function ClusterOverview() {
buttonStyle="danger"
children="Delete"
iconLeft={<Trash01Icon />}
onClick={() => setDeleteVisible(true)}
onClick={() => deleteModal.show()}
disabled={editMode()}
/>
)}
/>
</ScrollableContainer>
<Modal.Delete
visible={deleteVisible}
setVisible={setDeleteVisible}
onDelete={deleteCluster}
/>
<Modal.Simple
title="Save Changes?"
visible={saveVisible}
setVisible={setSaveVisible}
children="Do you want to save your changes?"
buttons={[
<Button
buttonStyle="secondary"
children="Cancel"
onClick={() => setSaveVisible(false)}
/>,
<Button
buttonStyle="danger"
children="No"
onClick={dontSave}
/>,
<Button
buttonStyle="primary"
children="Yes"
onClick={save}
/>,
]}
/>
</Sidebar.Page>
);
}
interface SaveModalProps extends ModalProps {
save: () => any;
dontSave: () => any;
}
function SaveModal(p: SaveModalProps) {
const [modalProps, props] = Modal.SplitProps(p);
return (
<Modal.Simple
{...modalProps}
title="Save Changes?"
children="Do you want to save your changes?"
buttons={[
<Button
buttonStyle="secondary"
children="Cancel"
onClick={() => modalProps.hide()}
/>,
<Button
buttonStyle="danger"
children="No"
onClick={props.dontSave}
/>,
<Button
buttonStyle="primary"
children="Yes"
onClick={props.save}
/>,
]}
/>
);
}
interface BannerProps {
cluster: Cluster;
saveVisible: Accessor<boolean>;
setSaveVisible: Setter<boolean>;
editMode: Accessor<boolean>;
newName: Accessor<string>;
setNewName: Setter<string>;