Improve a lot the tablo page
This commit is contained in:
parent
4e0038d899
commit
44d837496f
8 changed files with 921 additions and 656 deletions
101
ui/src/components/ClickOutside.md
Normal file
101
ui/src/components/ClickOutside.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Click Outside Components
|
||||
|
||||
This module provides two ways to detect clicks outside of elements: a hook and a wrapper component.
|
||||
|
||||
## `useClickOutside` Hook
|
||||
|
||||
A React hook that detects clicks outside of a referenced element.
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { useClickOutside } from "../hooks/useClickOutside";
|
||||
|
||||
function MyComponent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<div ref={ref}>{/* Content that will close when clicking outside */}</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- `callback: () => void` - Function to call when clicking outside
|
||||
|
||||
### Returns
|
||||
|
||||
- `ref` - React ref to attach to the element you want to monitor
|
||||
|
||||
## `ClickOutside` Component
|
||||
|
||||
A wrapper component that handles click outside detection for its children.
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
|
||||
function MyComponent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ClickOutside onClickOutside={() => setIsOpen(false)}>
|
||||
<div>{/* Content that will close when clicking outside */}</div>
|
||||
</ClickOutside>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
- `children: React.ReactNode` - The content to wrap
|
||||
- `onClickOutside: () => void` - Function to call when clicking outside
|
||||
- `className?: string` - Optional className for the wrapper div
|
||||
- `disabled?: boolean` - Disable click outside detection (default: false)
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Modal Dialog
|
||||
|
||||
```tsx
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
|
||||
<ClickOutside onClickOutside={closeModal}>
|
||||
<div className="bg-white rounded-lg p-6">Modal content</div>
|
||||
</ClickOutside>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Dropdown Menu
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<button onClick={toggleMenu}>Menu</button>
|
||||
{isOpen && (
|
||||
<ClickOutside
|
||||
onClickOutside={closeMenu}
|
||||
className="absolute top-full left-0"
|
||||
>
|
||||
<div className="bg-white border rounded shadow">Menu items</div>
|
||||
</ClickOutside>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Popover/Tooltip
|
||||
|
||||
```tsx
|
||||
<ClickOutside onClickOutside={closePopover}>
|
||||
<div className="bg-white border rounded shadow p-4">Popover content</div>
|
||||
</ClickOutside>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The hook uses `mousedown` events for detection
|
||||
- Event listeners are properly cleaned up when the component unmounts
|
||||
- The wrapper component adds a single `div` element
|
||||
- Use the `disabled` prop to temporarily disable click outside detection
|
||||
- TypeScript generic support for proper ref typing
|
||||
33
ui/src/components/ClickOutside.tsx
Normal file
33
ui/src/components/ClickOutside.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from "react";
|
||||
import { useClickOutside } from "../hooks/useClickOutside";
|
||||
|
||||
interface ClickOutsideProps {
|
||||
children: React.ReactNode;
|
||||
onClickOutside: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that wraps children and detects clicks outside
|
||||
* @param children - The content to wrap
|
||||
* @param onClickOutside - Function to call when clicking outside
|
||||
* @param className - Optional className for the wrapper
|
||||
* @param disabled - Disable click outside detection
|
||||
*/
|
||||
export const ClickOutside: React.FC<ClickOutsideProps> = ({
|
||||
children,
|
||||
onClickOutside,
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const ref = useClickOutside<HTMLDivElement>(
|
||||
disabled ? () => {} : onClickOutside
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
ui/src/components/CreateTabloModal.tsx
Normal file
121
ui/src/components/CreateTabloModal.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState } from "react";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
interface Tablo {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: string;
|
||||
color?: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
}
|
||||
|
||||
interface CreateTabloModalProps {
|
||||
onClose: () => void;
|
||||
onCreate: (tabloData: Omit<Tablo, "id">) => void;
|
||||
}
|
||||
|
||||
export const CreateTabloModal = ({
|
||||
onClose,
|
||||
onCreate,
|
||||
}: CreateTabloModalProps) => {
|
||||
const [newTabloName, setNewTabloName] = useState("");
|
||||
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
|
||||
const [selectedImage, setSelectedImage] = useState(
|
||||
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
|
||||
);
|
||||
const [selectedColor, setSelectedColor] = useState("bg-blue-500");
|
||||
const [selectedStatus, setSelectedStatus] = useState<
|
||||
"todo" | "in_progress" | "done"
|
||||
>("todo");
|
||||
|
||||
const resetForm = () => {
|
||||
setNewTabloName("");
|
||||
setCreationMode("color");
|
||||
setSelectedImage(
|
||||
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
|
||||
);
|
||||
setSelectedColor("bg-blue-500");
|
||||
setSelectedStatus("todo");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newTabloName.trim()) {
|
||||
const tabloData = {
|
||||
name: newTabloName.trim(),
|
||||
status: selectedStatus,
|
||||
...(creationMode === "image"
|
||||
? { image: selectedImage }
|
||||
: { color: selectedColor }),
|
||||
};
|
||||
onCreate(tabloData);
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<ClickOutside onClickOutside={handleClose}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl min-w-96 mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Créer un nouveau tablo
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Name Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nom du tablo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTabloName}
|
||||
onChange={(e) => setNewTabloName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Entrez le nom du tablo"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatusPicker
|
||||
selectedStatus={selectedStatus}
|
||||
setSelectedStatus={setSelectedStatus}
|
||||
/>
|
||||
|
||||
<ImageColorPicker
|
||||
creationMode={creationMode}
|
||||
setCreationMode={setCreationMode}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleCreate}
|
||||
disabled={!newTabloName.trim() || creationMode === "image"}
|
||||
>
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
ui/src/components/ImageColorPicker.tsx
Normal file
150
ui/src/components/ImageColorPicker.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
interface ImageColorPickerProps {
|
||||
creationMode: "image" | "color";
|
||||
setCreationMode: (mode: "image" | "color") => void;
|
||||
selectedColor: string;
|
||||
setSelectedColor: (color: string) => void;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
"bg-blue-500",
|
||||
"bg-green-500",
|
||||
"bg-purple-500",
|
||||
"bg-red-500",
|
||||
"bg-yellow-500",
|
||||
"bg-indigo-500",
|
||||
"bg-pink-500",
|
||||
"bg-teal-500",
|
||||
"bg-orange-500",
|
||||
"bg-cyan-500",
|
||||
];
|
||||
|
||||
export const ImageColorPicker = ({
|
||||
creationMode,
|
||||
setCreationMode,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
}: ImageColorPickerProps) => {
|
||||
return (
|
||||
<div className="my-4 space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Style
|
||||
</label>
|
||||
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
||||
creationMode === "image"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
} transition-colors`}
|
||||
onClick={() => setCreationMode("image")}
|
||||
>
|
||||
Image (Bientôt disponible)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium ${
|
||||
creationMode === "color"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
} transition-colors`}
|
||||
onClick={() => setCreationMode("color")}
|
||||
>
|
||||
Couleur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Mode */}
|
||||
{creationMode === "image" && (
|
||||
<div className="space-y-4">
|
||||
{/* File Upload - Coming Soon */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="mx-auto h-8 w-8 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
<span className="font-medium">Import d'images</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Bientôt disponible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commented out image selection */}
|
||||
{/* <div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Choisir une image
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 max-h-40 overflow-y-auto">
|
||||
{availableImages.map((image) => (
|
||||
<button
|
||||
key={image}
|
||||
type="button"
|
||||
className={`relative w-full h-16 rounded border-2 ${
|
||||
selectedImage === image
|
||||
? "border-blue-500"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
} hover:border-blue-400 transition-colors overflow-hidden`}
|
||||
onClick={() => setSelectedImage(image)}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt="Option"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{selectedImage === image && (
|
||||
<div className="absolute inset-0 bg-blue-500/20 flex items-center justify-center">
|
||||
<span className="text-blue-600 text-lg">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Mode */}
|
||||
{creationMode === "color" && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Couleur
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`w-12 h-12 ${color} rounded-lg border-2 ${
|
||||
selectedColor === color
|
||||
? "border-gray-800 dark:border-white scale-110"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
} hover:scale-105 transition-all duration-200`}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<span className="text-white text-lg">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
ui/src/components/StatusPicker.tsx
Normal file
52
ui/src/components/StatusPicker.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
interface StatusPickerProps {
|
||||
selectedStatus: "todo" | "in_progress" | "done";
|
||||
setSelectedStatus: (status: "todo" | "in_progress" | "done") => void;
|
||||
}
|
||||
|
||||
export const StatusPicker = ({
|
||||
selectedStatus,
|
||||
setSelectedStatus,
|
||||
}: StatusPickerProps) => {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Statut
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedStatus === "todo"
|
||||
? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 ring-2 ring-gray-300 dark:ring-gray-600"
|
||||
: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() => setSelectedStatus("todo")}
|
||||
>
|
||||
À faire
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedStatus === "in_progress"
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 ring-2 ring-blue-300 dark:ring-blue-600"
|
||||
: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() => setSelectedStatus("in_progress")}
|
||||
>
|
||||
En cours
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedStatus === "done"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 ring-2 ring-green-300 dark:ring-green-600"
|
||||
: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
onClick={() => setSelectedStatus("done")}
|
||||
>
|
||||
Terminé
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
128
ui/src/components/TabloModal.tsx
Normal file
128
ui/src/components/TabloModal.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { ClickOutside } from "./ClickOutside";
|
||||
import { useState } from "react";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
interface Tablo {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: string;
|
||||
color?: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
}
|
||||
|
||||
interface TabloModalProps {
|
||||
tablo: Tablo | null;
|
||||
onClose: () => void;
|
||||
onSave?: (updatedTablo: Tablo) => void;
|
||||
}
|
||||
|
||||
export const TabloModal = ({ tablo, onClose, onSave }: TabloModalProps) => {
|
||||
const [editData, setEditData] = useState<Tablo | null>(tablo);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
|
||||
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
|
||||
const [selectedColor, setSelectedColor] = useState("bg-blue-500");
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditData(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editData && onSave) {
|
||||
// Clear the unused field based on selection
|
||||
const updatedTablo = {
|
||||
...editData,
|
||||
image: creationMode === "image" ? editData.image : undefined,
|
||||
color: creationMode === "color" ? editData.color : undefined,
|
||||
};
|
||||
onSave(updatedTablo);
|
||||
}
|
||||
setEditData(null);
|
||||
};
|
||||
|
||||
if (!tablo) return null;
|
||||
|
||||
const currentData = editData || tablo;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<ClickOutside onClickOutside={onClose}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-4 w-full max-w-xl min-w-[28rem] max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
{isEditingName ? (
|
||||
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
|
||||
<input
|
||||
type="text"
|
||||
value={editData?.name}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
}
|
||||
className="text-2xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none focus:border-blue-600"
|
||||
/>
|
||||
</ClickOutside>
|
||||
) : (
|
||||
<h2
|
||||
className="text-2xl font-bold text-gray-900 dark:text-white cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
>
|
||||
{tablo.name}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 p-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<ImageColorPicker
|
||||
creationMode={creationMode}
|
||||
setCreationMode={setCreationMode}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-4 mb-4">
|
||||
<div>
|
||||
<StatusPicker
|
||||
selectedStatus={currentData.status}
|
||||
setSelectedStatus={(status) =>
|
||||
setEditData((prev) => (prev ? { ...prev, status } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-4 pt-4 pb-1 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md"
|
||||
onClick={handleSaveEdit}
|
||||
>
|
||||
Sauvegarder
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
ui/src/hooks/useClickOutside.ts
Normal file
47
ui/src/hooks/useClickOutside.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook that detects clicks outside of a referenced element
|
||||
* @param callback - Function to call when clicking outside
|
||||
* @returns ref - Ref to attach to the element you want to detect clicks outside of
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
callback: () => void
|
||||
) {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
export const ClickOutside = ({
|
||||
children,
|
||||
callback,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
callback: () => void;
|
||||
}) => {
|
||||
const ref = useClickOutside(callback);
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
ref,
|
||||
},
|
||||
children
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue