Skip to content

Commit

Permalink
Merge pull request altalyst-solutions#11 from sleepinzombie/feat/add-…
Browse files Browse the repository at this point in the history
…use-outside-click

Add useOutsideClick hook
  • Loading branch information
sleepinzombie authored Dec 13, 2024
2 parents 1688ea1 + 8a7f76f commit a4b22b8
Show file tree
Hide file tree
Showing 11 changed files with 659 additions and 3 deletions.
487 changes: 487 additions & 0 deletions examples/basic/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"sass-embedded": "^1.82.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
Expand Down
22 changes: 22 additions & 0 deletions examples/basic/src/components/use-outside-click/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.component {
&__container {
display: flex;
flex-direction: column;
position: relative;
}

&__button {
padding: 10px 20px;
cursor: pointer;
}

&__dropdown {
position: absolute;
top: 50px;
left: 0;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useOutsideClick } from "@altalyst/hookify";
import type { MouseEvent } from "react";
import { useRef, useState } from "react";

import "./styles.scss";

export const UseOutsideClick = () => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

const handleToggleDropdown = (event: MouseEvent) => {
event.stopPropagation();
setIsOpen((prev) => !prev);
};

const handleOutsideClick = () => {
setIsOpen(false);
};

useOutsideClick(dropdownRef, handleOutsideClick);

return (
<div className="component__container">
<button className="component__button" onClick={handleToggleDropdown}>
Toggle Dropdown
</button>

{isOpen && (
<div ref={dropdownRef} className="component__dropdown">
<p>Dropdown Content</p>
<p>Click outside to close me!</p>
</div>
)}
</div>
);
};
5 changes: 5 additions & 0 deletions examples/basic/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createBrowserRouter } from "react-router-dom";
import { HooksPage } from "./routes/hooks.tsx";
import { UseApiPage } from "./routes/hooks/use-api.tsx";
import { UseEffectAfterMountPage } from "./routes/hooks/use-effect-after-mount.tsx";
import { UseOutsideClickPage } from "./routes/hooks/use-outside-click.tsx";
import { Root } from "./routes/root.tsx";

export const router = createBrowserRouter([
Expand All @@ -22,6 +23,10 @@ export const router = createBrowserRouter([
path: "/hooks/use-api",
element: <UseApiPage />,
},
{
path: "/hooks/use-outside-click",
element: <UseOutsideClickPage />,
},
],
},
]);
3 changes: 3 additions & 0 deletions examples/basic/src/routes/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export const HooksPage = () => {
<li>
<Link to={"/hooks/use-api"}>useApi hook</Link>
</li>
<li>
<Link to={"/hooks/use-outside-click"}>useOutsideClick hook</Link>
</li>
</ul>
</nav>
<Outlet />
Expand Down
10 changes: 10 additions & 0 deletions examples/basic/src/routes/hooks/use-outside-click.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { UseOutsideClick } from "../../components/use-outside-click/use-outside-click";

export const UseOutsideClickPage = () => {
return (
<>
<h1>Demo for: useOutsideClick hook</h1>
<UseOutsideClick />
</>
);
};
13 changes: 10 additions & 3 deletions examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
},
},
},
});
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./use-api";
export * from "./use-effect-after-mount";
export * from "./use-outside-click";
43 changes: 43 additions & 0 deletions src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { RefObject } from "react";
import { useEffect } from "react";

/**
* A custom React hook that triggers a callback function when a click or touch event occurs outside the specified element.
*
* This hook is useful for handling scenarios like closing dropdowns, modals, or tooltips when clicking outside the component.
*
* @param {RefObject<HTMLElement>} ref - A React ref object pointing to the element to detect outside clicks for.
* @param {() => void} callback - The callback function to execute when a click or touch event occurs outside the specified element.
*
* @example
* ```typescript
* const ref = useRef<HTMLDivElement>(null);
* useOutsideClick(ref, () => {
* console.log("Clicked outside the component");
* });
*
* return <div ref={ref}>Click outside this element</div>;
* ```
*
* For a full working example, check the implementation in [examples/useOutsideClick.ts](../../examples/basic/src/components/use-outside-click/use-outside-click.tsx).
*/
export const useOutsideClick = (
ref: RefObject<HTMLElement>,
callback: () => void
) => {
useEffect(() => {
const handleOutsideClick = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};

document.addEventListener("click", handleOutsideClick);
document.addEventListener("touchstart", handleOutsideClick);

return () => {
document.removeEventListener("click", handleOutsideClick);
document.removeEventListener("touchstart", handleOutsideClick);
};
}, [ref, callback]);
};
41 changes: 41 additions & 0 deletions tests/hooks/use-outside-click.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useOutsideClick } from "@/hooks";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

describe("useOutsideClick", () => {
it("should call the callback when clicking outside the referenced element", () => {
const callback = vi.fn();
const ref = { current: document.createElement("div") };

document.body.appendChild(ref.current);
renderHook(() => useOutsideClick(ref, callback));
document.body.click();

expect(callback).toHaveBeenCalledTimes(1);

document.body.removeChild(ref.current);
});

it("should not call the callback when clicking inside the referenced element", () => {
const callback = vi.fn();
const ref = { current: document.createElement("div") };

document.body.appendChild(ref.current);
renderHook(() => useOutsideClick(ref, callback));
ref.current.click();

expect(callback).not.toHaveBeenCalled();

document.body.removeChild(ref.current);
});

it("should handle no reference gracefully", () => {
const callback = vi.fn();
const ref = { current: null };

renderHook(() => useOutsideClick(ref, callback));
document.body.click();

expect(callback).not.toHaveBeenCalled();
});
});

0 comments on commit a4b22b8

Please sign in to comment.