Skip to content

Instantly share code, notes, and snippets.

@acousineau
Last active October 20, 2024 23:11
Show Gist options
  • Save acousineau/1d0e2629477b0b9cae21eb2a60edc884 to your computer and use it in GitHub Desktop.
Save acousineau/1d0e2629477b0b9cae21eb2a60edc884 to your computer and use it in GitHub Desktop.

Confirm Delete Modal

Structure

  • App.tsx, App.css - App entrypoint and where an instance of the ConfirmDeleteModal is used for testing
  • buttons.tsx - Establish some baseline buttons with proper styles
  • index.css - Styles taken from the @prefecthq/prefect-design library
  • main.tsx - Root entrypoint for app, simply used to mount the React App to the DOM node index.html
  • tailwind.config.ts - Additional tailwind config taken from the @prefecthq/prefect-design library
  • ConfirmDeleteModal - contains the component along with an additional styles file
  • useClickOutside.ts - React hook for running some functionality if the user clicks outside the component

External Libraries

  • tailwind - The existing component library leverages tailwind, so I thought I should follow suit.
  • styled-components - This ensured I could use intended brand variables for various colors.
  • font-awesome - Get some standard icons into the project

Commentary

Upon building out the component there were a few considerations I made up front.

  • I wanted to ensure the styling matched the brand as close as possible along with using the tools that the open source library was using for styling
  • While I have some experience developing in Vue, I still wanted to leverage ChatGPT to see if it could bring to light any syntax/conceptual functionality misunderstandings I might of had with just reading the component itself. It would also give me a decent starting point with the component.

With those 2 things in mind. I started taking a look at how the existing component library was leveraging tailwind and any css to declare global variables. Come to find a lot of work has already been done there so I was able to pull in a lot of that pre-existing color palette. However, I wasn't sure if there was a way to leverage the globally declared variables within the tailwind class usage (this was at least an issue with a company I used to work at). Took a quick look through the tailwind configs but I didn't want to take too much time diving into configuration. Therefore I reached for the styled-components library which is relatively standard for the React ecosystem. Ultimately I just used it to establish some baseline Button styles (buttons.tsx).

When it came to using tailwind itself, the open source library made use of the @apply directive to supply tailwind shorthand to developer defined classes. Seemed like a very reasonable method, but again without wanting to figure out how to make that work within the styled-component configuration, I opted to just supply raw tailwind classes to the components where I could.

It's nice that Vue has a standard convention for styling because there are a lot of architectural choices that can be made when working with React and can supply some decision fatigue initially.

After I established some baseline styles and components, it was time to convert the ConfirmDeleteModal. I submitted the Vue code to ChatGPT to see what kind of React template it would give me. It delivered on most of the functionality but there were 4 primary issues with it in my opinion.

  • It did not properly translate the Vue slots for title, actions, and message to props
  • It made use of the useState and useEffect hooks to track how the showModal prop changed over time.
  • It did not leverage React.createPortal in any way to ensure the browser's stacking context would not interfere with displaying the modal at the correct DOM level
  • It did not generate a proper typescript template

To address the slots issue, I ensured that those could be passed in as additional props with a type of React.ReactNode so the developer can use components, strings, etc as it seems that was the intended API from the Vue template.

I addressed the overuse of useState and useEffect by ensuring the showModal prop would always be the source of truth to determine if the modal is open. This way, the state can be lifted out of the component and the business logic can live outside of a more functional component. Originally, the showModal prop serving only as the default state for the internal useState. Therefore a useEffect was needed to be a listener for that prop to ensure the internal useState was always accurate. And because useEffect runs after a render from a state or prop change, it would always trigger an unnecessary re-render which could lead to performance problems down the road.

Once I established the intended functionality and a more "pixel perfect" styling of the modal as represented by the video, I started to make use of the React.createPortal API as I would consider this a best practice when creating Modals or Tooltips. The developer can create an instance of the Modal anywhere within their component hierarchy without needing to worry about the browser's stacking context. The Modal will always appear above any content surrounding it. I think one opportunity for improvement here is actually to pass into the modal a reference to the intended mounting node as a prop.

I also added in a custom react hook that allows the user to "click outside" the modal in order to close it. This is another fairly standard piece of expected functionality when it comes to Modals. Given more time, I would have also like to address some accessibility needs when it comes to Modals in terms of focus management and labeling.

Additionally, I do work with Github Copilot locally for some help with speed and auto complete

import { useState, useEffect } from "react";
import { ConfirmDeleteModal } from "./ConfirmDeleteModal";
import { DestructiveButton, OutlineButton } from "./buttons";
import "./App.css";
function App() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
document.body.classList.toggle("dark", prefersDark);
document.body.classList.toggle("light", !prefersDark);
}, []);
const onDelete = () => alert("Item deleted");
const onClose = (showModal: boolean) => setIsOpen(showModal);
return (
<div>
<button
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setIsOpen((prevState) => !prevState);
}}
>
Trigger Modal
</button>
<ConfirmDeleteModal
showModal={isOpen}
name="grandparent"
title="An interesting title instead"
onDelete={onDelete}
onClose={onClose}
actions={
<>
<OutlineButton className="mr-2" onClick={() => onClose(false)}>
Close
</OutlineButton>
<DestructiveButton onClick={onDelete}>Delete</DestructiveButton>
</>
}
/>
<div id="modal-portal"></div>
</div>
);
}
export default App;
import styled from "styled-components";
const Button = ({ className, ...rest }: React.ComponentProps<"button">) => {
return <button className={`${className} rounded-md px-2 py-1`} {...rest} />;
};
export const OutlineButton = styled(Button)`
background: var(--p-color-button-default-bg);
border: 1px solid var(--p-color-button-default-border);
color: var(--p-color-button-default-text);
&:hover {
background: var(--p-color-button-default-bg-hover);
border: 1px solid var(--p-color-button-default-border-hover);
color: var(--p-color-button-default-text-hover);
}
&:active {
background: var(--p-color-button-default-bg-active);
border: 1px solid var(--p-color-button-default-border-active);
color: var(--p-color-button-default-text-active);
}
`;
export const DestructiveButton = styled(Button)`
background: var(--p-color-button-primary-danger-bg);
border: 1px solid var(--p-color-button-primary-danger-border);
color: var(--p-color-button-primary-danger-text);
&:hover {
background: var(--p-color-button-primary-danger-bg-hover);
border: 1px solid var(--p-color-button-primary-danger-border-hover);
color: var(--p-color-button-primary-danger-text-hover);
}
&:active {
background: var(--p-color-button-primary-danger-bg-active);
border: 1px solid var(--p-color-button-primary-danger-border-active);
color: var(--p-color-button-primary-danger-text-active);
}
`;
import React, { useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleExclamation,
faClose,
} from "@fortawesome/free-solid-svg-icons";
import { useOutsideClick } from "../useClickOutside";
import {
ConfirmModalBackdrop,
ConfirmModalContent,
} from "./ConfirmDeleteModal.style";
import { DestructiveButton } from "../buttons";
type ConfirmDeleteModalProps = {
showModal: boolean;
label?: string;
name?: string;
action?: "Delete" | "Remove";
onDelete?: () => void;
onClose?: (showModal: boolean) => void;
title?: React.ReactNode;
message?: React.ReactNode;
actions?: React.ReactNode;
};
export function ConfirmDeleteModal({
showModal,
label,
name = "",
action = "Delete",
onDelete,
onClose,
// Previously represented as slots
actions,
title,
message,
}: ConfirmDeleteModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const closeModal = useCallback(() => {
if (showModal && onClose) {
onClose(false);
}
}, [showModal, onClose]);
const handleDeleteClick = useCallback(() => {
if (onDelete) {
onDelete();
}
closeModal();
}, [closeModal, onDelete]);
useOutsideClick(modalRef, () => {
closeModal();
});
if (!showModal) return null;
return createPortal(
<div className="delete-modal__container">
<ConfirmModalBackdrop className="fixed top-0 left-0 right-0 bottom-0 opacity-50 z-50" />
<ConfirmModalContent
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 rounded-md min-w-[500px]"
ref={modalRef}
>
{/* Modal header */}
<div className="p-4 flex items-center border-b-[1px] border-gray-40 justify-between">
<div className="flex items-center">
<FontAwesomeIcon
icon={faCircleExclamation}
className="text-sentiment-negative mr-2"
/>
<span className="capitalize text-xl inline-block">
{title ? title : `${action} ${label ?? name}`}
</span>
</div>
<button onClick={closeModal}>
<FontAwesomeIcon icon={faClose} />
</button>
</div>
{/* Message */}
<span className="block p-4 border-b-[1px] border-gray-40">
{message
? message
: `Are you sure you want to ${action.toLowerCase()} ${name}?`}
</span>
{/* Actions */}
<div className="p-4 flex justify-end">
{actions ? (
actions
) : (
<DestructiveButton onClick={handleDeleteClick}>
{action}
</DestructiveButton>
)}
</div>
</ConfirmModalContent>
</div>,
document.getElementById("modal-portal") || document.body
);
}
import styled from "styled-components";
export const ConfirmModalBackdrop = styled.div`
background: var(--p-color-bg-1);
`;
export const ConfirmModalContent = styled.div`
background: var(--p-color-bg-3);
`;
import { useEffect, useCallback } from "react";
export function useOutsideClick<T extends HTMLElement>(
ref: React.RefObject<T>,
callback: () => void
) {
const handleClick = useCallback(
(e: MouseEvent) => {
const target = e.target as HTMLElement;
if (ref.current && !ref.current.contains(target)) {
callback();
}
},
[callback, ref]
);
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
}
@acousineau
Copy link
Author

Screenshot 2024-10-20 at 6 47 00 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment