diff --git a/src/AboutModal.tsx b/src/AboutModal.tsx index 8ecf5940..9bd97dc4 100644 --- a/src/AboutModal.tsx +++ b/src/AboutModal.tsx @@ -1,5 +1,4 @@ -import React from "react"; -import { useModalRef } from "./misc/useModalRef"; +import React, { FC, PropsWithChildren, useState } from "react"; import cannonKeys from "./assets/cannonkeys.png"; import cannonKeysDarkMode from "./assets/cannonkeys-dark-mode.png"; @@ -40,13 +39,8 @@ import mekiboDarkMode from "./assets/mekibo-dark-mode.png"; import splitkb from "./assets/splitkb.png"; import splitkbDarkMode from "./assets/splitkb-dark-mode.png"; -import { GenericModal } from "./GenericModal"; import { ExternalLink } from "./misc/ExternalLink"; - -export interface AboutModalProps { - open: boolean; - onClose: () => void; -} +import { Modal, ModalContent } from "./components/modal/Modal"; enum SponsorSize { Large, @@ -175,80 +169,87 @@ const sponsors = [ }, ]; -export const AboutModal = ({ open, onClose }: AboutModalProps) => { - const ref = useModalRef(open, true); - +export const AboutModal: FC = ({ children }) => { + const [open, setOpen] = useState(false); return ( - -
-

- The ZMK Project:{" "} - website,{" "} - - GitHub Issues - - ,{" "} - - Discord Server - -

- -
-
-

- ZMK Studio is made possible thanks to the generous donation of time - from our contributors, as well as the financial sponsorship from the - following vendors: -

-
-
- {sponsors.map((s) => { - const heightVariants = { - [SponsorSize.Large]: "h-16", - [SponsorSize.Medium]: "h-12", - [SponsorSize.Small]: "h-8", - }; - - return ( - - -
- {s.vendors.map((v) => { - const maxSizeVariants = { - [SponsorSize.Large]: "max-h-16", - [SponsorSize.Medium]: "max-h-12", - [SponsorSize.Small]: "max-h-8", - }; - - return ( - - - {v.darkModeImg && ( - - )} - - - - ); - })} -
-
- ); - })} -
-
+ <> + setOpen(true)} + > + {children} + + + +
+

+ The ZMK Project:{" "} + website,{" "} + + GitHub Issues + + ,{" "} + + Discord Server + +

+
+
+

+ ZMK Studio is made possible thanks to the generous donation of + time from our contributors, as well as the financial sponsorship + from the following vendors: +

+
+
+ {sponsors.map((s) => { + const heightVariants = { + [SponsorSize.Large]: "h-16", + [SponsorSize.Medium]: "h-12", + [SponsorSize.Small]: "h-8", + }; + + return ( + + +
+ {s.vendors.map((v) => { + const maxSizeVariants = { + [SponsorSize.Large]: "max-h-16", + [SponsorSize.Medium]: "max-h-12", + [SponsorSize.Small]: "max-h-8", + }; + + return ( + + + {v.darkModeImg && ( + + )} + + + + ); + })} +
+
+ ); + })} +
+
+
+ ); }; diff --git a/src/App.tsx b/src/App.tsx index e09ed0cb..15b29371 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,8 +27,6 @@ import { LockStateContext } from "./rpc/LockStateContext"; import { UnlockModal } from "./UnlockModal"; import { valueAfter } from "./misc/async"; import { AppFooter } from "./AppFooter"; -import { AboutModal } from "./AboutModal"; -import { LicenseNoticeModal } from "./misc/LicenseNoticeModal"; declare global { interface Window { @@ -166,8 +164,6 @@ function App() { string | undefined >(undefined); const [doIt, undo, redo, canUndo, canRedo, reset] = useUndoRedo(); - const [showAbout, setShowAbout] = useState(false); - const [showLicenseNotice, setShowLicenseNotice] = useState(false); const [connectionAbort, setConnectionAbort] = useState(new AbortController()); const [lockState, setLockState] = useState( @@ -290,11 +286,6 @@ function App() { transports={TRANSPORTS} onTransportCreated={onConnect} /> - setShowAbout(false)} /> - setShowLicenseNotice(false)} - />
- setShowAbout(true)} - onShowLicenseNotice={() => setShowLicenseNotice(true)} - /> +
diff --git a/src/AppFooter.tsx b/src/AppFooter.tsx index e0d9af43..319f8800 100644 --- a/src/AppFooter.tsx +++ b/src/AppFooter.tsx @@ -1,24 +1,20 @@ -export interface AppFooterProps { - onShowAbout: () => void; - onShowLicenseNotice: () => void; -} +import { AboutModal } from "./AboutModal"; +import { LicenseNoticeModal } from "./misc/LicenseNoticeModal"; -export const AppFooter = ({ - onShowAbout, - onShowLicenseNotice, -}: AppFooterProps) => { +export const AppFooter = () => { return ( -
-
- © 2024 - The ZMK Contributors -{" "} - - About ZMK Studio - {" "} - -{" "} - - License NOTICE - + <> +
+ © 2024 - The ZMK Contributors + + + About ZMK Studio + + + + License NOTICE +
-
+ ); }; diff --git a/src/AppHeader.tsx b/src/AppHeader.tsx index 52a6658d..2fd4fbe3 100644 --- a/src/AppHeader.tsx +++ b/src/AppHeader.tsx @@ -7,14 +7,28 @@ import { } from "react-aria-components"; import { useConnectedDeviceData } from "./rpc/useConnectedDeviceData"; import { useSub } from "./usePubSub"; -import { useContext, useEffect, useState } from "react"; -import { useModalRef } from "./misc/useModalRef"; +import { + Dispatch, + FC, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from "react"; import { LockStateContext } from "./rpc/LockStateContext"; import { LockState } from "@zmkfirmware/zmk-studio-ts-client/core"; import { ConnectionContext } from "./rpc/ConnectionContext"; -import { ChevronDown, Undo2, Redo2, Save, Trash2 } from "lucide-react"; +import { + ChevronDown, + Undo2, + Redo2, + Save, + Trash2, + AlertTriangle, +} from "lucide-react"; import { Tooltip } from "./misc/Tooltip"; -import { GenericModal } from "./GenericModal"; +import { Modal, ModalContent } from "./components/modal/Modal"; export interface AppHeaderProps { connectedDeviceLabel?: string; @@ -28,6 +42,57 @@ export interface AppHeaderProps { canRedo?: boolean; } +const RestoreSettingsPrompt: FC< + Pick & { + open: boolean; + onOpenChange: (value: boolean) => void | Dispatch>; + } +> = ({ onResetSettings, open, onOpenChange }) => { + const handleContinue = useCallback(() => { + onOpenChange(false); + onResetSettings?.(); + }, [onOpenChange, onResetSettings]); + + return ( + + +
+
+ + + Restore stock settings. + +
+
+

+ Reset will restore the default keymap and remove all ZMK Studio + customizations. +

+ +

+ Are you sure you want to continue? +

+
+
+ + +
+
+
+
+ ); +}; + export const AppHeader = ({ connectedDeviceLabel, canRedo, @@ -44,6 +109,8 @@ export const AppHeader = ({ const lockState = useContext(LockStateContext); const connectionState = useContext(ConnectionContext); + const [promptRestoreSettings, setPromptRestoreSettings] = useState(false); + useEffect(() => { if ( (!connectionState.conn || @@ -54,14 +121,14 @@ export const AppHeader = ({ } }, [lockState, showSettingsReset]); - const showSettingsRef = useModalRef(showSettingsReset); + const [unsaved, setUnsaved] = useConnectedDeviceData( { keymap: { checkUnsavedChanges: true } }, - (r) => r.keymap?.checkUnsavedChanges + (r) => r.keymap?.checkUnsavedChanges, ); useSub("rpc_notification.keymap.unsavedChangesStatusChanged", (unsaved) => - setUnsaved(unsaved) + setUnsaved(unsaved), ); return ( @@ -70,33 +137,7 @@ export const AppHeader = ({ ZMK Logo

Studio

- -

Restore Stock Settings

-
-

- Settings reset will remove any customizations previously made in ZMK - Studio and restore the stock keymap -

-

Continue?

-
- - -
-
-
+ + + ); }; diff --git a/src/ConnectModal.tsx b/src/ConnectModal.tsx index e84d6ae9..f48269f2 100644 --- a/src/ConnectModal.tsx +++ b/src/ConnectModal.tsx @@ -5,9 +5,9 @@ import { UserCancelledError } from "@zmkfirmware/zmk-studio-ts-client/transport/ import type { AvailableDevice } from "./tauri/index"; import { Bluetooth, RefreshCw } from "lucide-react"; import { Key, ListBox, ListBoxItem, Selection } from "react-aria-components"; -import { useModalRef } from "./misc/useModalRef"; import { ExternalLink } from "./misc/ExternalLink"; -import { GenericModal } from "./GenericModal"; +import { Modal, ModalContent } from "./components/modal/Modal"; +import { ZmkStudio } from "./components/ZmkStudio"; export type TransportFactory = { label: string; @@ -200,7 +200,7 @@ function simpleDevicePicker( return (

Select a connection type.

-
    {connections}
+
    {connections}
{selectedTransport && availableDevices && (
    {availableDevices.map((d) => ( @@ -278,16 +278,20 @@ export const ConnectModal = ({ transports, onTransportCreated, }: ConnectModalProps) => { - const dialog = useModalRef(open || false, false, false); - const haveTransports = useMemo(() => transports.length > 0, [transports]); return ( - -

    Welcome to ZMK Studio

    - {haveTransports - ? connectOptions(transports, onTransportCreated, open) - : noTransportsOptionsPrompt()} -
    + {}} onEscapeClose={false} onBackdropClose={false}> + + + {haveTransports + ? connectOptions(transports, onTransportCreated, open) + : noTransportsOptionsPrompt()} + +
    + © 2024 - The ZMK Contributors +
    +
    +
    ); }; diff --git a/src/components/ZmkStudio.tsx b/src/components/ZmkStudio.tsx new file mode 100644 index 00000000..4de27295 --- /dev/null +++ b/src/components/ZmkStudio.tsx @@ -0,0 +1,71 @@ +import { FC, SVGProps, useId } from "react"; + +const ZmkLogo: FC> = (props) => { + const gradientId = useId(); + return ( + + + + + + + + + + + + + + + + ); +}; + +type ZmkStudioProps = { + containerClassName?: string; + logoProps?: SVGProps; + textClassName?: string; +}; + +export const ZmkStudio: FC = ({ + containerClassName, + logoProps, + textClassName, +}) => ( +
    + + + Studio + +
    +); diff --git a/src/components/modal/Modal.stories.tsx b/src/components/modal/Modal.stories.tsx new file mode 100644 index 00000000..9e47ae25 --- /dev/null +++ b/src/components/modal/Modal.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Modal as ModalComponent, ModalContent as Content } from "./Modal"; + +import { useState } from "react"; +import { ModalContent } from "./ModalContent.stories"; +import { ZmkStudio } from "../ZmkStudio"; + +const meta = { + title: "UI/Modal", + component: ModalComponent, + subcomponents: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ModalContent: Content, + }, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + open: false, + onOpenChange: () => {}, + onBackdropClose: false, + onEscapeClose: true, + }, + argTypes: { + open: { + control: { + type: "boolean", + }, + }, + onBackdropClose: { + control: { + type: "boolean", + }, + }, + onEscapeClose: { + control: { + type: "boolean", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Modal: Story = { + args: { + onBackdropClose: true, + onEscapeClose: true, + }, + + render: (args) => { + const [open, setOpen] = useState(args.open); + return ( + <> + + + + +
    Hello World
    +
    +
    + + ); + }, +}; diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx new file mode 100644 index 00000000..b0417ba5 --- /dev/null +++ b/src/components/modal/Modal.tsx @@ -0,0 +1,126 @@ +import { X } from "lucide-react"; +import { + FC, + PropsWithChildren, + useContext, + useEffect, + useRef, + forwardRef, + useId, +} from "react"; +import { ModalContext, ModalContextType } from "./ModalContext"; + +export const Modal: FC>> = ({ + children, + onEscapeClose = true, + onBackdropClose = true, + open, + onOpenChange, +}) => { + const dialogId = useId(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current!; + + if (open) { + dialog.showModal(); + requestAnimationFrame(() => { + dialog.dataset.open = ""; + delete dialog.dataset.closing; + }); + + const handleEscape = (e: KeyboardEvent) => { + e.preventDefault(); + + if (!onEscapeClose) return; + if (e.key !== "Escape") return; + onOpenChange(false); + }; + + dialog.addEventListener("keydown", handleEscape); + return () => void dialog.removeEventListener("keydown", handleEscape); + } else { + dialog.dataset.closing = ""; + delete dialog.dataset.open; + + const backdrop = dialog.firstElementChild as HTMLElement; + const content = backdrop?.firstElementChild as HTMLElement; + + let transitionsComplete = 0; + + const handleTransitionEnd = () => { + // Increment counter to track when both backdrop and content transitions are complete + transitionsComplete++; + + // If both transitions are completee the dialog + if (transitionsComplete >= 2) { + dialog.close(); + backdrop?.removeEventListener("transitionend", handleTransitionEnd); + content?.removeEventListener("transitionend", handleTransitionEnd); + } + }; + + backdrop?.addEventListener("transitionend", handleTransitionEnd); + content?.addEventListener("transitionend", handleTransitionEnd); + + return () => { + backdrop.removeEventListener("transitionend", handleTransitionEnd); + content?.removeEventListener("transitionend", handleTransitionEnd); + }; + } + }, [onEscapeClose, onOpenChange, open]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (!onBackdropClose) return; + if (e.target === e.currentTarget) { + onOpenChange(false); + } + }; + + return ( + + +
    +
    + {children} +
    +
    +
    +
    + ); +}; + +type ModalContentProps = PropsWithChildren<{ + className?: string; + showCloseButton?: boolean; +}>; + +export const ModalContent = forwardRef( + function ModalContent({ children, className, showCloseButton = true }, ref) { + const { onOpenChange } = useContext(ModalContext); + return ( +
    + {children} + {showCloseButton && ( + + )} +
    + ); + }, +); diff --git a/src/components/modal/ModalContent.stories.tsx b/src/components/modal/ModalContent.stories.tsx new file mode 100644 index 00000000..369e320e --- /dev/null +++ b/src/components/modal/ModalContent.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Modal as ModalComponent, ModalContent as Content } from "./Modal"; +import { ZmkStudio } from "../ZmkStudio"; + +const meta = { + title: "UI/Modal/ModalContent", + component: Content, + parameters: { + layout: "centered", + }, + tags: [], + args: { + showCloseButton: true, + className: "max-w-2xl min-h-48", + }, + argTypes: { + className: { + control: { + type: "text", + }, + }, + showCloseButton: { + defaultValue: true, + control: { + type: "boolean", + }, + }, + }, +} satisfies Meta; + +export default meta; +type ModalContentStory = StoryObj; + +export const ModalContent: ModalContentStory = { + args: { + showCloseButton: true, + className: "max-w-2xl min-h-48", + }, + render: (args) => ( + {}}> + + +
    Hello World
    +
    +
    + ), +}; diff --git a/src/components/modal/ModalContext.tsx b/src/components/modal/ModalContext.tsx new file mode 100644 index 00000000..2ada3d51 --- /dev/null +++ b/src/components/modal/ModalContext.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Dispatch, PropsWithChildren, SetStateAction } from "react"; + +export type ModalContextType = PropsWithChildren<{ + id: string; + open: boolean; + onEscapeClose?: boolean; + onBackdropClose?: boolean; + onOpenChange: (open: boolean) => void | Dispatch>; +}>; + +export const ModalContext = React.createContext( + {} as ModalContextType, +); diff --git a/src/components/modal/useModalRef.tsx b/src/components/modal/useModalRef.tsx new file mode 100644 index 00000000..1bfc3584 --- /dev/null +++ b/src/components/modal/useModalRef.tsx @@ -0,0 +1,6 @@ +import { ModalContext, ModalContextType } from "./ModalContext"; +import { useContext } from "react"; + +export function useModalRef(): ModalContextType { + return useContext(ModalContext); +} diff --git a/src/keyboard/LayerPicker.tsx b/src/keyboard/LayerPicker.tsx index 1584191d..da0011bb 100644 --- a/src/keyboard/LayerPicker.tsx +++ b/src/keyboard/LayerPicker.tsx @@ -8,8 +8,7 @@ import { Selection, useDragAndDrop, } from "react-aria-components"; -import { useModalRef } from "../misc/useModalRef"; -import { GenericModal } from "../GenericModal"; +import { Modal, ModalContent } from "../components/modal/Modal.tsx"; interface Layer { id: number; @@ -49,56 +48,62 @@ const EditLabelModal = ({ }: { open: boolean; onClose: () => void; - editLabelData: EditLabelData; + editLabelData: EditLabelData | null; handleSaveNewLabel: ( id: number, oldName: string, newName: string | null ) => void; }) => { - const ref = useModalRef(open); - const [newLabelName, setNewLabelName] = useState(editLabelData.name); + const [newLabelName, setNewLabelName] = useState(editLabelData?.name ?? ''); - const handleSave = () => { + const handleSave = useCallback(() => { + if (!editLabelData) return + handleSaveNewLabel(editLabelData.id, editLabelData.name, newLabelName); onClose(); - }; + }, [editLabelData]); return ( - - New Layer Name - setNewLabelName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSave(); - } - }} - /> -
    - - -
    -
    + + + {editLabelData && ( + <> + New Layer Name + setNewLabelName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } + }} + /> +
    + + +
    + + )} +
    +
    ); }; @@ -190,14 +195,14 @@ export const LayerPicker = ({ )}
- {editLabelData !== null && ( - setEditLabelData(null)} - editLabelData={editLabelData} - handleSaveNewLabel={handleSaveNewLabel} - /> - )} + + setEditLabelData(null)} + editLabelData={editLabelData} + handleSaveNewLabel={handleSaveNewLabel} + /> + void; -} - -export const LicenseNoticeModal = ({ - open, - onClose, -}: LicenseNoticeModalProps) => { - const ref = useModalRef(open, true); +import { Modal, ModalContent } from "../components/modal/Modal"; +export const LicenseNoticeModal: FC = ({ children }) => { + const [open, setOpen] = useState(false); return ( - -
-
-

- ZMK Studio is released under the open source Apache 2.0 license. A - copy of the NOTICE file from the ZMK Studio repository is included - here: -

- -
-
{NOTICE}
-
-
+ <> + setOpen(true)} + > + {children} + + + +
+

+ ZMK Studio is released under the open source Apache 2.0 license. A + copy of the NOTICE file from the ZMK Studio repository is included + here: +

+
+
{NOTICE}
+
+
+ ); };